Setting the file. One moment.
Scan A11Y Render · Wix App · wix/skills · Skills Docs
ContentsBack to the top of the page const isLoaderError
— line 322
This file
Number 1.80
Position 80 of 81
Type JavaScript
Size 20 KB
Lines 551 scripts/ scan-a11y-render.cjs
JavaScript · 551 lines · 20 KB
16 */
17
18 const fs = require ( 'fs' );
19 const path = require ( 'path' );
20 const Module = require ( 'module' );
21 const { createRequire } = require ( 'module' );
22 const { pathToFileURL } = require ( 'url' );
23
24 const ROOT = process. cwd ();
25 const LOCAL_REQUIRE = createRequire (__filename);
26 const ROOT_REQUIRE = createRequire (path. join ( ROOT , 'package.json' ));
27
28 const RCU_SPECIFIER = '@wix/react-component-utils' ;
29 /** Scanner-owned wrapper around the rendered component; axe audits this, never the component's own `id`. */
30 const AUDIT_ROOT_ATTR = 'data-a11y-audit-root' ;
31 const COMPONENT_ID = 'component' ;
32 const SOURCE_EXTENSIONS = [ '.tsx' , '.ts' , '.jsx' ];
33 /** Everything else a relative import points at (svg, png, mp4, lottie, scss, ...) is served as its file name. */
34 const LOADABLE_EXTENSIONS = new Set ([
35 '.js' ,
36 '.cjs' ,
37 '.mjs' ,
38 '.json' ,
39 '.node' ,
40 '.css' ,
41 ... SOURCE_EXTENSIONS ,
42 ]);
43 const HTML_SNIPPET_LENGTH = 200 ;
44 const PLACEHOLDER_TEXT = 'No content to display yet' ;
45 /** Test hook: comma-separated tooling packages to treat as missing. */
46 const SIMULATE_MISSING = new Set (
47 (process.env. A11Y_SIMULATE_MISSING || '' ). split ( ',' ). filter (Boolean),
48 );
49
50 /** Rules that only make sense for a whole page, never for a component fragment. */
51 const PAGE_RULES = [
52 'region' ,
53 'landmark-one-main' ,
54 'landmark-banner-is-top-level' ,
55 'landmark-complementary-is-top-level' ,
56 'landmark-contentinfo-is-top-level' ,
57 'landmark-main-is-top-level' ,
58 'landmark-no-duplicate-banner' ,
59 'landmark-no-duplicate-contentinfo' ,
60 'landmark-no-duplicate-main' ,
61 'landmark-unique' ,
62 'page-has-heading-one' ,
63 'document-title' ,
64 'html-has-lang' ,
65 'html-lang-valid' ,
66 'html-xml-lang-mismatch' ,
67 'bypass' ,
68 'meta-viewport' ,
69 'meta-viewport-large' ,
70 'meta-refresh' ,
71 'meta-refresh-no-exceptions' ,
72 // `frame-title` stays on: an unnamed iframe inside a component is the component's defect.
73 'frame-title-unique' ,
74 'frame-tested' ,
75 'frame-focusable-content' ,
76 // Deprecated in axe 4.x and noisy; `duplicate-id-aria` stays on.
77 'duplicate-id' ,
78 'duplicate-id-active' ,
79 ];
80
81 /** Rules that need a layout engine. Disabled on jsdom; a browser engine would enable them. */
82 const LAYOUT_RULES = [
83 'color-contrast' ,
84 'color-contrast-enhanced' ,
85 'target-size' ,
86 'scrollable-region-focusable' ,
87 'link-in-text-block' ,
88 'p-as-heading' ,
89 ];
90
91 const NOT_CHECKED_BY_JSDOM = [
92 ... LAYOUT_RULES ,
93 '--display-driven visibility' ,
94 'keyboard interaction' ,
95 ];
96
97 const SITE_URL = 'https://example.wixsite.com/site' ;
98 const HOME_PAGE = { id: 'home' , title: 'Home' , path: '/' , popup: false };
99
100 /** Plain-object stand-ins for the site services `@wix/react-component-utils` hooks read. */
101 const SERVICES = {
102 // `useIsEditMode()` returns `!previewMode`; audit live-equivalent output.
103 '@wix/site-service-editor-context' : { previewMode: true },
104 '@wix/site-service-device-info' : { deviceType: 'Desktop' , reducedMotion: false },
105 '@wix/site-service-locale' : { language: 'en' , direction: 'ltr' },
106 '@wix/site-service-pages' : {
107 pages: { [ HOME_PAGE .id]: { title: HOME_PAGE .title, path: HOME_PAGE .path, popup: false } },
108 mainPage: HOME_PAGE ,
109 currentPage: HOME_PAGE ,
110 },
111 '@wix/site-service-url' : {
112 currentUrl: SITE_URL ,
113 siteUrl: SITE_URL ,
114 pages: [],
115 pageIdToPrefix: {},
116 },
117 };
118
119 const IMPACT_SEVERITY = { critical: 'high' , serious: 'high' , moderate: 'medium' , minor: 'low' };
120
121 // ─────────────────────────────────────────────────────────────────────────────
122 // Dependencies and entry discovery
123 // ─────────────────────────────────────────────────────────────────────────────
124
125 /** Tooling resolves from the consumer project first, then next to this script. */
126 function loadTooling ( names ) {
127 const modules = {};
128 const missing = [];
129 for ( const name of names) {
130 if ( SIMULATE_MISSING . has (name)) {
131 missing. push (name);
132 continue ;
133 }
134 try {
135 modules[name] = ROOT_REQUIRE (name);
136 } catch {
137 try {
138 modules[name] = LOCAL_REQUIRE (name);
139 } catch {
140 missing. push (name);
141 }
142 }
143 }
144 return { modules, missing };
145 }
146
147 function listFiles ( dir , predicate , acc = []) {
148 for ( const entry of fs. readdirSync (dir, { withFileTypes: true })) {
149 const full = path. join (dir, entry.name);
150 if (entry. isDirectory ()) {
151 if (entry.name !== 'node_modules' ) listFiles (full, predicate, acc);
152 } else if (entry. isFile () && predicate (entry.name)) {
153 acc. push (full);
154 }
155 }
156 return acc;
157 }
158
159 function resolveEntry ( componentDir ) {
160 const basename = path. basename (componentDir);
161 const existing = ( name ) =>
162 fs. existsSync (path. join (componentDir, name)) ? path. join (componentDir, name) : null ;
163 const tsxFiles = listFiles (componentDir, ( name ) => name. endsWith ( '.tsx' ));
164 const impl =
165 existing ( `${ basename }.tsx` ) ||
166 tsxFiles. find (
167 ( file ) =>
168 path. dirname (file) === componentDir && ! path. basename (file). startsWith ( 'component.' ),
169 ) ||
170 null ;
171
172 return {
173 live: existing ( 'component.tsx' ),
174 preview: existing ( 'component.preview.tsx' ),
175 impl,
176 props: existing ( `${ basename }.props.ts` ),
177 cssFiles: listFiles (componentDir, ( name ) => name. endsWith ( '.module.css' )),
178 };
179 }
180
181 // ─────────────────────────────────────────────────────────────────────────────
182 // Compile hooks and runtime
183 // ─────────────────────────────────────────────────────────────────────────────
184
185 function installCompileHooks ( ts ) {
186 const compile = ( module , filename ) => {
187 const { outputText } = ts. transpileModule (fs. readFileSync (filename, 'utf8' ), {
188 fileName: filename,
189 compilerOptions: {
190 module: ts.ModuleKind.CommonJS,
191 target: ts.ScriptTarget. ES2020 ,
192 jsx: ts.JsxEmit.ReactJSX,
193 esModuleInterop: true ,
194 allowJs: true ,
195 isolatedModules: true ,
196 },
197 });
198 module . _compile (outputText, filename);
199 };
200 for ( const ext of SOURCE_EXTENSIONS ) require.extensions[ext] = compile;
201
202 // CSS Modules map every class to its own name so the markup keeps the
203 // module class names the component CSS targets.
204 const cssProxy = new Proxy (
205 {},
206 {
207 get ( _target , prop ) {
208 if (prop === '__esModule' ) return true ;
209 if (prop === 'default' ) return cssProxy;
210 return typeof prop === 'symbol' ? undefined : String (prop);
211 },
212 },
213 );
214 require.extensions[ '.css' ] = ( module ) => {
215 module . exports = cssProxy;
216 };
217 }
218
219 /**
220 * Load React and `@wix/react-component-utils` from the component's own
221 * dependency tree so hooks and context share one React instance. The utils
222 * package is ESM-only, so it is imported once and served to the transpiled
223 * CommonJS component code through `Module._load`.
224 */
225 async function loadRuntime ( entryFile ) {
226 const entryRequire = createRequire (entryFile);
227 const React = entryRequire ( 'react' );
228 const { renderToStaticMarkup } = entryRequire ( 'react-dom/server' );
229
230 const rcuDir = (entryRequire.resolve. paths ( RCU_SPECIFIER ) || [])
231 . map (( dir ) => path. join (dir, RCU_SPECIFIER ))
232 . find (( dir ) => fs. existsSync (path. join (dir, 'package.json' )));
233 if ( ! rcuDir) {
234 throw new Error ( `Cannot resolve ${ RCU_SPECIFIER } from ${ path . relative ( ROOT , entryFile ) }.` );
235 }
236
237 const manifest = JSON . parse (fs. readFileSync (path. join (rcuDir, 'package.json' ), 'utf8' ));
238 const root = manifest.exports && manifest.exports[ '.' ];
239 const entry =
240 ( typeof root === 'string' ? root : root && (root.import || root.default)) || manifest.main;
241 const rcu = { __esModule: true , ... ( await import ( pathToFileURL (path. join (rcuDir, entry)).href)) };
242
243 const originalLoad = Module._load;
244 Module. _load = function patchedLoad ( request , parent , ... rest ) {
245 if (request === RCU_SPECIFIER ) return rcu;
246 const query = request. indexOf ( '?' );
247 const bare = query === - 1 ? request : request. slice ( 0 , query);
248 // Packages load as-is; only the component's own relative imports get the asset treatment.
249 if (query === - 1 && ! / ^ [./] / . test (bare))
250 return originalLoad. call ( this , request, parent, ... rest);
251 // Throws MODULE_NOT_FOUND for a missing file, which the audit reports as a loader limit.
252 const file = Module. _resolveFilename (bare, parent);
253 if (query === - 1 && LOADABLE_EXTENSIONS . has (path. extname (file). toLowerCase ())) {
254 return originalLoad. call ( this , request, parent, ... rest);
255 }
256 // Vite resource queries: `?raw` is the file text, `?url`/`?inline` its name. A bare asset
257 // import (mp4, lottie, scss, ...) is its name too; Node would otherwise parse it as JS.
258 const raw = query !== - 1 && /( ^| &)raw(& |$ )/ . test (request. slice (query + 1 ));
259 return { __esModule: true , default: raw ? fs. readFileSync (file, 'utf8' ) : path. basename (file) };
260 };
261
262 if ( ! globalThis.WixReactContext) globalThis.WixReactContext = React. createContext ( undefined );
263 return { React, renderToStaticMarkup, rcu, WixContext: globalThis.WixReactContext };
264 }
265
266 function createServicesProvider ( React , WixContext ) {
267 const manager = {
268 getService ( definition ) {
269 const id = String (definition);
270 if ( ! (id in SERVICES )) {
271 throw new Error ( `Unknown site service "${ id }" requested during render.` );
272 }
273 return SERVICES [id];
274 },
275 hasService ( definition ) {
276 return String (definition) in SERVICES ;
277 },
278 };
279 return ({ children }) => React. createElement (WixContext.Provider, { value: manager }, children);
280 }
281
282 const interopDefault = ( loaded ) =>
283 loaded && loaded.__esModule && loaded.default !== undefined ? loaded.default : loaded;
284
285 function loadComponent ( entry , runtime ) {
286 if (entry.live) {
287 return { Component: interopDefault ( require (entry.live)), source: 'component.tsx' };
288 }
289 if ( ! entry.impl) throw new Error ( 'No component.tsx or implementation .tsx found.' );
290 const Impl = interopDefault ( require (entry.impl));
291 const defaultProps = entry.props ? require (entry.props).defaultProps || {} : {};
292 return {
293 Component: runtime.rcu. withDefaults (Impl, defaultProps),
294 source: path. basename (entry.impl),
295 };
296 }
297
298 /** The wrapper is the audit boundary, so a component that forwards `id` to an inner element still gets all of its output audited. */
299 function render ( runtime , Provider , Component ) {
300 const { React , renderToStaticMarkup } = runtime;
301 return renderToStaticMarkup (
302 React. createElement (
303 Provider,
304 null ,
305 React. createElement (
306 'div' ,
307 { [ AUDIT_ROOT_ATTR ]: '' },
308 React. createElement (Component, { id: COMPONENT_ID }),
309 ),
310 ),
311 );
312 }
313
314 const LOADER_ERROR_CODES = new Set ([
315 'MODULE_NOT_FOUND' ,
316 'ERR_REQUIRE_ESM' ,
317 'ERR_REQUIRE_ASYNC_MODULE' ,
318 'ERR_UNKNOWN_FILE_EXTENSION' ,
319 ]);
320
321 /** The component could not be loaded at all: a scanner limitation, never a finding. */
322 const isLoaderError = ( error ) =>
323 Boolean (error) &&
324 ( LOADER_ERROR_CODES . has (error.code) || /Cannot find module/ . test (error.message || '' ));
325
326 function classifyRenderError ( error ) {
327 const message = error && error.message ? error.message : String (error);
328 if ( /No ServiceManagerProvider | Unknown site service/ . test (message)) {
329 return {
330 rule: 'render-unsupported-hook' ,
331 severity: 'low' ,
332 confidence: 'low' ,
333 message: `The component reads a site service the audit cannot provide: ${ message }` ,
334 };
335 }
336 const browserGlobal =
337 / \b (window | document | navigator | localStorage | sessionStorage | matchMedia) \b . * is not defined/ . test (
338 message,
339 );
340 return {
341 rule: 'render-failed' ,
342 severity: 'high' ,
343 confidence: 'high' ,
344 message: browserGlobal
345 ? `The first render touches a browser global: ${ message }`
346 : `Rendering with defaultProps threw: ${ message }` ,
347 };
348 }
349
350 const checkPreviewPlaceholder = ( html ) =>
351 html. includes ( PLACEHOLDER_TEXT ) || /class=" [ ^ "] * _placeholder_/ . test (html);
352
353 // ─────────────────────────────────────────────────────────────────────────────
354 // jsdom + axe
355 // ─────────────────────────────────────────────────────────────────────────────
356
357 /** Strip CSS Modules syntax jsdom cannot parse; keep every declaration. */
358 function normalizeModuleCss ( css ) {
359 return css
360 . replace ( /@value [ ^ ;] * ;/ g , '' )
361 . replace ( / \b composes \s * : [ ^ ;{}] * ;/ g , '' )
362 . replace ( /:global \( ( [ ^ )] * ) \) / g , '$1' )
363 . replace ( /:local \( ( [ ^ )] * ) \) / g , '$1' )
364 . replace ( /:global \s + / g , '' );
365 }
366
367 function truncate ( text , length ) {
368 const compact = String (text || '' ). replace ( / \s + / g , ' ' );
369 return compact. length > length ? `${ compact . slice ( 0 , length - 1 ) }…` : compact;
370 }
371
372 async function runAxe ( jsdom , axe , html , css ) {
373 const dom = new jsdom. JSDOM (
374 `<!doctype html><html lang="en"><head><style>${ css }</style></head><body>${ html }</body></html>` ,
375 { virtualConsole: new jsdom. VirtualConsole () },
376 );
377
378 const { window } = dom;
379 const previous = { window: globalThis.window, document: globalThis.document };
380 globalThis.window = window;
381 globalThis.document = window.document;
382 try {
383 const known = new Set (axe. getRules (). map (( rule ) => rule.ruleId));
384 const rules = {};
385 for ( const ruleId of [ ... PAGE_RULES , ... LAYOUT_RULES ]) {
386 if (known. has (ruleId)) rules[ruleId] = { enabled: false };
387 }
388 const root = window.document. querySelector ( `[${ AUDIT_ROOT_ATTR }]` ) || window.document.body;
389 const results = await axe. run (root, { rules, resultTypes: [ 'violations' ], elementRef: false });
390
391 const findings = [];
392 for ( const violation of results.violations) {
393 for ( const node of violation.nodes) {
394 findings. push ({
395 rule: violation.id,
396 source: 'render' ,
397 severity: IMPACT_SEVERITY [violation.impact] || 'medium' ,
398 confidence: 'high' ,
399 message: violation.help,
400 detail: truncate (node.failureSummary, 240 ),
401 target: node.target. join ( ' ' ),
402 html: truncate (node.html, HTML_SNIPPET_LENGTH ),
403 helpUrl: violation.helpUrl,
404 });
405 }
406 }
407 return { findings, rulesRun: known.size - Object. keys (rules). length };
408 } finally {
409 globalThis.window = previous.window;
410 globalThis.document = previous.document;
411 if (previous.window === undefined ) delete globalThis.window;
412 if (previous.document === undefined ) delete globalThis.document;
413 }
414 }
415
416 // ─────────────────────────────────────────────────────────────────────────────
417 // Main
418 // ─────────────────────────────────────────────────────────────────────────────
419
420 async function audit ( componentDir ) {
421 const report = {
422 ok: false ,
423 engine: 'jsdom' ,
424 componentDir: path. relative ( ROOT , componentDir) || '.' ,
425 entry: null ,
426 preview: null ,
427 ssr: null ,
428 axe: null ,
429 findings: [],
430 notChecked: [ ... NOT_CHECKED_BY_JSDOM ],
431 };
432
433 if ( ! fs. existsSync (componentDir) || ! fs. statSync (componentDir). isDirectory ()) {
434 report.error = `Component directory not found: ${ componentDir }` ;
435 return report;
436 }
437
438 const { modules , missing } = loadTooling ([ 'typescript' , 'jsdom' , 'axe-core' ]);
439 if (missing. length > 0 ) {
440 report.reason = 'missing-deps' ;
441 report.missing = missing;
442 report.error = `Missing render dependencies: ${ missing . join ( ', ' ) }. Install them (SKILL.md step 2) and rerun.` ;
443 return report;
444 }
445
446 const entry = resolveEntry (componentDir);
447 if ( ! entry.live && ! entry.impl) {
448 report.error = 'No component.tsx or implementation .tsx found.' ;
449 return report;
450 }
451
452 installCompileHooks (modules.typescript);
453 const runtime = await loadRuntime (entry.live || entry.impl);
454 const Provider = createServicesProvider (runtime.React, runtime.WixContext);
455
456 // Live render: the markup visitors get.
457 let liveHtml = null ;
458 try {
459 const { Component , source } = loadComponent (entry, runtime);
460 report.entry = source;
461 liveHtml = render (runtime, Provider, Component);
462 report.ssr = { ok: true , bytes: liveHtml. length };
463 } catch (error) {
464 if ( isLoaderError (error)) {
465 report.reason = 'loader' ;
466 report.error = truncate (error.stack || error.message, 600 );
467 return report;
468 }
469 report.ssr = { ok: false , error: truncate (error && error.stack ? error.stack : error, 600 ) };
470 report.findings. push ({ ... classifyRenderError (error), source: 'render' });
471 }
472
473 // Preview render: must not fall back to the placeholder with default props.
474 if (entry.preview) {
475 try {
476 const Preview = interopDefault ( require (entry.preview));
477 const placeholder = checkPreviewPlaceholder ( render (runtime, Provider, Preview));
478 report.preview = placeholder ? 'placeholder' : 'ok' ;
479 if (placeholder) {
480 report.findings. push ({
481 rule: 'preview-placeholder' ,
482 source: 'render' ,
483 severity: 'high' ,
484 confidence: 'high' ,
485 message:
486 'component.preview.tsx renders the fallback placeholder with defaultProps, so the editor shows an empty box.' ,
487 });
488 }
489 } catch (error) {
490 if ( isLoaderError (error)) {
491 report.reason = 'loader' ;
492 report.error = truncate (error.stack || error.message, 600 );
493 return report;
494 }
495 report.preview = 'failed' ;
496 report.findings. push ({
497 rule: 'preview-render-failed' ,
498 source: 'render' ,
499 severity: 'medium' ,
500 confidence: 'high' ,
501 message: `component.preview.tsx threw with defaultProps: ${ truncate ( error && error . message , 200 ) }` ,
502 });
503 }
504 }
505
506 if (liveHtml !== null ) {
507 const css = entry.cssFiles
508 . map (( file ) => normalizeModuleCss (fs. readFileSync (file, 'utf8' )))
509 . join ( ' \n ' );
510 const result = await runAxe (modules.jsdom, modules[ 'axe-core' ], liveHtml, css);
511 report.axe = { rulesRun: result.rulesRun };
512 report.findings. push ( ... result.findings);
513 }
514
515 report.ok = true ;
516 return report;
517 }
518
519 async function main () {
520 const componentDir = process.argv. slice ( 2 ). find (( arg ) => ! arg. startsWith ( '--' ));
521 if ( ! componentDir) {
522 console. log (
523 JSON . stringify ({
524 ok: false ,
525 usage: 'node <SKILL_ROOT>/scripts/scan-a11y-render.cjs <component-dir>' ,
526 }),
527 );
528 process. exit ( 2 );
529 }
530 try {
531 console. log ( JSON . stringify ( await audit (path. resolve (componentDir))));
532 } catch (error) {
533 console. log (
534 JSON . stringify ({
535 ok: false ,
536 error: truncate (error && error.stack ? error.stack : error, 800 ),
537 }),
538 );
539 process. exit ( 2 );
540 }
541 }
542
543 module . exports = {
544 PAGE_RULES,
545 LAYOUT_RULES,
546 normalizeModuleCss,
547 classifyRenderError,
548 checkPreviewPlaceholder,
549 };
550
551 if (require.main === module ) main ();