← Files WixARCHIVED FILE

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

10.1 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

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

const ROOT = process.cwd();
const LOCAL_REQUIRE = createRequire(__filename);
const ROOT_REQUIRE = createRequire(path.join(ROOT, 'package.json'));
const { ESLint } = loadModule('eslint');
const jsxA11y = loadModule('eslint-plugin-jsx-a11y');
const tsParser = loadModule('@typescript-eslint/parser');

const RULE_CONFIG = {
  'jsx-a11y/alt-text': 'error',
  'jsx-a11y/anchor-has-content': 'error',
  'jsx-a11y/anchor-is-valid': 'error',
  'jsx-a11y/aria-activedescendant-has-tabindex': 'error',
  'jsx-a11y/aria-props': 'error',
  'jsx-a11y/aria-proptypes': 'error',
  'jsx-a11y/aria-role': 'error',
  'jsx-a11y/aria-unsupported-elements': 'error',
  'jsx-a11y/click-events-have-key-events': 'error',
  'jsx-a11y/heading-has-content': 'error',
  'jsx-a11y/iframe-has-title': 'error',
  'jsx-a11y/img-redundant-alt': 'error',
  'jsx-a11y/interactive-supports-focus': 'error',
  'jsx-a11y/label-has-associated-control': 'error',
  'jsx-a11y/media-has-caption': 'error',
  'jsx-a11y/mouse-events-have-key-events': 'error',
  'jsx-a11y/no-access-key': 'error',
  'jsx-a11y/no-aria-hidden-on-focusable': 'error',
  'jsx-a11y/no-autofocus': 'error',
  'jsx-a11y/no-distracting-elements': 'error',
  'jsx-a11y/no-interactive-element-to-noninteractive-role': ['error', { canvas: ['img'] }],
  'jsx-a11y/no-noninteractive-element-interactions': 'error',
  'jsx-a11y/no-noninteractive-element-to-interactive-role': 'error',
  // A tabpanel with no focusable content takes tabIndex=0 (ARIA Authoring Practices).
  'jsx-a11y/no-noninteractive-tabindex': ['error', { roles: ['tabpanel'] }],
  // Safari drops <ul> list semantics with list-style: none; role="list" restores
  // them (WebKit bug 170179). Keep all other redundant roles flagged.
  'jsx-a11y/no-redundant-roles': ['error', { ul: ['list'] }],
  'jsx-a11y/no-static-element-interactions': 'error',
  'jsx-a11y/prefer-tag-over-role': 'error',
  'jsx-a11y/role-has-required-aria-props': 'error',
  'jsx-a11y/role-supports-aria-props': 'error',
  'jsx-a11y/scope': 'error',
  'jsx-a11y/tabindex-no-positive': 'error',
};

function toRelative(filePath) {
  return path.relative(ROOT, filePath) || filePath;
}

/** DOM handler → SDK prop it forwards (FUNCTION-HANDLERS.md). */
const SDK_HANDLER_PROPS = {
  onClick: 'onClick',
  onDoubleClick: 'onDblClick',
  onMouseEnter: 'onMouseIn',
  onMouseLeave: 'onMouseOut',
};
const isSdkRoot = (tag) => /\bid=\{\s*(?:props\.)?id\s*\}/.test(tag);

/** Every handler on the tag forwards its SDK prop, `onClick={onClick}` or `onMouseEnter={props.onMouseIn}`; anything else makes the root a control. */
function forwardsOnlySdkHandlers(tag) {
  const handlers = [...tag.matchAll(/\b(on[A-Z]\w*)=\{([^}]*)\}/g)];
  return (
    handlers.length > 0 &&
    handlers.every(([, name, value]) => {
      const sdkProp = SDK_HANDLER_PROPS[name];
      return Boolean(sdkProp) && value.trim().replace(/^props\./, '') === sdkProp;
    })
  );
}

const PASSIVE_AUTOPLAY_HANDLERS = new Set([
  'onMouseEnter',
  'onMouseLeave',
  'onFocus',
  'onBlur',
  'onFocusCapture',
  'onBlurCapture',
]);

/** Carousel roots observe hover/focus to pause autoplay; these handlers do not activate the region. */
function pausesAutoplayOnly(tag) {
  if (!isSdkRoot(tag) || !tag.includes('data-pause-button-visibility=')) return false;
  const handlers = [...tag.matchAll(/\b(on[A-Z]\w*)=\{[^}]*\}/g)];
  return handlers.length > 0 && handlers.every(([, name]) => PASSIVE_AUTOPLAY_HANDLERS.has(name));
}

/** Roles whose native element cannot express a styled component. */
const ROLES_WITHOUT_NATIVE_TAG = new Set(['img', 'presentation', 'none', 'group', 'status']);

/**
 * `role="img"` or `role={cond ? 'img' : undefined}`: every string literal in the role
 * attribute must be an exempt role, so `role={cond ? 'button' : 'img'}` stays flagged.
 */
function roleHasNoNativeTag(tag) {
  const attr = tag.match(/\brole=(?:"[^"]*"|'[^']*'|\{[^}]*\})/);
  if (!attr) return false;
  const literals = [...attr[0].slice(5).matchAll(/["']([^"']*)["']/g)].map((m) => m[1]);
  return literals.length > 0 && literals.every((role) => ROLES_WITHOUT_NATIVE_TAG.has(role));
}

/**
 * Editor React Component patterns that jsx-a11y reads as defects. A rule in
 * `rules` is skipped when the opening tag of the reported element matches `tag`.
 */
const EXEMPTIONS = [
  {
    // ANIMATED-COMPONENTS.md requires the noninteractive carousel root to
    // observe hover and focus so autoplay stops while a visitor is using it.
    rules: new Set(['jsx-a11y/no-noninteractive-element-interactions']),
    tag: pausesAutoplayOnly,
  },
  {
    // SDK handlers (`onClick`, `onMouseIn`, ...) are forwarded on the root
    // element, which carries `id={id}`; the root itself is not the control.
    rules: new Set([
      'jsx-a11y/click-events-have-key-events',
      'jsx-a11y/mouse-events-have-key-events',
      'jsx-a11y/no-noninteractive-element-interactions',
      'jsx-a11y/no-static-element-interactions',
    ]),
    tag: (tag) => isSdkRoot(tag) && forwardsOnlySdkHandlers(tag),
  },
  {
    // Roles whose native element cannot express a styled component: graphics
    // (svg, canvas, star ratings), decorative wrappers, widget groups, live
    // regions. axe still requires their names (`role-img-alt`).
    rules: new Set(['jsx-a11y/prefer-tag-over-role']),
    tag: roleHasNoNativeTag,
  },
];

/** Text of the opening tag that contains the reported range. */
function openingTag(source, msg) {
  const lines = source.split('\n');
  const offset =
    lines.slice(0, msg.line - 1).reduce((n, line) => n + line.length + 1, 0) + msg.column - 1;
  const start = source.lastIndexOf('<', offset);
  if (start === -1) return '';
  // The tag ends at the first `>` outside braces, so `=>` inside handlers does not count.
  let depth = 0;
  for (let i = start; i < source.length; i++) {
    if (source[i] === '{') depth++;
    else if (source[i] === '}') depth--;
    else if (source[i] === '>' && depth === 0) return source.slice(start, i);
  }
  return '';
}

const isExempt = (source, msg) =>
  EXEMPTIONS.some((x) => x.rules.has(msg.ruleId) && x.tag(openingTag(source, msg)));

function severityLabel(severity) {
  if (severity === 2) return 'error';
  if (severity === 1) return 'warning';
  return 'off';
}

function loadModule(name) {
  try {
    return LOCAL_REQUIRE(name);
  } catch (localError) {
    try {
      return ROOT_REQUIRE(name);
    } catch (rootError) {
      throw new Error(
        `Missing dependency "${name}". Local resolution failed: ${localError.message}. Root resolution failed: ${rootError.message}`,
      );
    }
  }
}

function createEslint() {
  try {
    return new ESLint({
      overrideConfigFile: true,
      overrideConfig: [
        {
          files: ['**/*.{tsx,jsx,ts,js}'],
          languageOptions: {
            parser: tsParser,
            parserOptions: {
              ecmaFeatures: { jsx: true },
              ecmaVersion: 2022,
              sourceType: 'module',
            },
          },
          plugins: {
            'jsx-a11y': jsxA11y,
          },
          rules: RULE_CONFIG,
        },
      ],
    });
  } catch (flatConfigError) {
    try {
      return new ESLint({
        useEslintrc: false,
        overrideConfig: {
          parser: '@typescript-eslint/parser',
          plugins: ['jsx-a11y'],
          parserOptions: {
            ecmaFeatures: { jsx: true },
            ecmaVersion: 2022,
            sourceType: 'module',
          },
          rules: RULE_CONFIG,
        },
        extensions: ['.tsx', '.jsx', '.ts', '.js'],
      });
    } catch (legacyConfigError) {
      throw new Error(
        `Failed to initialize ESLint. Flat config error: ${flatConfigError.message}. Legacy config error: ${legacyConfigError.message}`,
      );
    }
  }
}

/**
 * Lint the given files with the jsx-a11y rule set.
 * Returns the same report shape the CLI prints.
 */
async function scan(files) {
  const absoluteFiles = files.map((f) => path.resolve(f));

  const eslint = createEslint();

  const results = await eslint.lintFiles(absoluteFiles);

  const findings = [];
  const parseErrors = [];

  for (const result of results) {
    const relFile = toRelative(result.filePath);
    const source = () => result.source || fs.readFileSync(result.filePath, 'utf8');

    for (const msg of result.messages) {
      if (msg.fatal) {
        parseErrors.push({
          file: relFile,
          line: msg.line,
          column: msg.column,
          message: msg.message,
        });
        continue;
      }

      if (!msg.ruleId || !msg.ruleId.startsWith('jsx-a11y/')) continue;
      if (isExempt(source(), msg)) continue;

      findings.push({
        file: relFile,
        line: msg.line,
        column: msg.column,
        endLine: msg.endLine ?? null,
        endColumn: msg.endColumn ?? null,
        rule: msg.ruleId,
        severity: severityLabel(msg.severity),
        message: msg.message,
      });
    }
  }

  const ruleBreakdown = {};
  for (const f of findings) {
    ruleBreakdown[f.rule] = (ruleBreakdown[f.rule] || 0) + 1;
  }

  return {
    meta: {
      filesScanned: files.length,
      engine: 'eslint + eslint-plugin-jsx-a11y',
      rulesEnabled: Object.keys(RULE_CONFIG).length,
      parseErrors,
    },
    findings,
    summary: {
      totalFindings: findings.length,
      filesWithFindings: new Set(findings.map((f) => f.file)).size,
      cleanFiles: files.length - new Set(findings.map((f) => f.file)).size,
      ruleBreakdown,
    },
  };
}

async function main() {
  const files = process.argv.slice(2);
  if (files.length === 0) {
    console.log(
      JSON.stringify(
        {
          error: 'No files specified.',
          usage: 'node <SKILL_ROOT>/scripts/scan-a11y-eslint.cjs <file1> [file2] ...',
        },
        null,
        2,
      ),
    );
    process.exit(1);
  }

  console.log(JSON.stringify(await scan(files), null, 2));
}

module.exports = { scan, RULE_CONFIG };

if (require.main === module) {
  main().catch((err) => {
    console.error(JSON.stringify({ error: err.message }, null, 2));
    process.exit(1);
  });
}

SHA-256: 4381f2bee658c96b026d27b36584c40fe4f8f3f6c4b0c0fc7d83a96fb192b815