@stacksjs/stx 0.2.174 → 0.2.176

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/a11y.d.ts CHANGED
@@ -36,6 +36,29 @@ export interface A11yViolation {
36
36
  /** URL to more information */
37
37
  helpUrl?: string;
38
38
  }
39
+ /**
40
+ * Has this element declared itself decorative rather than semantic?
41
+ *
42
+ * `role="presentation"` and its synonym `role="none"` remove an element's
43
+ * implicit semantics from the accessibility tree. On a table that is exactly the
44
+ * declaration that it carries layout, not data — so demanding a `<th>` or a
45
+ * `<caption>` asks the author to reintroduce the semantics the role exists to
46
+ * remove (stacksjs/stx#1916).
47
+ *
48
+ * This is not a corner case. Every HTML email is nested layout tables: mail
49
+ * clients strip `<style>` and have no reliable box model, so `role="presentation"`
50
+ * is the standard, correct pattern there. One email template accounted for every
51
+ * remaining finding in an otherwise-clean app, and none of them was fixable —
52
+ * the correct markup was what triggered them. An advisory checker with a
53
+ * permanent finding nobody can close is one people learn to ignore, which costs
54
+ * more than the rule is worth.
55
+ *
56
+ * The rule itself is right to exist: a DATA table with no headers is a real
57
+ * problem. It just has to take the author's word for which kind it is.
58
+ */
59
+ export declare function isPresentationalRole(role: string | null | undefined): boolean;
60
+ /** Read one attribute out of a raw tag's attribute text. */
61
+ export declare function attributeValue(attrs: string, name: string): string | null;
39
62
  /**
40
63
  * Process accessibility directives.
41
64
  * Handles @a11y hints, @screenReader content, and @ariaDescribe.
@@ -63,6 +86,23 @@ else {
63
86
  * ```
64
87
  */
65
88
  export declare function hasDOMSupport(): boolean;
89
+ /**
90
+ * Regex-based accessibility checker for non-DOM environments.
91
+ * Provides a subset of checks that can be performed with regex parsing.
92
+ *
93
+ * @param html - HTML content to check
94
+ * @param _filePath - File path for error reporting
95
+ * @returns Array of accessibility violations found
96
+ */
97
+ /**
98
+ * Regex-based checking, used when no DOM is available — which is the CLI.
99
+ *
100
+ * Exported so tests can exercise it directly. It and the DOM path implement the
101
+ * same rules twice, and the way two implementations of one rule go wrong is by
102
+ * disagreeing; pinning only whichever one the test environment happens to pick
103
+ * leaves the other free to drift (#1916).
104
+ */
105
+ export declare function checkA11yWithRegex(html: string, _filePath: string): A11yViolation[];
66
106
  /**
67
107
  * Automatically check template for accessibility issues.
68
108
  *
@@ -81,9 +121,39 @@ export declare function hasDOMSupport(): boolean;
81
121
  * ```
82
122
  */
83
123
  export declare function checkA11y(html: string, filePath: string): Promise<A11yViolation[]>;
124
+ /** What a scan actually read, alongside what it found. */
125
+ export interface A11yScanResult {
126
+ /** Files that had at least one violation, mapped to their violations. */
127
+ results: Record<string, A11yViolation[]>;
128
+ /**
129
+ * Every file the scan READ.
130
+ *
131
+ * Reported separately because "no violations" and "no files" are different
132
+ * answers that used to be the same one: `results` only ever contains files
133
+ * WITH violations, so an empty map means both "everything is clean" and
134
+ * "nothing was scanned", and the success banner was printed for both
135
+ * (stacksjs/stx#1918).
136
+ */
137
+ scannedFiles: string[];
138
+ }
139
+ /**
140
+ * Scan a directory, a file, or a glob for accessibility issues.
141
+ *
142
+ * The argument used to be joined into a scan pattern, so only a directory
143
+ * worked: a file path became `<file>/**` + `/*.stx` and a glob became a doubled
144
+ * pattern, both matching nothing and both reported as a clean pass. See
145
+ * `resolve-stx-targets.ts` for what that cost.
146
+ */
147
+ export declare function scanA11yTargets(target: string | readonly string[], options?: {
148
+ recursive?: boolean;
149
+ ignorePaths?: string[];
150
+ }): Promise<A11yScanResult>;
84
151
  /**
85
- * Scan a directory of stx files for accessibility issues.
86
- * Returns a map of file paths to their violations.
152
+ * Back-compatible wrapper: violations only.
153
+ *
154
+ * Kept so existing callers keep working, but it cannot say whether the scan read
155
+ * anything — which is the distinction #1918 turned on. Prefer
156
+ * {@link scanA11yTargets}.
87
157
  */
88
158
  export declare function scanA11yIssues(directory: string, options?: {
89
159
  recursive?: boolean;
package/dist/a11y.js CHANGED
@@ -1,4 +1,16 @@
1
1
  import path from "node:path";
2
+ import { resolveStxTargets } from "./resolve-stx-targets.js";
3
+ export function isPresentationalRole(role) {
4
+ if (!role)
5
+ return !1;
6
+ return role.trim().split(/\s+/)[0]?.toLowerCase() === "presentation" || role.trim().split(/\s+/)[0]?.toLowerCase() === "none";
7
+ }
8
+ export function attributeValue(attrs, name) {
9
+ const match = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(attrs);
10
+ if (!match)
11
+ return null;
12
+ return match[2] ?? match[3] ?? match[4] ?? null;
13
+ }
2
14
  export function processA11yDirectives(template, _context, _filePath, _options) {
3
15
  let output = template;
4
16
  output = output.replace(/@a11y\(\s*['"]([^'"]+)['"]\s*(?:,\s*['"]([^'"]+)['"]\s*)?\)/g, (_, hintType, customMessage) => {
@@ -58,7 +70,7 @@ function protectStxComponentTags(source) {
58
70
  })
59
71
  };
60
72
  }
61
- function checkA11yWithRegex(html, _filePath) {
73
+ export function checkA11yWithRegex(html, _filePath) {
62
74
  const violations = [], imgRegex = /<img\s+([^>]*)>/gi;
63
75
  let match;
64
76
  match = imgRegex.exec(html);
@@ -156,11 +168,11 @@ function checkA11yWithRegex(html, _filePath) {
156
168
  help: "Add lang attribute to the html element",
157
169
  helpUrl: "https://web.dev/learn/accessibility/more-html/#language"
158
170
  });
159
- const tableRegex = /<table[^>]*>([\s\S]*?)<\/table>/gi;
171
+ const tableRegex = /<table([^>]*)>([\s\S]*?)<\/table>/gi;
160
172
  match = tableRegex.exec(html);
161
173
  while (match !== null) {
162
- const tableContent = match[1];
163
- if (!/<th\b/i.test(tableContent))
174
+ const tableContent = match[2];
175
+ if (!isPresentationalRole(attributeValue(match[1], "role")) && !/<th\b/i.test(tableContent))
164
176
  violations.push({
165
177
  type: "table-missing-headers",
166
178
  element: "<table>",
@@ -321,6 +333,8 @@ export async function checkA11y(html, filePath) {
321
333
  });
322
334
  }
323
335
  container.querySelectorAll("table").forEach((table) => {
336
+ if (isPresentationalRole(table.getAttribute("role")))
337
+ return;
324
338
  const hasCaption = table.querySelector("caption");
325
339
  if (!table.querySelector("th"))
326
340
  violations.push({
@@ -411,23 +425,24 @@ export async function checkA11y(html, filePath) {
411
425
  }
412
426
  return violations;
413
427
  }
414
- export async function scanA11yIssues(directory, options = {}) {
415
- const results = {}, { recursive = !0, ignorePaths = [] } = options, pattern = path.join(directory, recursive ? "**/*.stx" : "*.stx"), glob = new Bun.Glob(pattern), files = [];
416
- for await (const file of glob.scan())
417
- if (!ignorePaths.some((ignorePath) => file.includes(path.normalize(ignorePath))))
418
- files.push(file);
428
+ export async function scanA11yTargets(target, options = {}) {
429
+ const results = {}, { recursive = !0, ignorePaths = [] } = options, targets = Array.isArray(target) ? target : [target], files = await resolveStxTargets(targets, { recursive, ignorePaths }), scannedFiles = [];
419
430
  for (const file of files)
420
431
  try {
421
432
  const content = await Bun.file(file).text();
422
433
  if (file.endsWith("good-a11y.stx"))
423
434
  continue;
424
435
  const violations = await checkA11y(content, file);
436
+ scannedFiles.push(file);
425
437
  if (violations.length > 0)
426
438
  results[file] = violations;
427
439
  } catch (error) {
428
440
  console.error(`Error scanning ${file}:`, error);
429
441
  }
430
- return results;
442
+ return { results, scannedFiles };
443
+ }
444
+ export async function scanA11yIssues(directory, options = {}) {
445
+ return (await scanA11yTargets(directory, options)).results;
431
446
  }
432
447
  const defaultAutoFixConfig = {
433
448
  fixMissingAlt: !0,