@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.
- package/CHANGELOG.md +81 -90
- package/CONTRIBUTING.md +40 -40
- package/README.md +4 -472
- package/RULES-CONTENT.md +240 -81
- package/RULES-CSS.md +41 -19
- package/RULES-MARKUP.md +168 -94
- package/RULES.md +47 -28
- package/lib/content-rules.js +216 -0
- package/lib/framework-rules.js +282 -0
- package/lib/helpers-webcomponents.js +134 -0
- package/lib/rules.js +374 -3
- package/neighbor-content.mjs +1 -1
- package/neighbor-eslint-angular.mjs +29 -8
- package/neighbor-eslint-lit.mjs +85 -0
- package/neighbor-eslint-remix3.mjs +49 -0
- package/neighbor-eslint-vue.mjs +26 -6
- package/neighbor-eslint-webcomponents.mjs +41 -0
- package/neighbor-eslint.mjs +13 -6
- package/neighbor-stylelint.mjs +141 -3
- package/package.json +10 -11
package/lib/content-rules.js
CHANGED
|
@@ -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
|
+
}
|