Uso independiente
Cuándo usarla
Para una comprobación manual contra una página abierta en un navegador real, un bookmarklet o cualquier otro contexto sin driver de automatización y sin paso de compilación al que recurrir.
Uso
@surea11y/core incluye surea11y.browser.js en la raíz del paquete, un bundle generado a partir de las mismas fuentes de reglas que el paquete de Node. Cargarlo directamente define una única variable global, a11ycore:
<script src="node_modules/@surea11y/core/surea11y.browser.js"></script><script>const result = a11ycore.runa11yCoreInPage(location.href, null, {}, null);console.log(result.checksResults.filter((r) => r.outcome === 'fail'));</script>
a11ycore.runa11yCoreInPage es exactamente la misma función que utilizan internamente las integraciones. El bundle existe solo para resolver el problema de cargarla sin require/un sistema de módulos, no para añadir una superficie de API separada. Los argumentos son los mismos que en cualquier otro sitio. Un análisis limitado a una región y a WCAG 2.2 A y AA, que deja fuera dos widgets ruidosos:
<script src="node_modules/@surea11y/core/surea11y.browser.js"></script><script>const result = a11ycore.runa11yCoreInPage(location.href,'#main', // contextSelector: scan only this region{excludeSelectors: ['#cookie-banner', '.intercom-launcher'],contrast: { mode: 'auditorAssist' } // trade some false-positive protection for more findings},{ wcag: { version: '2.2', level: 'AA' } } // runOnly);</script>
El bundle solo lleva inglés. Cualquier otro idioma se distribuye junto a él como su propio archivo. Carga uno después del bundle y ese idioma queda disponible:
<script src="node_modules/@surea11y/core/surea11y.browser.js"></script><script src="node_modules/@surea11y/core/surea11y.i18n.de.js"></script><script>const result = a11ycore.runa11yCoreInPage(location.href, null, { locale: 'de' }, null);</script>
Pide un idioma cuyo archivo no hayas cargado y obtienes inglés, no un error (consulta Locales).
El bundle también se carga como módulo: import a11ycore from '@surea11y/core/browser', una importación con nombre de runa11yCoreInPage y require() obtienen todos su API, y el global sigue definido.
Excluido deliberadamente del bundle: runa11yCoreAcrossFrames/a11yCoreEnableFrameResponder (el análisis entre frames necesita que el frame incrustado también cargue el motor y se adhiera. No puede resolverse con una única etiqueta <script> aislada). Usa el paquete npm directamente si lo necesitas (consulta INTEGRATION.md).
require('@surea11y/core') directamente, sin ninguna integración (binding), es la otra vía independiente. Esto es el Patrón 1/Patrón 2 en INTEGRATION.md: jsdom en Node sin navegador real, o bien manejar tú mismo Puppeteer/Playwright en lugar de pasar por una integración. Para pruebas, runa11yCoreOnHtml(html, options) de @surea11y/core/testing analiza una cadena HTML en jsdom y prepara el documento por ti; desde core 1.12, su opción contentType interpreta un documento SVG o XHTML como lo que es.
Desde la consola del navegador
Para un análisis a mano, el bundle tiene algunas ayudas. Cárgalo en cualquier página abierta desde la consola del navegador (la pestaña Console de las herramientas para desarrolladores) y después analiza, enumera los hallazgos, márcalos en la página o abre el informe HTML:
// Pasted into the console of the page to check.const s = document.createElement('script');s.src = 'https://cdn.jsdelivr.net/npm/@surea11y/core/surea11y.browser.js';document.head.append(s);// Once it has loaded:a11ycore.version; // the engine's versionconst result = await a11ycore.run(); // scan the pagea11ycore.table(result); // the findings, worst firsta11ycore.highlight(result); // box each one on the pageawait a11ycore.openReport(result); // the HTML report, in a new tabawait a11ycore.inspect('headings'); // what a person checks by hand
run(options) admite las opciones del motor y dos propias, context (un selector, para analizar solo una parte de la página) y runOnly, y antes de analizar descarga las hojas de estilo que la página no puede leer por sí misma. table() imprime una fila por hallazgo: primero los fallos, luego las preguntas con tendencia al fallo, las que tienen tendencia a cumplir y las que solo puede juzgar una persona. highlight() dibuja un recuadro sobre cada elemento que nombra un fail (rojo continuo) o un cantTell (ámbar discontinuo), en una capa propia que no toca los elementos de la página; clearHighlights() la quita. inspect() dibuja lo que revisa a mano una persona y no juzga nada: 'headings', 'landmarks', 'names' o 'focus-order'. openReport() descarga surea11y.report.js desde junto al bundle la primera vez, y el informe muestra cómo comprobar cada pregunta. La Content Security Policy de una página puede bloquear un script de una CDN: en ese caso carga los archivos desde el propio origen de la página, o analiza con un driver. Todas las opciones están en la sección sobre la consola del navegador de INTEGRATION.md, la guía de integración en GitHub.
Packs en una página
Un pack se prepara en Node, así que llega a una página como script. Para una página que carga sus scripts como archivos (una etiqueta <script>, un bookmarklet, una extensión), genera un solo archivo en Node: buildBrowserBundle({ packs }), de @surea11y/core/pack, es el bundle con los packs registrados a continuación. Luego nombra los packs en engineOptions.packs como name@version. El código de las reglas se escribe en el script como funciones, así que se ejecuta donde una Content Security Policy prohíbe eval.
// In Node, once per release of core or of a pack.const fs = require('node:fs');const { buildBrowserBundle } = require('@surea11y/core/pack');const acmePolicy = require('./acme-policy-pack');fs.writeFileSync('surea11y-acme.js', buildBrowserBundle({ packs: [acmePolicy] }));
<script src="surea11y-acme.js"></script><script>const result = a11ycore.runa11yCoreInPage(location.href, null, {packs: ['@acme/a11y-policy@1.0.0'],profile: 'acme-web-2026'}, null);</script>
El script de un pack solo se ejecuta con el core que lo escribió: lleva el catálogo que forman core y los packs, comprobado en Node. Una página cuyo bundle es de otra versión de core ejecuta el análisis sin los packs y enumera cada uno en skippedPacks, nombrando las dos versiones. Un solo archivo contiene ambos, así que no pueden desfasarse. Vuelve a generarlo cuando actualices core o un pack, y actualiza el name@version que nombra la página.
Si el bundle de core ya está en la página, escribe packScript(packs) en un archivo con el mismo core y cárgalo después del bundle (y en cada frame que analices):
// In Node, with the core whose bundle the page loads.const fs = require('node:fs');const { packScript } = require('@surea11y/core/pack');const acmePolicy = require('./acme-policy-pack');fs.writeFileSync('acme-pack.js', packScript([acmePolicy]));
<script src="node_modules/@surea11y/core/surea11y.browser.js"></script><script src="acme-pack.js"></script>
La página ejecuta exactamente el conjunto de packs que registró una llamada a packScript, con el core con el que se preparó: pasa a una sola llamada todos los packs que necesita un análisis. Los packs nombrados que la página no ejecuta se enumeran en skippedPacks con el motivo. Las integraciones hacen todo esto por ti con .withPacks().
Esperar a la página antes de un análisis
Un análisis lee la página tal como está en ese momento. En una página recién abierta, las fuentes web pueden seguir cargándose y las imágenes pueden no haber llegado, así que la misma página analizada un segundo después puede dar otro resultado. El análisis lo registra en engine.environment.fonts y .images (consulta el esquema de salida), pero la mejor solución es no analizar hasta que la página se haya asentado.
waitForPageReady(options) se encarga de esa espera, y el bundle la expone en el mismo global. El análisis nunca la llama por sí mismo; llámala justo antes de analizar. Espera al evento load de la ventana, a document.fonts.ready y a cada imagen que siga cargándose (salvo las de loading="lazy"), como máximo timeoutMs (por defecto 5000). Nunca se rechaza: cuando se agota el tiempo, se resuelve igualmente con ready: false e indica qué seguía pendiente. Analizar de todos modos, esperar más o detenerse es decisión tuya; si analizas, guarda junto al resultado lo que seguía pendiente.
const ready = await a11ycore.waitForPageReady({ timeoutMs: 3000 });const result = a11ycore.runa11yCoreInPage(location.href, null, {}, null);
Un driver que manejes tú mismo puede enviarla a la página igual que runa11yCoreInPage: page.evaluate(waitForPageReady, { timeoutMs: 5000 }). En jsdom no hay nada que esperar, así que se resuelve de inmediato con ready: true. Las demás opciones (quietMs, document) y el valor de retorno completo están en INTEGRATION.md, la guía de integración en GitHub.
Hojas de estilo de otro origen
El script de una página no puede leer las reglas de una hoja de estilo servida desde otro origen (una CDN, un servicio de fuentes), así que una regla que busca algo en el CSS, como css-orientation-lock, no puede saber si está ahí, y pregunta. Da al análisis su texto en engineOptions.styleSheetTexts ([{ href, text }]) y css-orientation-lock, css-hidden-focus y css-focus-indicator-suppressed lo leen. En el bundle, runa11yCoreInPageAsync recibe los mismos cuatro argumentos que runa11yCoreInPage, descarga con preloadStyleSheets() las hojas cuyo servidor las comparte (CORS) y después analiza:
const result = await a11ycore.runa11yCoreInPageAsync(location.href, null, {}, null);
Una hoja cuyo servidor no la comparte no se puede leer en absoluto desde la página. Una herramienta que controla el navegador puede descargarla desde fuera de la página, donde no se aplica CORS, y pasarla en la misma opción; las integraciones lo hacen.
Un límite de tiempo para un análisis
Un análisis se ejecuta de una sola vez dentro de la página, y nada en la página puede detenerlo una vez iniciado: un getter de la página o una regla que nunca termina bloquea esa pestaña para siempre. Limita el tiempo desde donde inicias el análisis, y abandona la pestaña cuando pase el límite: tu límite de tiempo detiene la espera, no el script que se ejecuta en la página. 60 segundos está muy por encima de lo que tarda una página normal, así que solo atrapa las que están rotas:
// Playwright or Puppeteer: page.evaluate has no time limit of its own.async function scanWithin(page, ms, scan) {let timer;const late = new Promise((_, reject) => {timer = setTimeout(() => reject(new Error(`the scan took longer than ${ms} ms`)), ms);});try {return await Promise.race([scan(), late]);} catch (err) {// The tab may still be busy with the scan: close it, and use a new one.await page.close().catch(() => {});throw err;} finally {clearTimeout(timer);}}const fs = require('node:fs');await page.evaluate(fs.readFileSync(require.resolve('@surea11y/core/browser'), 'utf8'));const result = await scanWithin(page, 60_000, () =>page.evaluate(() => window.a11ycore.runa11yCoreInPage(location.href, null, {}, null)));
En jsdom el análisis se ejecuta en el propio hilo de Node, así que un temporizador ahí no puede dispararse hasta que termine: ejecuta el análisis en un worker thread o en un proceso hijo, y termínalo cuando pase el límite. El propio límite de tiempo de un trabajo de CI es el último recurso en ambos casos.
Componentes web con internals privados
Un elemento personalizado puede darse un nombre, fijar sus estados y asignarse un rol a través de ElementInternals. Muchos guardan sus internals en un campo privado, donde ningún script puede volver a leerlos, así que el análisis pregunta por ese elemento (cantTell) en lugar de juzgarlo, y no ve en absoluto un rol asignado así.
internalsHookScript(), de @surea11y/core (no del bundle), devuelve un script que registra los internals de cada elemento a medida que la página los adjunta, para que el análisis pueda leerlos. Es opcional: core nunca lo instala. Instálalo antes de los scripts propios de la página, en cada frame que analices: con la API de scripts de inicio de tu driver o, en una página que construyas tú, como el primer <script> de <head>. En un archivo de configuración de jsdom, que se ejecuta cuando el documento ya se ha cargado pero antes de que se defina ningún componente, pasa { early: true }:
const { internalsHookScript } = require('@surea11y/core');// Playwrightawait page.addInitScript({ content: internalsHookScript() });// Puppeteerawait page.evaluateOnNewDocument(internalsHookScript());// jest.setup.js or vitest.setup.js, before any component is importedwindow.eval(internalsHookScript({ early: true }));
El resultado indica si estaba, en engine.environment.internalsHook: 'early' cuando se instaló antes de los scripts de la página, 'late' cuando se instaló después y solo ve lo que la página adjunta a partir de entonces. Cualquier script de la página puede leer lo que registra el hook, así que los internals privados de un componente dejan de serlo: instálalo para ejecuciones de prueba y de auditoría, no en páginas que sirvas a usuarios. Las demás formas de instalarlo, y lo que una página puede notar, están en INTEGRATION.md.
Manejadores de clic y regiones vivas
Dos hooks opcionales más, desde core 1.14, se instalan igual que el hook de internals, antes de los scripts de la propia página. listenerHookScript() registra los listeners de clic, teclado, foco, ratón y pegado que una página añade desde un script, para que las reglas de teclado los juzguen: un <div role="button"> con un listener de clic y sin ningún listener de teclado allí adonde llega un evento de teclado desde él falla (CLICK_HANDLER_NO_KEY_LISTENER). liveRegionHookScript() registra lo que reciben las regiones vivas: live-region-announcements pregunta entonces por una región añadida con su texto ya dentro, o mostrada y rellenada a la vez, que los lectores de pantalla no suelen anunciar, y getAnnouncements() devuelve lo que la página puso a disposición para anunciar:
const { listenerHookScript, liveRegionHookScript, getAnnouncements } = require('@surea11y/core');// Playwright: before the page's own scriptsawait page.addInitScript({ content: listenerHookScript() });await page.addInitScript({ content: liveRegionHookScript() });await page.goto('https://example.com/settings');await page.click('text=Save');const said = await page.evaluate(getAnnouncements, { selectors: true });// [{ text: 'Saved', politeness: 'polite', time: 1234.5, order: 7, behindModal: false, selector: '#status' }]
engine.environment.listenerHook y .liveRegionHook indican si un análisis los vio. Solo un hook de listeners instalado antes de los scripts de la página ('early') demuestra que falta un listener; instalado después, las reglas solo preguntan. En el bundle, a11ycore.getAnnouncements() los lee en la página. Como el hook de internals, ambos guardan lo que registran donde cualquier script de la página puede leerlo: instálalos para ejecuciones de pruebas y auditorías. En un archivo de configuración de jsdom, pasa { early: true } y, para el hook de listeners, ejecuta antes un selector (document.querySelector('*')), porque jsdom añade listeners de teclado propios la primera vez que se ejecuta un selector. Una prueba con jsdom que sustituye showModal() poniendo open y aria-modal="true" en un diálogo instala el hook de regiones vivas con { early: true, assumeModal: true } y analiza con engineOptions.assumeModal: true (core 1.16.0 y posteriores).
setContent() de Playwright no es una navegación. Escribe el marcado en el documento actual de la página, que ya se ha cargado, así que un script de inicio no se ejecuta mientras se carga ese documento. Uno añadido al contexto antes de abrir la página se ejecuta en el primer documento de la página, en blanco: el hook está ahí cuando setContent() escribe la página, pero se registra como 'late', y click-handler-keyboard-operable pregunta donde un hook temprano habría hecho fallar el elemento. Uno añadido a una página que ya está abierta espera a su siguiente documento, y setContent() no crea ninguno, así que el análisis se ejecuta sin hook. Para una captura temprana, carga la página con una navegación real: page.goto() a un servidor o a una URL file://, o page.route() para responder a una URL con el marcado. Lo mismo vale para el hook de internals:
await context.addInitScript({ content: listenerHookScript() });const page = await context.newPage();await page.route('https://test.local/', (route) =>route.fulfill({ body: html, contentType: 'text/html' }));await page.goto('https://test.local/'); // listenerHook: 'early'
Las secciones del hook de listeners y del hook de regiones vivas de INTEGRATION.md, la guía de integración en GitHub, tienen el resto.
Tendencias y pasos para las preguntas
Desde core 1.14, una ocurrencia cantTell puede llevar un lean: hacia dónde apuntan las evidencias de la página (toward: 'fail' o 'pass'), por qué y qué podría hacerlo erróneo. El resultado sigue siendo cantTell. Cada pregunta de una regla de core lleva además howToCheck, los pasos que sigue una persona para responderla, en el idioma del análisis. Se añaden en Node, no en el motor de la página: runDomRulesInPage los añade, y para un resultado que vuelve de una página, addHowToCheck(result) devuelve una copia con ellos. leaningFailures(result) enumera los hallazgos con tendencia al fallo, para un umbral propio:
const { runa11yCoreInPage, addHowToCheck, leaningFailures } = require('@surea11y/core');const raw = await page.evaluate(runa11yCoreInPage, page.url(), null, {}, null);const result = addHowToCheck(raw); // a copy, with each question's stepsconst likelyFailures = leaningFailures(result);
Si un hallazgo tiene tendencia, y sus textos, pueden cambiar en una versión menor de core; la forma de lean es estable. Consulta el esquema de salida para ambos campos.
Procedimientos en un navegador
Desde core 1.15, los procedimientos de prueba, cómo evalúa una persona a mano cada criterio, llegan también al código que se ejecuta en un navegador, desde @surea11y/core/procedures: no necesita sistema de archivos ni toca ninguna de las reglas, así que una extensión de navegador o una página propia puede mostrarlos, o abrir una hoja de trabajo, con los diccionarios que importa:
import { procedureChecklistFrom, renderProceduresFrom } from '@surea11y/core/procedures';import en from '@surea11y/core/procedures/en';import fr from '@surea11y/core/procedures/fr';const dictionaries = { en, fr };const checklist = procedureChecklistFrom(dictionaries, { levels: ['A', 'AA'], locale: 'fr' });const html = renderProceduresFrom(dictionaries, checklist);window.open(URL.createObjectURL(new Blob([html], { type: 'text/html' })));
Saber qué cubre un análisis
Una herramienta que informa por criterio de WCAG y permite a sus usuarios elegir comprobaciones puede indicar para qué objetivo es el análisis: engineOptions.wcagLevel, junto a wcagVersion, deja fuera los criterios por encima de ese nivel sin cambiar qué comprobaciones se ejecutan. getRulesForRunOnly(runOnly, engineOptions) enumera los criterios de los que informa un análisis con esa selección, antes de ejecutarlo, con la misma lógica del análisis:
const { getRulesForRunOnly, runDomRulesInPage } = require('@surea11y/core');const engineOptions = { wcagVersion: '2.2', wcagLevel: 'AA' };const runOnly = { includeRuleIds: ['contrast-computable'] };getRulesForRunOnly(runOnly, engineOptions).map((r) => r.id); // before the scanconst result = runDomRulesInPage(url, null, engineOptions, runOnly);
Un criterio del que solo se ejecutaron algunas comprobaciones vuelve como cantTell, con cada comprobación que no se ejecutó marcada como notSelected: true: muéstralo como comprobado en parte, nunca como superado. Desde core 1.14 ningún criterio se informa como superado: un criterio solo pasa cuando sus reglas comprueban todo lo que pide, y todavía no hay ninguno que se compruebe por completo. Cómo crear una herramienta propia sobre core (una CLI, una integración de CI, un panel, un generador de informes de conformidad) se explica en BUILDING_ON_CORE.md, en GitHub: en qué puedes confiar, cómo ejecutar análisis, elegir comprobaciones, presentar los resultados con honestidad y comparar ejecuciones.