@a11yfred/neighbor 1.1.2 → 2.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.
@@ -64,6 +64,8 @@ export const ABLEIST_TERMS = [
64
64
  { term: /\blame\b/i, suggest: 'weak / unconvincing (for the non-disability sense)', sources: 'NCDJ, A11y Collective' },
65
65
  { term: /\bvegetable[s]?\b/i, suggest: 'person in a persistent vegetative state', sources: 'ADA NN' },
66
66
  { term: /\bfreak[s]?\b/i, suggest: '(rewrite)', sources: 'ADA NN' },
67
+ { term: /\bspastic[s]?\b/i, suggest: 'clumsy / energetic (highly offensive ableist slur in UK)', sources: 'Scope UK' },
68
+ { term: /\bspaz(zes|zed)?\b/i, suggest: 'clumsy / energetic (highly offensive ableist slur in UK)', sources: 'Scope UK' },
67
69
 
68
70
  // Condescending euphemisms - ≥3 sources each
69
71
  { term: /\bspecial needs\b/i, suggest: 'disability / person with a disability', sources: 'NCDJ, AP, ADA NN, APA' },
@@ -791,8 +793,210 @@ export function createNoAmpersandInProseRule() {
791
793
  }
792
794
  }
793
795
 
796
+
797
+ /**
798
+ * no-exclusive-language
799
+ */
800
+ export const EXCLUSIVE_TERMS = [
801
+ { term: /\bblack[- ]?list[s]?\b/i, suggest: 'denylist / blocklist', sources: 'Google, MS, Prevention.org' },
802
+ { term: /\bwhite[- ]?list[s]?\b/i, suggest: 'allowlist / safelist', sources: 'Google, MS, Prevention.org' },
803
+ { term: /\bmaster(?! (node|degree|class))\b/i, suggest: 'primary / main / leader', sources: 'Google, MS, Prevention.org' },
804
+ { term: /\bslave\b/i, suggest: 'replica / worker / follower', sources: 'Google, MS, Prevention.org' },
805
+ { term: /\bdummy\b/i, suggest: 'placeholder / mock / sample', sources: 'Google' },
806
+ { term: /\bsanity check\b/i, suggest: 'quick check / confidence check', sources: 'Google' },
807
+ { term: /\bspirit animal\b/i, suggest: 'remove / favorite (avoid cultural appropriation)', sources: 'MS, Prevention.org' },
808
+ { term: /\bpowwow\b/i, suggest: 'meeting / gathering (avoid cultural appropriation)', sources: 'MS, Prevention.org' },
809
+ { term: /\bninja[s]?\b/i, suggest: 'expert / specialist', sources: 'MS, Prevention.org' },
810
+ { term: /\bguru[s]?\b/i, suggest: 'expert / leader', sources: 'MS, Prevention.org' },
811
+ { term: /\btribe\b/i, suggest: 'team / network / community', sources: 'Prevention.org' },
812
+ { term: /\bguys\b/i, suggest: 'folks / everyone / team', sources: 'Google, MS' },
813
+ { term: /\bcolou?red people\b/i, suggest: 'people of color (avoid coloured which is highly offensive in the UK)', sources: 'APA, AP Style' },
814
+ { term: /\boriental[s]?\b/i, suggest: 'Asian / specific descent', sources: 'AP Style, Microsoft' },
815
+ { term: /\beskimo[s]?\b/i, suggest: 'Alaska Native / Inuit', sources: 'AP Style, Microsoft' },
816
+ { term: /\bnegro[s]?\b/i, suggest: 'Black / African American / specific descent', sources: 'AP Style, APA' },
817
+ { term: /\bafro[- ]american[s]?\b/i, suggest: 'Black / African American / specific descent', sources: 'AP Style, APA' },
818
+ { term: /\bpaki[s]?\b/i, suggest: 'specific descent (highly offensive slur in UK, avoid entirely)', sources: 'UK Gov' },
819
+ ]
820
+
821
+ export function createNoExclusiveLanguageRule() {
822
+ return {
823
+ meta: { type: 'suggestion', docs: { description: 'Disallow non-inclusive tech jargon and cultural appropriation', url: 'https://github.com/a11yfred/neighbor#no-exclusive-language' }, messages: { exclusive: '"{{term}}" is non-inclusive or culturally appropriated. Suggestion: {{suggest}}. ({{sources}})' }, schema: [{ type: 'object', properties: { allow: { type: 'array', items: { type: 'string' } } }, additionalProperties: false }] },
824
+ create(context) {
825
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
826
+ return {
827
+ Literal(node) {
828
+ if (typeof node.value !== 'string') return
829
+ checkTermList(node, node.value, EXCLUSIVE_TERMS, allow, context, 'exclusive')
830
+ },
831
+ TemplateLiteral(node) {
832
+ for (const quasi of node.quasis) { checkTermList(quasi, quasi.value.raw, EXCLUSIVE_TERMS, allow, context, 'exclusive') }
833
+ }
834
+ }
835
+ }
836
+ }
837
+ }
838
+
839
+ /**
840
+ * no-colonial-and-violent-language
841
+ */
842
+ export const VIOLENT_COLONIAL_TERMS = [
843
+ { term: /\bstakeholder[s]?\b/i, suggest: 'partner / collaborator / contributor / community member', sources: 'SkilledWork' },
844
+ { term: /\btarget (population|audience)\b/i, suggest: 'group of focus / intended audience / specific population', sources: 'SkilledWork, Prevention.org' },
845
+ { term: /\b(combat|tackle)\b(?! (the|this) (issue|problem|disease))/i, suggest: 'address / collaborate with / eliminate', sources: 'Prevention.org' }
846
+ ]
847
+
848
+ export function createNoColonialAndViolentLanguageRule() {
849
+ return {
850
+ meta: { type: 'suggestion', docs: { description: 'Disallow terms rooted in colonialism or violent imagery applied to people', url: 'https://github.com/a11yfred/neighbor#no-colonial-and-violent-language' }, messages: { violentColonial: '"{{term}}" has violent or colonial origins. Suggestion: {{suggest}}. ({{sources}})' }, schema: [{ type: 'object', properties: { allow: { type: 'array', items: { type: 'string' } } }, additionalProperties: false }] },
851
+ create(context) {
852
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
853
+ return {
854
+ Literal(node) {
855
+ if (typeof node.value !== 'string') return
856
+ checkTermList(node, node.value, VIOLENT_COLONIAL_TERMS, allow, context, 'violentColonial')
857
+ },
858
+ TemplateLiteral(node) {
859
+ for (const quasi of node.quasis) { checkTermList(quasi, quasi.value.raw, VIOLENT_COLONIAL_TERMS, allow, context, 'violentColonial') }
860
+ }
861
+ }
862
+ }
863
+ }
864
+ }
865
+
866
+ /**
867
+ * no-deficit-language
868
+ */
869
+ export const DEFICIT_TERMS = [
870
+ { term: /\bthe homeless\b/i, suggest: 'people experiencing homelessness', sources: 'Prevention.org, SkilledWork' },
871
+ { term: /\b(inmate|felon|convict|ex-con)[s]?\b/i, suggest: 'person with legal system involvement / formerly incarcerated person', sources: 'SkilledWork' },
872
+ { term: /\boffender[s]?\b/i, suggest: 'person with legal system involvement', sources: 'SkilledWork' },
873
+ { term: /\baddict[s]?\b/i, suggest: 'person with a substance use disorder', sources: 'Prevention.org' },
874
+ { term: /\b(drug|substance) abuse\b/i, suggest: 'substance use disorder', sources: 'Prevention.org' },
875
+ { term: /\bminority\b/i, suggest: 'historically marginalized group / people of color', sources: 'APA, Prevention.org' },
876
+ { term: /\bat-risk youth\b/i, suggest: 'opportunity youth', sources: 'SkilledWork' },
877
+ { term: /\b(vulnerable|high-risk) (group|population)[s]?\b/i, suggest: 'groups experiencing vulnerability / historically marginalized communities', sources: 'SkilledWork, Prevention.org' },
878
+ { term: /\bnon-English speaking\b/i, suggest: 'multilingual learner', sources: 'ACECQA' },
879
+ ]
880
+
881
+ export function createNoDeficitLanguageRule() {
882
+ return {
883
+ meta: { type: 'suggestion', docs: { description: 'Disallow language that reduces people to their circumstances or behaviors', url: 'https://github.com/a11yfred/neighbor#no-deficit-language' }, messages: { deficit: '"{{term}}" is deficit-based language. Suggestion: {{suggest}}. ({{sources}})' }, schema: [{ type: 'object', properties: { allow: { type: 'array', items: { type: 'string' } } }, additionalProperties: false }] },
884
+ create(context) {
885
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
886
+ return {
887
+ Literal(node) {
888
+ if (typeof node.value !== 'string') return
889
+ checkTermList(node, node.value, DEFICIT_TERMS, allow, context, 'deficit')
890
+ },
891
+ TemplateLiteral(node) {
892
+ for (const quasi of node.quasis) { checkTermList(quasi, quasi.value.raw, DEFICIT_TERMS, allow, context, 'deficit') }
893
+ }
894
+ }
895
+ }
896
+ }
897
+ }
898
+
899
+ /**
900
+ * no-gendered-language
901
+ */
902
+ export const GENDERED_PATTERNS = [
903
+ { term: /\b(he\/she|she\/he|he or she|she or he)\b/i, suggest: 'they / their / you / the user', sources: 'MS, Google' },
904
+ { term: /\b(his\/her|her\/his|his or her|her or his)\b/i, suggest: 'their / your', sources: 'MS, Google' },
905
+ { term: /\bmum and dad\b/i, suggest: 'families / parents / carers', sources: 'ACECQA' },
906
+ { term: /\b(born a man|born a woman)\b/i, suggest: 'assigned male/female at birth', sources: 'UPenn, NAHJ' },
907
+ { term: /\b(biologically male|biologically female)\b/i, suggest: 'assigned male/female at birth', sources: 'UPenn, NAHJ' },
908
+ { term: /\b(opposite sex|opposite gender)\b/i, suggest: 'different gender / another sex', sources: 'APA, TJA' },
909
+ { term: /\b(husband|wife)\b/i, suggest: 'partner / spouse (when gender is unknown)', sources: 'APA, Google, NAHJ' },
910
+ { term: /\b(boyfriend|girlfriend)\b/i, suggest: 'partner (when gender is unknown)', sources: 'APA, Google, NAHJ' },
911
+ { term: /\b(male-bodied|female-bodied)\b/i, suggest: 'assigned male/female at birth', sources: 'TJA' },
912
+ { term: /\b(fireman|policeman|chairman)\b/i, suggest: 'firefighter / police officer / chairperson', sources: 'GOV.UK, Canada' },
913
+ ]
914
+
915
+ export function createNoGenderedLanguageRule() {
916
+ return {
917
+ meta: { type: 'suggestion', docs: { description: 'Disallow gendered pronoun patterns in generic references', url: 'https://github.com/a11yfred/neighbor#no-gendered-language' }, messages: { gendered: '"{{term}}" is a generic gendered pattern. Suggestion: {{suggest}}. ({{sources}})' }, schema: [{ type: 'object', properties: { allow: { type: 'array', items: { type: 'string' } } }, additionalProperties: false }] },
918
+ create(context) {
919
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
920
+ return {
921
+ Literal(node) {
922
+ if (typeof node.value !== 'string') return
923
+ checkTermList(node, node.value, GENDERED_PATTERNS, allow, context, 'gendered')
924
+ },
925
+ TemplateLiteral(node) {
926
+ for (const quasi of node.quasis) { checkTermList(quasi, quasi.value.raw, GENDERED_PATTERNS, allow, context, 'gendered') }
927
+ }
928
+ }
929
+ }
930
+ }
931
+ }
932
+
933
+ /**
934
+ * no-device-specific-action
935
+ */
936
+ export const DEVICE_SPECIFIC_PATTERNS = [
937
+ { term: /\bclick(ing|ed)? (on|the|this)\b/i, suggest: 'choose / select', sources: 'Apple, Google' },
938
+ { term: /\btap(ping|ped)? (on|the|this)\b/i, suggest: 'choose / select', sources: 'Apple, Google' },
939
+ { term: /\bswipe (the|this)\b/i, suggest: 'choose / select / navigate', sources: 'Apple' }
940
+ ]
941
+
942
+ export function createNoDeviceSpecificActionRule() {
943
+ return {
944
+ meta: { type: 'suggestion', docs: { description: 'Disallow device-specific action verbs', url: 'https://github.com/a11yfred/neighbor#no-device-specific-action' }, messages: { deviceAction: '"{{term}}" assumes a specific input device (mouse, touch screen). Suggestion: {{suggest}}. ({{sources}})' }, schema: [{ type: 'object', properties: { allow: { type: 'array', items: { type: 'string' } } }, additionalProperties: false }] },
945
+ create(context) {
946
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
947
+ return {
948
+ Literal(node) {
949
+ if (typeof node.value !== 'string') return
950
+ checkTermList(node, node.value, DEVICE_SPECIFIC_PATTERNS, allow, context, 'deviceAction')
951
+ },
952
+ TemplateLiteral(node) {
953
+ for (const quasi of node.quasis) { checkTermList(quasi, quasi.value.raw, DEVICE_SPECIFIC_PATTERNS, allow, context, 'deviceAction') }
954
+ }
955
+ }
956
+ }
957
+ }
958
+ }
959
+
960
+ /**
961
+ * no-anti-lgbtq-language
962
+ */
963
+ export const ANTI_LGBTQ_TERMS = [
964
+ { term: /\bhomosexual[s]?\b/i, suggest: 'gay / lesbian / bisexual', sources: 'APA, AP Style, NAHJ' },
965
+ { term: /\bsexual preference[s]?\b/i, suggest: 'sexual orientation', sources: 'APA, AP Style, NAHJ' },
966
+ { term: /\btransgendered\b/i, suggest: 'transgender', sources: 'TJA, APA, AP Style' },
967
+ { term: /\ba transgender\b/i, suggest: 'a transgender person', sources: 'TJA, APA, AP Style' },
968
+ { term: /\btransgenderism\b/i, suggest: 'transgender people / trans rights', sources: 'TJA' },
969
+ { term: /\b(trans ideology|gender ideology)\b/i, suggest: 'transgender rights', sources: 'TJA' },
970
+ { term: /\btrans-identified\b/i, suggest: 'transgender', sources: 'TJA' },
971
+ { term: /\b(trans male|trans female)\b/i, suggest: 'trans man / trans woman', sources: 'TJA' },
972
+ { term: /\btransvestite[s]?\b/i, suggest: 'transgender (or use specific terms as preferred)', sources: 'NAHJ, TJA' },
973
+ { term: /\bcross[- ]dresser[s]?\b/i, suggest: 'transgender (or use specific terms as preferred)', sources: 'NAHJ, TJA' },
974
+ { term: /\bfaggot[s]?\b/i, suggest: '(highly offensive slur, avoid entirely)', sources: 'AP Style' },
975
+ { term: /\bfag[s]?\b/i, suggest: '(highly offensive slur, avoid entirely. Note: slang for cigarette in UK but slur in US)', sources: 'AP Style' },
976
+ { term: /\bqueer\b/i, suggest: '(use only if referring to self-identification, otherwise avoid as it can be a slur)', sources: 'AP Style, UK Gov' },
977
+ ]
978
+
979
+ export function createNoAntiLgbtqLanguageRule() {
980
+ return {
981
+ meta: { type: 'suggestion', docs: { description: 'Disallow outdated, pathologizing, or offensive terms regarding sexual orientation and gender identity', url: 'https://github.com/a11yfred/neighbor#no-anti-lgbtq-language' }, messages: { antiLgbtq: '"{{term}}" is outdated or offensive regarding LGBTQ+ identity. Suggestion: {{suggest}}. ({{sources}})' }, schema: [{ type: 'object', properties: { allow: { type: 'array', items: { type: 'string' } } }, additionalProperties: false }] },
982
+ create(context) {
983
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
984
+ return {
985
+ Literal(node) {
986
+ if (typeof node.value !== 'string') return
987
+ checkTermList(node, node.value, ANTI_LGBTQ_TERMS, allow, context, 'antiLgbtq')
988
+ },
989
+ TemplateLiteral(node) {
990
+ for (const quasi of node.quasis) { checkTermList(quasi, quasi.value.raw, ANTI_LGBTQ_TERMS, allow, context, 'antiLgbtq') }
991
+ }
992
+ }
993
+ }
994
+ }
995
+ }
996
+
794
997
  // ─── Rule export map ─────────────────────────────────────────────────────────
795
998
 
999
+
796
1000
  export const CONTENT_RULE_FACTORIES = {
797
1001
  'no-ableist-language': createNoAbleistLanguageRule,
798
1002
  'no-disability-metaphor': createNoDisabilityMetaphorRule,
@@ -803,6 +1007,12 @@ export const CONTENT_RULE_FACTORIES = {
803
1007
  'no-all-caps-prose': createNoAllCapsProse,
804
1008
  'no-vague-error-message': createNoVagueErrorMessageRule,
805
1009
  'no-ampersand-in-prose': createNoAmpersandInProseRule,
1010
+ 'no-exclusive-language': createNoExclusiveLanguageRule,
1011
+ 'no-colonial-and-violent-language': createNoColonialAndViolentLanguageRule,
1012
+ 'no-deficit-language': createNoDeficitLanguageRule,
1013
+ 'no-gendered-language': createNoGenderedLanguageRule,
1014
+ 'no-device-specific-action': createNoDeviceSpecificActionRule,
1015
+ 'no-anti-lgbtq-language': createNoAntiLgbtqLanguageRule,
806
1016
  }
807
1017
 
808
1018
  /**
@@ -824,6 +1034,12 @@ export function buildContentRecommendedRules(ns) {
824
1034
  [`${ns}/no-all-caps-prose`]: 'warn',
825
1035
  [`${ns}/no-vague-error-message`]: 'warn',
826
1036
  [`${ns}/no-ampersand-in-prose`]: 'warn',
1037
+ [`${ns}/no-exclusive-language`]: 'warn',
1038
+ [`${ns}/no-colonial-and-violent-language`]: 'warn',
1039
+ [`${ns}/no-deficit-language`]: 'warn',
1040
+ [`${ns}/no-gendered-language`]: 'warn',
1041
+ [`${ns}/no-device-specific-action`]: 'warn',
1042
+ [`${ns}/no-anti-lgbtq-language`]: 'warn',
827
1043
  }
828
1044
  }
829
1045
 
@@ -0,0 +1,282 @@
1
+ /**
2
+ * neighbor/lib/framework-rules.js
3
+ * Framework-specific ESLint rules using standard JS/TS AST visitors.
4
+ *
5
+ * Unlike rules.js (which uses template AST visitors via helpers-jsx/vue/angular),
6
+ * these rules target the JavaScript/TypeScript AST and are scoped to specific
7
+ * frameworks via their dedicated plugin config files.
8
+ *
9
+ * Rules:
10
+ * remix-route-title-missing → neighbor-eslint-remix3.mjs only
11
+ * angular-host-a11y → neighbor-eslint-angular.mjs only (TS files)
12
+ */
13
+
14
+ // ─── remix-route-title-missing ───────────────────────────────────────────────
15
+
16
+ /**
17
+ * Ensures every Remix v2/v3 route module exports a meta() function
18
+ * that includes a title property. Without this, navigating to the route
19
+ * leaves the document title unchanged - screen readers announce nothing.
20
+ *
21
+ * Applies only to files matching: app/routes/**
22
+ */
23
+ export const remixRouteTitleMissing = {
24
+ meta: {
25
+ type: 'problem',
26
+ docs: {
27
+ description:
28
+ 'Require a `title` in the exported `meta` function in Remix route files - screen readers announce the document title on navigation.',
29
+ },
30
+ messages: {
31
+ missingMeta:
32
+ 'Remix route is missing an exported `meta` function. Screen readers announce the document title on page navigation - add `export const meta = () => [{ title: "Page Name" }]`. (WCAG SC 2.4.2)',
33
+ missingTitle:
34
+ 'Remix route `meta` export must include a `{ title: "..." }` entry. Screen readers announce the document title on page navigation. (WCAG SC 2.4.2)',
35
+ },
36
+ schema: [],
37
+ },
38
+ create(context) {
39
+ const filename = context.getFilename?.() ?? context.filename ?? ''
40
+ // Only apply in Remix route files
41
+ const isRoute =
42
+ /[/\\]app[/\\]routes[/\\]/.test(filename) ||
43
+ /[/\\]routes[/\\]/.test(filename)
44
+ if (!isRoute) return {}
45
+
46
+ let metaExportFound = false
47
+ let titleFound = false
48
+
49
+ function checkObjectHasTitle(node) {
50
+ if (node.type === 'ObjectExpression') {
51
+ return node.properties.some(
52
+ (p) =>
53
+ p.type === 'Property' &&
54
+ (p.key?.name === 'title' || p.key?.value === 'title')
55
+ )
56
+ }
57
+ return false
58
+ }
59
+
60
+ function checkForTitle(node) {
61
+ // Array: return [{ title: "..." }, ...]
62
+ if (node.type === 'ArrayExpression') {
63
+ return node.elements.some((el) => el && checkObjectHasTitle(el))
64
+ }
65
+ // Object: return { title: "..." } (Remix v1 compat)
66
+ if (node.type === 'ObjectExpression') {
67
+ return checkObjectHasTitle(node)
68
+ }
69
+ // Arrow with implicit return: () => [...]
70
+ if (node.type === 'ArrowFunctionExpression' && node.expression) {
71
+ return checkForTitle(node.body)
72
+ }
73
+ return false
74
+ }
75
+
76
+ return {
77
+ // export const meta = ...
78
+ ExportNamedDeclaration(node) {
79
+ const decl = node.declaration
80
+ if (!decl || decl.type !== 'VariableDeclaration') return
81
+ for (const declarator of decl.declarations) {
82
+ if (declarator.id?.name !== 'meta') continue
83
+ metaExportFound = true
84
+ if (declarator.init && checkForTitle(declarator.init)) {
85
+ titleFound = true
86
+ }
87
+ }
88
+ },
89
+ // export function meta() { return [...] }
90
+ 'ExportNamedDeclaration > FunctionDeclaration'(node) {
91
+ if (node.id?.name !== 'meta') return
92
+ metaExportFound = true
93
+ // Walk return statements in the function body
94
+ const body = node.body?.body ?? []
95
+ for (const stmt of body) {
96
+ if (
97
+ stmt.type === 'ReturnStatement' &&
98
+ stmt.argument &&
99
+ checkForTitle(stmt.argument)
100
+ ) {
101
+ titleFound = true
102
+ }
103
+ }
104
+ },
105
+ 'Program:exit'(node) {
106
+ if (!metaExportFound) {
107
+ context.report({ node, messageId: 'missingMeta' })
108
+ } else if (!titleFound) {
109
+ context.report({ node, messageId: 'missingTitle' })
110
+ }
111
+ },
112
+ }
113
+ },
114
+ }
115
+
116
+ // ─── angular-host-a11y ───────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Ensures Angular @Component({ host: {...} }) decorators don't apply
120
+ * interactive roles to the host element without also providing tabindex.
121
+ *
122
+ * Without tabindex="0", a custom Angular component with role="button" set
123
+ * in the host binding will appear in the accessibility tree but won't be
124
+ * reachable via keyboard Tab navigation.
125
+ */
126
+
127
+ const INTERACTIVE_HOST_ROLES = new Set([
128
+ 'button', 'link', 'checkbox', 'radio', 'switch', 'tab',
129
+ 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option',
130
+ 'slider', 'spinbutton', 'treeitem', 'gridcell',
131
+ ])
132
+
133
+ export const angularHostA11y = {
134
+ meta: {
135
+ type: 'problem',
136
+ docs: {
137
+ description:
138
+ 'Disallow interactive `role` in Angular @Component host bindings without `tabindex`',
139
+ },
140
+ messages: {
141
+ missingTabindex:
142
+ 'Angular @Component host binding has `role="{{ role }}"` but is missing `tabindex: "0"`. Without tabindex, the element will not be reachable via keyboard navigation. (ARIA 1.2 / APG)',
143
+ },
144
+ schema: [],
145
+ },
146
+ create(context) {
147
+ return {
148
+ // Matches: @Component({ host: { role: 'button' } })
149
+ Decorator(node) {
150
+ if (
151
+ node.expression?.type !== 'CallExpression' ||
152
+ node.expression.callee?.name !== 'Component'
153
+ ) return
154
+
155
+ const args = node.expression.arguments ?? []
156
+ const configObj = args.find((a) => a.type === 'ObjectExpression')
157
+ if (!configObj) return
158
+
159
+ const hostProp = configObj.properties.find(
160
+ (p) =>
161
+ p.type === 'Property' &&
162
+ (p.key?.name === 'host' || p.key?.value === 'host')
163
+ )
164
+ if (!hostProp || hostProp.value?.type !== 'ObjectExpression') return
165
+
166
+ const hostProps = hostProp.value.properties
167
+ let hostRole = null
168
+ let hasTabindex = false
169
+
170
+ for (const prop of hostProps) {
171
+ const key =
172
+ prop.key?.name ?? prop.key?.value ?? ''
173
+ const val =
174
+ prop.value?.value ?? prop.value?.quasis?.[0]?.value?.raw ?? ''
175
+
176
+ if (key === 'role' && INTERACTIVE_HOST_ROLES.has(val)) {
177
+ hostRole = val
178
+ }
179
+ if (key === 'tabindex' || key === 'tabIndex') {
180
+ hasTabindex = true
181
+ }
182
+ }
183
+
184
+ if (hostRole && !hasTabindex) {
185
+ context.report({
186
+ node: hostProp,
187
+ messageId: 'missingTabindex',
188
+ data: { role: hostRole },
189
+ })
190
+ }
191
+ },
192
+ }
193
+ },
194
+ }
195
+
196
+ // ─── angular-router-focus-management ─────────────────────────────────────────
197
+
198
+ export const angularRouterFocusManagement = {
199
+ meta: {
200
+ type: 'suggestion',
201
+ docs: { description: 'Warn if <router-outlet> is used without focus management.' },
202
+ messages: {
203
+ noFocus: 'SPA route transitions via <router-outlet> require manual focus management (e.g., using a skip-link or programmatic focus).'
204
+ },
205
+ schema: [],
206
+ },
207
+ create(context) {
208
+ return {
209
+ Element(node) {
210
+ if (node.name === 'router-outlet') {
211
+ context.report({ node, messageId: 'noFocus' })
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // ─── lit-no-autofocus ────────────────────────────────────────────────────────
219
+
220
+ export const litNoAutofocus = {
221
+ meta: {
222
+ type: 'suggestion',
223
+ docs: { description: 'Disallow autofocus in Lit templates.' },
224
+ messages: {
225
+ noAutofocus: 'The autofocus attribute disrupts focus flow and disorients screen reader users.'
226
+ },
227
+ schema: [],
228
+ },
229
+ create(context) {
230
+ return {
231
+ TaggedTemplateExpression(node) {
232
+ if (node.tag && node.tag.name === 'html') {
233
+ const raw = node.quasi.quasis.map(q => q.value.raw).join('')
234
+ // Simple regex to catch autofocus attribute
235
+ if (/\bautofocus\b/.test(raw)) {
236
+ context.report({ node, messageId: 'noAutofocus' })
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ // ─── Exports ─────────────────────────────────────────────────────────────────
245
+
246
+ export function buildRemixRules() {
247
+ return {
248
+ 'remix-route-title-missing': remixRouteTitleMissing,
249
+ }
250
+ }
251
+
252
+ export function buildRemixRecommendedRules(ns) {
253
+ return {
254
+ [`${ns}/remix-route-title-missing`]: 'error',
255
+ }
256
+ }
257
+
258
+ export function buildAngularFrameworkRules() {
259
+ return {
260
+ 'angular-host-a11y': angularHostA11y,
261
+ 'angular-router-focus-management': angularRouterFocusManagement,
262
+ }
263
+ }
264
+
265
+ export function buildAngularHostRecommendedRules(ns) {
266
+ return {
267
+ [`${ns}/angular-host-a11y`]: 'error',
268
+ [`${ns}/angular-router-focus-management`]: 'off',
269
+ }
270
+ }
271
+
272
+ export function buildLitRules() {
273
+ return {
274
+ 'lit-no-autofocus': litNoAutofocus,
275
+ }
276
+ }
277
+
278
+ export function buildLitRecommendedRules(ns) {
279
+ return {
280
+ [`${ns}/lit-no-autofocus`]: 'error',
281
+ }
282
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * neighbor/lib/helpers-webcomponents.js
3
+ * Helpers for Vanilla Web Components and static HTML AST via @html-eslint/parser.
4
+ *
5
+ * Parser: @html-eslint/parser provides a generic HTML AST.
6
+ * Node type for elements: 'Tag'
7
+ * The node has:
8
+ * node.name - tag name string
9
+ * node.attributes - Attribute[]
10
+ * node.children - Array of Tag | Text
11
+ * node.parent - parent Tag
12
+ */
13
+
14
+ import {
15
+ INTERACTIVE_ELEMENTS,
16
+ INTERACTIVE_ROLES,
17
+ } from './helpers.js'
18
+
19
+ export function getAttr(node, name) {
20
+ return (node.attributes ?? []).find(a => a.key?.value === name) ?? null
21
+ }
22
+
23
+ export function getAttrStringValue(attr) {
24
+ if (!attr) return null
25
+ return typeof attr.value?.value === 'string' ? attr.value.value : null
26
+ }
27
+
28
+ export function getElementName(node) {
29
+ const raw = node.name ?? ''
30
+ if (!raw) return null
31
+ // HTML tags are case-insensitive, but Web Component tags usually have a dash.
32
+ return raw.toLowerCase()
33
+ }
34
+
35
+ export function hasAttr(node, name) {
36
+ return getAttr(node, name) !== null
37
+ }
38
+
39
+ export function getRoleValue(node) {
40
+ return getAttrStringValue(getAttr(node, 'role'))
41
+ }
42
+
43
+ export function hasAccessibleName(node) {
44
+ return hasAttr(node, 'aria-label') || hasAttr(node, 'aria-labelledby')
45
+ }
46
+
47
+ export function isInteractiveElement(node) {
48
+ const el = getElementName(node)
49
+ if (el && INTERACTIVE_ELEMENTS.has(el)) return true
50
+ const role = getRoleValue(node)
51
+ return !!(role && INTERACTIVE_ROLES.has(role))
52
+ }
53
+
54
+ export function hasOnlyHiddenChildren(node) {
55
+ const children = node.children ?? []
56
+ if (children.length === 0) return false
57
+ return children.every(child => {
58
+ if (child.type === 'Text') return (child.value ?? '').trim() === ''
59
+ if (child.type === 'Tag') {
60
+ return (child.attributes ?? []).some(a => a.key?.value === 'aria-hidden')
61
+ }
62
+ return false
63
+ })
64
+ }
65
+
66
+ const NEW_TAB_PATTERN = /new.tab|new.window|opens in/i
67
+
68
+ function collectHtmlText(node) {
69
+ if (!node) return ''
70
+ if (node.type === 'Text') return node.value ?? ''
71
+ if (node.type === 'Tag') return (node.children ?? []).map(collectHtmlText).join('')
72
+ return ''
73
+ }
74
+
75
+ export function hasNewTabWarning(node) {
76
+ const labelVal = (getAttrStringValue(getAttr(node, 'aria-label')) ?? '').toLowerCase()
77
+ if (NEW_TAB_PATTERN.test(labelVal)) return true
78
+ const childText = (node.children ?? []).map(collectHtmlText).join('')
79
+ return NEW_TAB_PATTERN.test(childText)
80
+ }
81
+
82
+ export function getParent(node) {
83
+ return node.parent?.type === 'Tag' ? node.parent : null
84
+ }
85
+
86
+ export function* getAncestors(node) {
87
+ let cur = getParent(node)
88
+ while (cur) {
89
+ yield cur
90
+ cur = getParent(cur)
91
+ }
92
+ }
93
+
94
+ export function* getChildOpeningElements(node) {
95
+ for (const child of node.children ?? []) {
96
+ if (child.type === 'Tag') yield child
97
+ }
98
+ }
99
+
100
+ export function getClassName(node) {
101
+ return getAttrStringValue(getAttr(node, 'class'))
102
+ }
103
+
104
+ export function getInnerHtmlAttr(node) {
105
+ // Vanilla HTML has no innerHTML attribute directive
106
+ return null
107
+ }
108
+
109
+ export function getInnerHtmlAttrName(_node) {
110
+ return null
111
+ }
112
+
113
+ export const h = {
114
+ getAttr,
115
+ getAttrStringValue,
116
+ getElementName,
117
+ hasAttr,
118
+ getRoleValue,
119
+ hasAccessibleName,
120
+ isInteractiveElement,
121
+ hasOnlyHiddenChildren,
122
+ hasNewTabWarning,
123
+ getParent,
124
+ getAncestors,
125
+ getChildOpeningElements,
126
+ getClassName,
127
+ getInnerHtmlAttr,
128
+ getInnerHtmlAttrName,
129
+ elementVisitor: 'Tag',
130
+ elementWithChildrenVisitor: 'Tag',
131
+ getOpeningElement: (node) => node,
132
+ getChildOpeningElementsFromWrapper: (node) =>
133
+ (node.children ?? []).filter(c => c.type === 'Tag'),
134
+ }