@a11yfred/neighbor 1.0.4 → 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.
package/RULES.md CHANGED
@@ -1,24 +1,25 @@
1
- # @a11yfred/neighbor - Rule Index
1
+ # @a11yfred/neighbor: Rule Index
2
2
 
3
- Neighbor ships rules across three separate domains. Each has its own reference page.
3
+ Neighbor has rules for four different areas. Each area has its own page.
4
4
 
5
- | Domain | Entry point | Rules page |
5
+ | Area | Setup | Rules page |
6
6
  | --- | --- | --- |
7
- | Markup | `@a11yfred/neighbor/eslint`, `/eslint-vue`, `/eslint-angular` | [RULES-MARKUP.md](RULES-MARKUP.md) |
8
- | CSS | `@a11yfred/neighbor` (default), `@a11yfred/neighbor/stylelint` | [RULES-CSS.md](RULES-CSS.md) |
9
- | Content | `@a11yfred/neighbor/content` | [RULES-CONTENT.md](RULES-CONTENT.md) |
7
+ | CSS | `@a11yfred/neighbor`, `@a11yfred/neighbor/stylelint` | [RULES-CSS.md](RULES-CSS.md) |
8
+ | HTML / Markup | `@a11yfred/neighbor/eslint`, `/eslint-vue`, `/eslint-angular`, `/webcomponents` | [RULES-MARKUP.md](RULES-MARKUP.md) |
9
+ | Native Mobile | `apps/ios-app`, `apps/android-app` | [iOS Rules](apps/ios-app/README.md) / [Android Rules](apps/android-app/README.md) |
10
+ | Text / Content | `@a11yfred/neighbor/content` | [RULES-CONTENT.md](RULES-CONTENT.md) |
10
11
 
11
12
  ---
12
13
 
13
- ## Markup rules - summary
14
+ ## Markup rules: summary
14
15
 
15
- ESLint rules that flag bad ARIA patterns, missing accessible names, keyboard traps, and structural errors in JSX, Vue SFCs, and Angular templates. Full reference → [RULES-MARKUP.md](RULES-MARKUP.md)
16
+ ESLint rules that find bad ARIA code, missing names, keyboard traps, and HTML mistakes in React, Vue, Angular, Lit, and plain HTML. Full list → [RULES-MARKUP.md](RULES-MARKUP.md)
16
17
 
17
- **Errors (definite breakage):** `no-aria-label-on-generic`, `no-assertive-live-overuse`, `no-unblocked-aria-disabled`, `no-roles-without-name`, `no-group-without-name`, `no-presentation-on-focusable`, `no-log-with-interactive-children`, `no-aria-hidden-in-link`, `no-redundant-aria-hidden-with-presentation`, `no-aria-owns-on-void`, `no-title-as-label`, `no-tabs-without-structure`, `no-positive-tabindex`, `no-autoplay-without-controls`, `no-heading-inside-interactive`, `no-placeholder-only`, `no-empty-button`, `no-image-role-without-name`, `no-spinbutton-without-range`, `no-slider-without-range`, `no-combobox-without-expanded`, `no-mouse-only-events`, `no-listbox-without-option`, `no-tree-without-treeitem`, `no-feed-without-article`, `no-aria-activedescendant-without-id`, `no-duplicate-id`, `no-summary-without-details`, `no-aria-required-on-non-form`, `no-input-type-invalid`, `no-labelledby-missing-target`, `no-dynamic-content-without-live`, `form-field-multiple-labels`, `no-empty-table-header`
18
+ **Errors (you must fix these):** `no-aria-label-on-generic`, `no-assertive-live-overuse`, `no-unblocked-aria-disabled`, `no-roles-without-name`, `no-group-without-name`, `no-presentation-on-focusable`, `no-log-with-interactive-children`, `no-aria-hidden-in-link`, `no-redundant-aria-hidden-with-presentation`, `no-aria-owns-on-void`, `no-title-as-label`, `no-tabs-without-structure`, `no-positive-tabindex`, `no-autoplay-without-controls`, `no-heading-inside-interactive`, `no-placeholder-only`, `no-empty-button`, `no-image-role-without-name`, `no-spinbutton-without-range`, `no-slider-without-range`, `no-combobox-without-expanded`, `no-mouse-only-events`, `no-listbox-without-option`, `no-tree-without-treeitem`, `no-feed-without-article`, `no-aria-activedescendant-without-id`, `no-duplicate-id`, `no-summary-without-details`, `no-aria-required-on-non-form`, `no-input-type-invalid`, `no-labelledby-missing-target`, `no-dynamic-content-without-live`, `form-field-multiple-labels`, `no-empty-table-header`, `no-disabled-and-aria-disabled`, `prefer-aria-disabled`
18
19
 
19
- **Warnings (on by default):** `no-tooltip-role-misuse`, `no-menu-role-on-nav`, `no-button-type-missing`
20
+ **Warnings (these are usually bad):** `no-tooltip-role-misuse`, `no-menu-role-on-nav`, `no-button-type-missing`
20
21
 
21
- **Off by default (opt in):** `no-application-role`, `no-grid-role`, `no-aria-roledescription`, `no-aria-readonly`, `no-tab-without-controls`, `no-href-hash`, `warn-role-alert`, `prefer-aria-disabled`, `no-target-blank-without-label`, `no-dialog-without-close`
22
+ **Off by default (you can turn these on):** `no-application-role`, `no-grid-role`, `no-aria-roledescription`, `no-aria-readonly`, `no-tab-without-controls`, `no-href-hash`, `warn-role-alert`, `no-target-blank-without-label`, `no-dialog-without-close`
22
23
 
23
24
  **Vue / Angular only:** `no-anchor-ambiguous-text`, `no-anchor-no-content`, `no-aria-activedescendant-no-tabindex`, `no-invalid-aria-prop-value`, `no-autocomplete-invalid`, `no-heading-no-content`, `no-iframe-no-title`, `no-img-redundant-alt`, `no-access-key`, `no-noninteractive-to-interactive-role`, `no-noninteractive-tabindex`, `prefer-semantic-element`, `no-role-supports-aria-props`, `no-scope-on-td`
24
25
 
@@ -26,30 +27,48 @@ ESLint rules that flag bad ARIA patterns, missing accessible names, keyboard tra
26
27
 
27
28
  ---
28
29
 
29
- ## CSS rules - summary
30
+ ## CSS rules: summary
30
31
 
31
- Stylelint rules that flag CSS that removes focus indicators, opts out of High Contrast Mode, or fails to provide user-preference media query fallbacks. Full reference → [RULES-CSS.md](RULES-CSS.md)
32
+ Stylelint rules that find bad CSS. They check if you hide focus rings, block High Contrast Mode, or ignore user preferences for motion. Full list → [RULES-CSS.md](RULES-CSS.md)
32
33
 
33
- | Rule | What it flags |
34
+ | Rule | What it finds |
34
35
  | --- | --- |
36
+ | `neighbor/no-absolute-viewport-text` | Pure viewport units (`vw`, `vh`) for text sizing - this stops browser zoom from working |
37
+ | `neighbor/no-forced-colors-none` | `forced-color-adjust: none` inside `@media (forced-colors)` - this blocks Windows High Contrast Mode |
38
+ | `neighbor/no-outline-none` | `outline: none` outside `:focus` - this removes keyboard focus rings |
39
+ | `neighbor/no-text-justify` | `text-align: justify` - this creates uneven word spacing that is hard for dyslexic users to read |
40
+ | `neighbor/no-user-select-all-none` | `user-select: none` on text - this stops users from highlighting, copying, and translating text |
35
41
  | `neighbor/user-preferences` | Animation, motion, and transparency without `@media (prefers-*)` fallbacks |
36
- | `neighbor/no-outline-none` | `outline: none` outside `:focus` selectors - removes keyboard focus ring |
37
- | `neighbor/no-forced-colors-none` | `forced-color-adjust: none` inside `@media (forced-colors)` - opts out of Windows High Contrast Mode |
38
42
 
39
43
  ---
40
44
 
41
- ## Content rules - summary
45
+ ## Content rules: summary
42
46
 
43
- ESLint rules that flag accessibility and inclusion problems in string literals and JSX text - ableist language, disability metaphors, English idioms, vague link and button text, directional references, unexplained abbreviations, ALL CAPS prose, and vague error messages. All ship as `warn`. Full reference → [RULES-CONTENT.md](RULES-CONTENT.md)
47
+ ESLint rules that find problems in your text. They check for ableist language, hard-to-understand English idioms, confusing links, and unexplained short words. All of these rules are set to `warn`. Full list → [RULES-CONTENT.md](RULES-CONTENT.md)
44
48
 
45
- | Rule | What it flags | WCAG SC |
49
+ | Rule | What it finds | WCAG SC |
46
50
  | --- | --- | --- |
47
- | `no-ableist-language` | Slurs, suffering-framing, condescending euphemisms ("wheelchair-bound", "suffers from", "special needs") | 3.1.1 |
48
- | `no-disability-metaphor` | Disability used figuratively ("blind spot", "tone deaf", "paralyzed by") | - |
49
- | `no-english-idiom` | Idioms and sports metaphors opaque to ESL readers ("slam dunk", "boil the ocean", "circle back") | 3.1.5 |
50
- | `no-vague-cta` | Vague link/button text ("click here", "read more", "here") | 2.4.4 |
51
- | `no-directional-language` | Position-based instructions ("see above", "in the right sidebar") | 1.3.3 |
52
- | `no-unexplained-abbreviation` | Acronyms used without prior expansion in the file | 3.1.4 |
53
- | `no-all-caps-prose` | ALL CAPS words that screen readers may spell out letter-by-letter | - |
54
- | `no-vague-error-message` | Error messages that don't say what went wrong ("An error occurred") | 3.3.1 |
55
- | `no-ampersand-in-prose` | `&` in place of "and" - announced inconsistently by screen readers | - |
51
+ | `no-ableist-language` | Offensive words about disability or framing disability as suffering ("wheelchair-bound", "suffers from", "special needs") | 3.1.1 |
52
+ | `no-all-caps-prose` | ALL CAPS words (screen readers might read them one letter at a time) | - |
53
+ | `no-ampersand-in-prose` | Using `&` instead of "and" (screen readers read this differently) | - |
54
+ | `no-anti-lgbtq-language` | Old or offensive words about sexual orientation and gender | - |
55
+ | `no-colonial-and-violent-language` | Words based on colonialism or violence (stakeholder, target population, tackle) | - |
56
+ | `no-deficit-language` | Words that reduce people to their bad situations (the homeless, inmate, addict) | - |
57
+ | `no-device-specific-action` | Words that only make sense on a desktop computer ("click here", "press enter") | - |
58
+ | `no-directional-language` | Instructions based on where things are on the screen ("see above", "in the right sidebar") | 1.3.3 |
59
+ | `no-disability-metaphor` | Using disability as a metaphor ("blind spot", "tone deaf", "paralyzed by") | - |
60
+ | `no-english-idiom` | Phrases or sports metaphors that are hard for non-native English speakers to understand ("slam dunk", "boil the ocean", "circle back") | 3.1.5 |
61
+ | `no-exclusive-language` | Tech jargon and culturally insensitive words (blacklist, master/slave, spirit animal) | - |
62
+ | `no-gendered-language` | Gendered pronouns when the gender is unknown (he/she, his or her, mum and dad) | - |
63
+ | `no-unexplained-abbreviation` | Short words or acronyms used before you explain what they mean | 3.1.4 |
64
+ | `no-vague-cta` | Confusing link or button text ("click here", "read more", "here") | 2.4.4 |
65
+ | `no-vague-error-message` | Error messages that do not explain what is wrong ("An error occurred") | 3.3.1 |
66
+
67
+ ---
68
+
69
+ ## Native Mobile: summary
70
+
71
+ `neighbor` includes strict rule implementations translated directly from our core web libraries for native iOS (SwiftUI) and Android (Jetpack Compose). They flag issues like missing Roles, unscaled text, broken touch targets, and improper semantics directly inside Xcode and Android Studio.
72
+
73
+ - **[iOS Rules](apps/ios-app/README.md):** 8 Custom SwiftLint Rules for SwiftUI (via `.swiftlint.yml`).
74
+ - **[Android Rules](apps/android-app/README.md):** 17 Custom Android Lint Rules for Jetpack Compose (via standard Gradle module).
@@ -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
+ }