← Files WixARCHIVED FILE

skills/wix-app/scripts/scan-a11y-render.cjs

19.6 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

#!/usr/bin/env node
'use strict';

// oxlint-disable no-console preserve-caught-error no-underscore-dangle

/**
 * Render audit for one Editor React Component folder.
 *
 * Renders the component with its default props in plain Node (an SSR check),
 * loads the markup into jsdom together with the component's CSS Modules, and
 * runs axe-core against the rendered root. Prints one JSON line.
 *
 * Spawned by `scan-a11y-review.cjs`; also usable standalone:
 *
 *   node <SKILL_ROOT>/scripts/scan-a11y-render.cjs <component-dir>
 */

const fs = require('fs');
const path = require('path');
const Module = require('module');
const { createRequire } = require('module');
const { pathToFileURL } = require('url');

const ROOT = process.cwd();
const LOCAL_REQUIRE = createRequire(__filename);
const ROOT_REQUIRE = createRequire(path.join(ROOT, 'package.json'));

const RCU_SPECIFIER = '@wix/react-component-utils';
/** Scanner-owned wrapper around the rendered component; axe audits this, never the component's own `id`. */
const AUDIT_ROOT_ATTR = 'data-a11y-audit-root';
const COMPONENT_ID = 'component';
const SOURCE_EXTENSIONS = ['.tsx', '.ts', '.jsx'];
/** Everything else a relative import points at (svg, png, mp4, lottie, scss, ...) is served as its file name. */
const LOADABLE_EXTENSIONS = new Set([
  '.js',
  '.cjs',
  '.mjs',
  '.json',
  '.node',
  '.css',
  ...SOURCE_EXTENSIONS,
]);
const HTML_SNIPPET_LENGTH = 200;
const PLACEHOLDER_TEXT = 'No content to display yet';
/** Test hook: comma-separated tooling packages to treat as missing. */
const SIMULATE_MISSING = new Set(
  (process.env.A11Y_SIMULATE_MISSING || '').split(',').filter(Boolean),
);

/** Rules that only make sense for a whole page, never for a component fragment. */
const PAGE_RULES = [
  'region',
  'landmark-one-main',
  'landmark-banner-is-top-level',
  'landmark-complementary-is-top-level',
  'landmark-contentinfo-is-top-level',
  'landmark-main-is-top-level',
  'landmark-no-duplicate-banner',
  'landmark-no-duplicate-contentinfo',
  'landmark-no-duplicate-main',
  'landmark-unique',
  'page-has-heading-one',
  'document-title',
  'html-has-lang',
  'html-lang-valid',
  'html-xml-lang-mismatch',
  'bypass',
  'meta-viewport',
  'meta-viewport-large',
  'meta-refresh',
  'meta-refresh-no-exceptions',
  // `frame-title` stays on: an unnamed iframe inside a component is the component's defect.
  'frame-title-unique',
  'frame-tested',
  'frame-focusable-content',
  // Deprecated in axe 4.x and noisy; `duplicate-id-aria` stays on.
  'duplicate-id',
  'duplicate-id-active',
];

/** Rules that need a layout engine. Disabled on jsdom; a browser engine would enable them. */
const LAYOUT_RULES = [
  'color-contrast',
  'color-contrast-enhanced',
  'target-size',
  'scrollable-region-focusable',
  'link-in-text-block',
  'p-as-heading',
];

const NOT_CHECKED_BY_JSDOM = [
  ...LAYOUT_RULES,
  '--display-driven visibility',
  'keyboard interaction',
];

const SITE_URL = 'https://example.wixsite.com/site';
const HOME_PAGE = { id: 'home', title: 'Home', path: '/', popup: false };

/** Plain-object stand-ins for the site services `@wix/react-component-utils` hooks read. */
const SERVICES = {
  // `useIsEditMode()` returns `!previewMode`; audit live-equivalent output.
  '@wix/site-service-editor-context': { previewMode: true },
  '@wix/site-service-device-info': { deviceType: 'Desktop', reducedMotion: false },
  '@wix/site-service-locale': { language: 'en', direction: 'ltr' },
  '@wix/site-service-pages': {
    pages: { [HOME_PAGE.id]: { title: HOME_PAGE.title, path: HOME_PAGE.path, popup: false } },
    mainPage: HOME_PAGE,
    currentPage: HOME_PAGE,
  },
  '@wix/site-service-url': {
    currentUrl: SITE_URL,
    siteUrl: SITE_URL,
    pages: [],
    pageIdToPrefix: {},
  },
};

const IMPACT_SEVERITY = { critical: 'high', serious: 'high', moderate: 'medium', minor: 'low' };

// ─────────────────────────────────────────────────────────────────────────────
// Dependencies and entry discovery
// ─────────────────────────────────────────────────────────────────────────────

/** Tooling resolves from the consumer project first, then next to this script. */
function loadTooling(names) {
  const modules = {};
  const missing = [];
  for (const name of names) {
    if (SIMULATE_MISSING.has(name)) {
      missing.push(name);
      continue;
    }
    try {
      modules[name] = ROOT_REQUIRE(name);
    } catch {
      try {
        modules[name] = LOCAL_REQUIRE(name);
      } catch {
        missing.push(name);
      }
    }
  }
  return { modules, missing };
}

function listFiles(dir, predicate, acc = []) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      if (entry.name !== 'node_modules') listFiles(full, predicate, acc);
    } else if (entry.isFile() && predicate(entry.name)) {
      acc.push(full);
    }
  }
  return acc;
}

function resolveEntry(componentDir) {
  const basename = path.basename(componentDir);
  const existing = (name) =>
    fs.existsSync(path.join(componentDir, name)) ? path.join(componentDir, name) : null;
  const tsxFiles = listFiles(componentDir, (name) => name.endsWith('.tsx'));
  const impl =
    existing(`${basename}.tsx`) ||
    tsxFiles.find(
      (file) =>
        path.dirname(file) === componentDir && !path.basename(file).startsWith('component.'),
    ) ||
    null;

  return {
    live: existing('component.tsx'),
    preview: existing('component.preview.tsx'),
    impl,
    props: existing(`${basename}.props.ts`),
    cssFiles: listFiles(componentDir, (name) => name.endsWith('.module.css')),
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// Compile hooks and runtime
// ─────────────────────────────────────────────────────────────────────────────

function installCompileHooks(ts) {
  const compile = (module, filename) => {
    const { outputText } = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
      fileName: filename,
      compilerOptions: {
        module: ts.ModuleKind.CommonJS,
        target: ts.ScriptTarget.ES2020,
        jsx: ts.JsxEmit.ReactJSX,
        esModuleInterop: true,
        allowJs: true,
        isolatedModules: true,
      },
    });
    module._compile(outputText, filename);
  };
  for (const ext of SOURCE_EXTENSIONS) require.extensions[ext] = compile;

  // CSS Modules map every class to its own name so the markup keeps the
  // module class names the component CSS targets.
  const cssProxy = new Proxy(
    {},
    {
      get(_target, prop) {
        if (prop === '__esModule') return true;
        if (prop === 'default') return cssProxy;
        return typeof prop === 'symbol' ? undefined : String(prop);
      },
    },
  );
  require.extensions['.css'] = (module) => {
    module.exports = cssProxy;
  };
}

/**
 * Load React and `@wix/react-component-utils` from the component's own
 * dependency tree so hooks and context share one React instance. The utils
 * package is ESM-only, so it is imported once and served to the transpiled
 * CommonJS component code through `Module._load`.
 */
async function loadRuntime(entryFile) {
  const entryRequire = createRequire(entryFile);
  const React = entryRequire('react');
  const { renderToStaticMarkup } = entryRequire('react-dom/server');

  const rcuDir = (entryRequire.resolve.paths(RCU_SPECIFIER) || [])
    .map((dir) => path.join(dir, RCU_SPECIFIER))
    .find((dir) => fs.existsSync(path.join(dir, 'package.json')));
  if (!rcuDir) {
    throw new Error(`Cannot resolve ${RCU_SPECIFIER} from ${path.relative(ROOT, entryFile)}.`);
  }

  const manifest = JSON.parse(fs.readFileSync(path.join(rcuDir, 'package.json'), 'utf8'));
  const root = manifest.exports && manifest.exports['.'];
  const entry =
    (typeof root === 'string' ? root : root && (root.import || root.default)) || manifest.main;
  const rcu = { __esModule: true, ...(await import(pathToFileURL(path.join(rcuDir, entry)).href)) };

  const originalLoad = Module._load;
  Module._load = function patchedLoad(request, parent, ...rest) {
    if (request === RCU_SPECIFIER) return rcu;
    const query = request.indexOf('?');
    const bare = query === -1 ? request : request.slice(0, query);
    // Packages load as-is; only the component's own relative imports get the asset treatment.
    if (query === -1 && !/^[./]/.test(bare))
      return originalLoad.call(this, request, parent, ...rest);
    // Throws MODULE_NOT_FOUND for a missing file, which the audit reports as a loader limit.
    const file = Module._resolveFilename(bare, parent);
    if (query === -1 && LOADABLE_EXTENSIONS.has(path.extname(file).toLowerCase())) {
      return originalLoad.call(this, request, parent, ...rest);
    }
    // Vite resource queries: `?raw` is the file text, `?url`/`?inline` its name. A bare asset
    // import (mp4, lottie, scss, ...) is its name too; Node would otherwise parse it as JS.
    const raw = query !== -1 && /(^|&)raw(&|$)/.test(request.slice(query + 1));
    return { __esModule: true, default: raw ? fs.readFileSync(file, 'utf8') : path.basename(file) };
  };

  if (!globalThis.WixReactContext) globalThis.WixReactContext = React.createContext(undefined);
  return { React, renderToStaticMarkup, rcu, WixContext: globalThis.WixReactContext };
}

function createServicesProvider(React, WixContext) {
  const manager = {
    getService(definition) {
      const id = String(definition);
      if (!(id in SERVICES)) {
        throw new Error(`Unknown site service "${id}" requested during render.`);
      }
      return SERVICES[id];
    },
    hasService(definition) {
      return String(definition) in SERVICES;
    },
  };
  return ({ children }) => React.createElement(WixContext.Provider, { value: manager }, children);
}

const interopDefault = (loaded) =>
  loaded && loaded.__esModule && loaded.default !== undefined ? loaded.default : loaded;

function loadComponent(entry, runtime) {
  if (entry.live) {
    return { Component: interopDefault(require(entry.live)), source: 'component.tsx' };
  }
  if (!entry.impl) throw new Error('No component.tsx or implementation .tsx found.');
  const Impl = interopDefault(require(entry.impl));
  const defaultProps = entry.props ? require(entry.props).defaultProps || {} : {};
  return {
    Component: runtime.rcu.withDefaults(Impl, defaultProps),
    source: path.basename(entry.impl),
  };
}

/** The wrapper is the audit boundary, so a component that forwards `id` to an inner element still gets all of its output audited. */
function render(runtime, Provider, Component) {
  const { React, renderToStaticMarkup } = runtime;
  return renderToStaticMarkup(
    React.createElement(
      Provider,
      null,
      React.createElement(
        'div',
        { [AUDIT_ROOT_ATTR]: '' },
        React.createElement(Component, { id: COMPONENT_ID }),
      ),
    ),
  );
}

const LOADER_ERROR_CODES = new Set([
  'MODULE_NOT_FOUND',
  'ERR_REQUIRE_ESM',
  'ERR_REQUIRE_ASYNC_MODULE',
  'ERR_UNKNOWN_FILE_EXTENSION',
]);

/** The component could not be loaded at all: a scanner limitation, never a finding. */
const isLoaderError = (error) =>
  Boolean(error) &&
  (LOADER_ERROR_CODES.has(error.code) || /Cannot find module/.test(error.message || ''));

function classifyRenderError(error) {
  const message = error && error.message ? error.message : String(error);
  if (/No ServiceManagerProvider|Unknown site service/.test(message)) {
    return {
      rule: 'render-unsupported-hook',
      severity: 'low',
      confidence: 'low',
      message: `The component reads a site service the audit cannot provide: ${message}`,
    };
  }
  const browserGlobal =
    /\b(window|document|navigator|localStorage|sessionStorage|matchMedia)\b.*is not defined/.test(
      message,
    );
  return {
    rule: 'render-failed',
    severity: 'high',
    confidence: 'high',
    message: browserGlobal
      ? `The first render touches a browser global: ${message}`
      : `Rendering with defaultProps threw: ${message}`,
  };
}

const checkPreviewPlaceholder = (html) =>
  html.includes(PLACEHOLDER_TEXT) || /class="[^"]*_placeholder_/.test(html);

// ─────────────────────────────────────────────────────────────────────────────
// jsdom + axe
// ─────────────────────────────────────────────────────────────────────────────

/** Strip CSS Modules syntax jsdom cannot parse; keep every declaration. */
function normalizeModuleCss(css) {
  return css
    .replace(/@value[^;]*;/g, '')
    .replace(/\bcomposes\s*:[^;{}]*;/g, '')
    .replace(/:global\(([^)]*)\)/g, '$1')
    .replace(/:local\(([^)]*)\)/g, '$1')
    .replace(/:global\s+/g, '');
}

function truncate(text, length) {
  const compact = String(text || '').replace(/\s+/g, ' ');
  return compact.length > length ? `${compact.slice(0, length - 1)}…` : compact;
}

async function runAxe(jsdom, axe, html, css) {
  const dom = new jsdom.JSDOM(
    `<!doctype html><html lang="en"><head><style>${css}</style></head><body>${html}</body></html>`,
    { virtualConsole: new jsdom.VirtualConsole() },
  );

  const { window } = dom;
  const previous = { window: globalThis.window, document: globalThis.document };
  globalThis.window = window;
  globalThis.document = window.document;
  try {
    const known = new Set(axe.getRules().map((rule) => rule.ruleId));
    const rules = {};
    for (const ruleId of [...PAGE_RULES, ...LAYOUT_RULES]) {
      if (known.has(ruleId)) rules[ruleId] = { enabled: false };
    }
    const root = window.document.querySelector(`[${AUDIT_ROOT_ATTR}]`) || window.document.body;
    const results = await axe.run(root, { rules, resultTypes: ['violations'], elementRef: false });

    const findings = [];
    for (const violation of results.violations) {
      for (const node of violation.nodes) {
        findings.push({
          rule: violation.id,
          source: 'render',
          severity: IMPACT_SEVERITY[violation.impact] || 'medium',
          confidence: 'high',
          message: violation.help,
          detail: truncate(node.failureSummary, 240),
          target: node.target.join(' '),
          html: truncate(node.html, HTML_SNIPPET_LENGTH),
          helpUrl: violation.helpUrl,
        });
      }
    }
    return { findings, rulesRun: known.size - Object.keys(rules).length };
  } finally {
    globalThis.window = previous.window;
    globalThis.document = previous.document;
    if (previous.window === undefined) delete globalThis.window;
    if (previous.document === undefined) delete globalThis.document;
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────────────────────

async function audit(componentDir) {
  const report = {
    ok: false,
    engine: 'jsdom',
    componentDir: path.relative(ROOT, componentDir) || '.',
    entry: null,
    preview: null,
    ssr: null,
    axe: null,
    findings: [],
    notChecked: [...NOT_CHECKED_BY_JSDOM],
  };

  if (!fs.existsSync(componentDir) || !fs.statSync(componentDir).isDirectory()) {
    report.error = `Component directory not found: ${componentDir}`;
    return report;
  }

  const { modules, missing } = loadTooling(['typescript', 'jsdom', 'axe-core']);
  if (missing.length > 0) {
    report.reason = 'missing-deps';
    report.missing = missing;
    report.error = `Missing render dependencies: ${missing.join(', ')}. Install them (SKILL.md step 2) and rerun.`;
    return report;
  }

  const entry = resolveEntry(componentDir);
  if (!entry.live && !entry.impl) {
    report.error = 'No component.tsx or implementation .tsx found.';
    return report;
  }

  installCompileHooks(modules.typescript);
  const runtime = await loadRuntime(entry.live || entry.impl);
  const Provider = createServicesProvider(runtime.React, runtime.WixContext);

  // Live render: the markup visitors get.
  let liveHtml = null;
  try {
    const { Component, source } = loadComponent(entry, runtime);
    report.entry = source;
    liveHtml = render(runtime, Provider, Component);
    report.ssr = { ok: true, bytes: liveHtml.length };
  } catch (error) {
    if (isLoaderError(error)) {
      report.reason = 'loader';
      report.error = truncate(error.stack || error.message, 600);
      return report;
    }
    report.ssr = { ok: false, error: truncate(error && error.stack ? error.stack : error, 600) };
    report.findings.push({ ...classifyRenderError(error), source: 'render' });
  }

  // Preview render: must not fall back to the placeholder with default props.
  if (entry.preview) {
    try {
      const Preview = interopDefault(require(entry.preview));
      const placeholder = checkPreviewPlaceholder(render(runtime, Provider, Preview));
      report.preview = placeholder ? 'placeholder' : 'ok';
      if (placeholder) {
        report.findings.push({
          rule: 'preview-placeholder',
          source: 'render',
          severity: 'high',
          confidence: 'high',
          message:
            'component.preview.tsx renders the fallback placeholder with defaultProps, so the editor shows an empty box.',
        });
      }
    } catch (error) {
      if (isLoaderError(error)) {
        report.reason = 'loader';
        report.error = truncate(error.stack || error.message, 600);
        return report;
      }
      report.preview = 'failed';
      report.findings.push({
        rule: 'preview-render-failed',
        source: 'render',
        severity: 'medium',
        confidence: 'high',
        message: `component.preview.tsx threw with defaultProps: ${truncate(error && error.message, 200)}`,
      });
    }
  }

  if (liveHtml !== null) {
    const css = entry.cssFiles
      .map((file) => normalizeModuleCss(fs.readFileSync(file, 'utf8')))
      .join('\n');
    const result = await runAxe(modules.jsdom, modules['axe-core'], liveHtml, css);
    report.axe = { rulesRun: result.rulesRun };
    report.findings.push(...result.findings);
  }

  report.ok = true;
  return report;
}

async function main() {
  const componentDir = process.argv.slice(2).find((arg) => !arg.startsWith('--'));
  if (!componentDir) {
    console.log(
      JSON.stringify({
        ok: false,
        usage: 'node <SKILL_ROOT>/scripts/scan-a11y-render.cjs <component-dir>',
      }),
    );
    process.exit(2);
  }
  try {
    console.log(JSON.stringify(await audit(path.resolve(componentDir))));
  } catch (error) {
    console.log(
      JSON.stringify({
        ok: false,
        error: truncate(error && error.stack ? error.stack : error, 800),
      }),
    );
    process.exit(2);
  }
}

module.exports = {
  PAGE_RULES,
  LAYOUT_RULES,
  normalizeModuleCss,
  classifyRenderError,
  checkPreviewPlaceholder,
};

if (require.main === module) main();

SHA-256: bb3388899fa74d097b3c50c6dd083ed77b92a19892694c82357d6d2356624ad3