semantica11y 1.0.0

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.
@@ -0,0 +1,63 @@
1
+ import { SEMANTIC_ROLE_MAPPINGS } from '../semantic-role-mappings.js';
2
+
3
+ const REQUIRED_LANDMARK_ELEMENTS = ['header', 'main', 'footer'];
4
+
5
+ function hasSemanticElement(document, element) {
6
+ return document.getElementsByTagName(element).length > 0;
7
+ }
8
+
9
+ function hasAriaRole(document, ariaRole) {
10
+ return Array.from(document.querySelectorAll('[role]')).some((element) =>
11
+ element
12
+ .getAttribute('role')
13
+ .toLowerCase()
14
+ .split(/\s+/)
15
+ .includes(ariaRole)
16
+ );
17
+ }
18
+
19
+ export const missingKeyLandmarkRule = {
20
+ id: 'missing-key-landmark',
21
+ name: 'Missing key landmark',
22
+ enabled: true,
23
+ description: 'Detects pages without key landmarks',
24
+ check(document) {
25
+ const issues = [];
26
+
27
+ SEMANTIC_ROLE_MAPPINGS
28
+ .filter(({ element }) => REQUIRED_LANDMARK_ELEMENTS.includes(element))
29
+ .forEach(({ element, ariaRole, missingMessage, missingSuggestion }) => {
30
+ const hasRequiredSemanticElement = hasSemanticElement(document, element);
31
+ const hasRequiredAriaRole = hasAriaRole(document, ariaRole);
32
+
33
+ if (hasRequiredSemanticElement || hasRequiredAriaRole) {
34
+ return;
35
+ }
36
+
37
+ issues.push({
38
+ severity: 'suggestion',
39
+ rule: 'missing-key-landmark',
40
+ element: '<body>',
41
+ message: missingMessage,
42
+ suggestion: missingSuggestion,
43
+ line: 1,
44
+ });
45
+ });
46
+
47
+ const hasHeading = document.querySelector('h1, h2, h3, h4, h5, h6');
48
+ const hasHeadingRole = hasAriaRole(document, 'heading');
49
+
50
+ if (!hasHeading && !hasHeadingRole) {
51
+ issues.push({
52
+ severity: 'suggestion',
53
+ rule: 'missing-key-landmark',
54
+ element: '<body>',
55
+ message: 'Page should have a heading',
56
+ suggestion: 'Add a heading element such as <h1> through <h6>',
57
+ line: 1,
58
+ });
59
+ }
60
+
61
+ return issues;
62
+ },
63
+ };
@@ -0,0 +1,77 @@
1
+ import { getElementSignature, getLineNumber } from '../utils.js';
2
+
3
+ const ACTION_ROLES = ['button', 'checkbox', 'link', 'textbox'];
4
+
5
+ function hasActionRole(element) {
6
+ const roles = (element.getAttribute('role') || '')
7
+ .toLowerCase()
8
+ .split(/\s+/)
9
+ .filter(Boolean);
10
+
11
+ return roles.some((role) => ACTION_ROLES.includes(role));
12
+ }
13
+
14
+ function isNativeActionElement(element) {
15
+ const tagName = element.tagName.toLowerCase();
16
+
17
+ if (['button', 'select', 'textarea'].includes(tagName)) {
18
+ return true;
19
+ }
20
+
21
+ if (tagName === 'a') {
22
+ return element.hasAttribute('href');
23
+ }
24
+
25
+ if (tagName === 'input') {
26
+ return element.getAttribute('type')?.toLowerCase() !== 'hidden';
27
+ }
28
+
29
+ return false;
30
+ }
31
+
32
+ function createIssue(element, message) {
33
+ return {
34
+ severity: 'warning',
35
+ rule: 'missing-role-action',
36
+ element: getElementSignature(element),
37
+ message,
38
+ suggestion: 'Use a native action element such as <button> or <a>, or add an appropriate action role',
39
+ line: getLineNumber(element),
40
+ };
41
+ }
42
+
43
+ export const missingRoleActionRule = {
44
+ id: 'missing-role-action',
45
+ name: 'Missing role on focusable action',
46
+ enabled: true,
47
+ description: 'Detects non-semantic action elements that do not have an action role',
48
+ check(document) {
49
+ const issues = [];
50
+ const reportedElements = new Set();
51
+
52
+ document.querySelectorAll('[tabindex="0"]').forEach((element) => {
53
+ if (hasActionRole(element) || isNativeActionElement(element)) {
54
+ return;
55
+ }
56
+
57
+ issues.push(createIssue(element, 'Focusable element does not have an action role'));
58
+ reportedElements.add(element);
59
+ });
60
+
61
+ document.querySelectorAll('[onclick]').forEach((element) => {
62
+ if (
63
+ reportedElements.has(element) ||
64
+ hasActionRole(element) ||
65
+ isNativeActionElement(element)
66
+ ) {
67
+ return;
68
+ }
69
+
70
+ issues.push({
71
+ ...createIssue(element, 'Element has a click handler but does not have an action role'),
72
+ });
73
+ });
74
+
75
+ return issues;
76
+ },
77
+ };
@@ -0,0 +1,113 @@
1
+ import { getElementSignature, getLineNumber } from '../utils.js';
2
+
3
+ const INPUT_VALUE_LABEL_TYPES = ['button', 'reset', 'submit'];
4
+ const ALLOWED_ARIA_LABEL_EXTENSIONS = [
5
+ 'opens in a new window',
6
+ 'opens in new window',
7
+ ];
8
+
9
+ function normalizeText(value) {
10
+ return value
11
+ .trim()
12
+ .replace(/[()[\].,:;–—-]+/g, ' ')
13
+ .replace(/\s+/g, ' ')
14
+ .toLowerCase();
15
+ }
16
+
17
+ function getLabelText(element, document) {
18
+ const id = element.id;
19
+ const explicitLabel = id ? document.querySelector(`label[for="${id}"]`) : null;
20
+ const wrappingLabel = element.closest('label');
21
+
22
+ return explicitLabel?.textContent.trim() || wrappingLabel?.textContent.trim() || '';
23
+ }
24
+
25
+ function getNativeText(element, document) {
26
+ const tagName = element.tagName.toLowerCase();
27
+
28
+ if (['button', 'a'].includes(tagName)) {
29
+ return element.textContent.trim();
30
+ }
31
+
32
+ if (tagName === 'input') {
33
+ const type = element.getAttribute('type')?.toLowerCase() || 'text';
34
+
35
+ if (INPUT_VALUE_LABEL_TYPES.includes(type)) {
36
+ return element.getAttribute('value')?.trim() || '';
37
+ }
38
+
39
+ return getLabelText(element, document);
40
+ }
41
+
42
+ if (['textarea', 'select'].includes(tagName)) {
43
+ return getLabelText(element, document);
44
+ }
45
+
46
+ return '';
47
+ }
48
+
49
+ function hasAllowedExtension(ariaLabel, nativeText) {
50
+ const normalizedAriaLabel = normalizeText(ariaLabel);
51
+ const normalizedNativeText = normalizeText(nativeText);
52
+
53
+ return normalizedAriaLabel.includes(normalizedNativeText);
54
+ }
55
+
56
+ function hasApprovedHelperText(ariaLabel, nativeText) {
57
+ const normalizedAriaLabel = normalizeText(ariaLabel);
58
+ const normalizedNativeText = normalizeText(nativeText);
59
+
60
+ if (!normalizedAriaLabel.startsWith(`${normalizedNativeText} `)) {
61
+ return false;
62
+ }
63
+
64
+ const extension = normalizedAriaLabel.slice(normalizedNativeText.length).trim();
65
+
66
+ return ALLOWED_ARIA_LABEL_EXTENSIONS.includes(extension);
67
+ }
68
+
69
+ export const nativeLabelRule = {
70
+ id: 'native-label',
71
+ name: 'Native label conflicts',
72
+ enabled: true,
73
+ description: 'Detects aria-label values that duplicate or conflict with native label text',
74
+ check(document) {
75
+ const issues = [];
76
+
77
+ document
78
+ .querySelectorAll('button[aria-label], a[href][aria-label], input[aria-label], textarea[aria-label], select[aria-label]')
79
+ .forEach((element) => {
80
+ const ariaLabel = element.getAttribute('aria-label') || '';
81
+ const nativeText = getNativeText(element, document);
82
+
83
+ if (!nativeText.trim()) {
84
+ return;
85
+ }
86
+
87
+ const isSameLabel = normalizeText(ariaLabel) === normalizeText(nativeText);
88
+
89
+ if (
90
+ !isSameLabel &&
91
+ (hasAllowedExtension(ariaLabel, nativeText) ||
92
+ hasApprovedHelperText(ariaLabel, nativeText))
93
+ ) {
94
+ return;
95
+ }
96
+
97
+ issues.push({
98
+ severity: isSameLabel ? 'warning' : 'error',
99
+ rule: 'native-label',
100
+ element: getElementSignature(element),
101
+ message: isSameLabel
102
+ ? 'Unnecessary aria-label duplicates native label text'
103
+ : 'aria-label does not match native label text',
104
+ suggestion: isSameLabel
105
+ ? 'Remove the aria-label and use the native label text'
106
+ : 'Update aria-label to match the visible label, or remove it and rely on native text',
107
+ line: getLineNumber(element),
108
+ });
109
+ });
110
+
111
+ return issues;
112
+ },
113
+ };
@@ -0,0 +1,51 @@
1
+ export const SEMANTIC_ROLE_MAPPINGS = [
2
+ {
3
+ element: 'header',
4
+ ariaRole: 'banner',
5
+ message: 'Element appears to be used for page header',
6
+ missingMessage: 'Page should have a header landmark',
7
+ missingSuggestion: 'Add a <header> element',
8
+ },
9
+ {
10
+ element: 'footer',
11
+ ariaRole: 'contentinfo',
12
+ message: 'Element appears to be used for page footer',
13
+ missingMessage: 'Page should have a footer landmark',
14
+ missingSuggestion: 'Add a <footer> element',
15
+ },
16
+ {
17
+ element: 'nav',
18
+ ariaRole: 'navigation',
19
+ message: 'Element appears to be used for navigation',
20
+ missingMessage: 'Page should have a navigation landmark',
21
+ missingSuggestion: 'Add a <nav> element',
22
+ },
23
+ {
24
+ element: 'main',
25
+ ariaRole: 'main',
26
+ message: 'Element appears to be used for main content',
27
+ missingMessage: 'Page should have a main content landmark',
28
+ missingSuggestion: 'Add a <main> element',
29
+ },
30
+ {
31
+ element: 'aside',
32
+ ariaRole: 'complementary',
33
+ message: 'Element appears to be used for complementary content',
34
+ missingMessage: 'Page should have a complementary content landmark',
35
+ missingSuggestion: 'Add an <aside> element',
36
+ },
37
+ {
38
+ element: 'article',
39
+ ariaRole: 'article',
40
+ message: 'Element appears to be used for article content',
41
+ missingMessage: 'Page should have an article landmark',
42
+ missingSuggestion: 'Add an <article> element',
43
+ },
44
+ {
45
+ element: 'section',
46
+ ariaRole: 'region',
47
+ message: 'Element appears to be used for a page section',
48
+ missingMessage: 'Page should have a section landmark',
49
+ missingSuggestion: 'Add a <section> element',
50
+ },
51
+ ];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Helper function to get approximate line number of an element
3
+ * (JSDOM doesn't provide line numbers, so this is a placeholder)
4
+ */
5
+ export function getLineNumber(element) {
6
+ // In a real implementation, you might track this during DOM traversal
7
+ return 0;
8
+ }
9
+
10
+ export function getElementSignature(element) {
11
+ const attributes = [
12
+ 'id',
13
+ 'class',
14
+ 'role',
15
+ 'tabindex',
16
+ 'aria-label',
17
+ 'aria-expanded',
18
+ 'aria-modal',
19
+ ]
20
+ .map((attribute) => {
21
+ const value = element.getAttribute(attribute);
22
+ return value ? ` ${attribute}="${value}"` : '';
23
+ })
24
+ .join('');
25
+
26
+ return `<${element.tagName.toLowerCase()}${attributes}>`;
27
+ }
package/src/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Semantica11y - HTML Semantic and ARIA Analysis Engine
3
+ * A JavaScript library to check webpages for ARIA and non-semantic HTML elements
4
+ * with suggestions for semantic alternatives.
5
+ */
6
+
7
+ export { Analyzer } from './analyzer.js';
8
+ export { RuleEngine } from './engine/index.js';
9
+ export { DEFAULT_RULES, CUSTOM_RULES } from './engine/definitions.js';
10
+ export {
11
+ exportTextReport,
12
+ formatConsoleReport,
13
+ printConsoleReport,
14
+ } from './engine/reporter/index.js';