@a11yfred/neighbor 0.2.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,858 @@
1
+ /**
2
+ * neighbor/lib/content-rules.js
3
+ * Content and prose accessibility rules for web and app copy.
4
+ *
5
+ * These rules operate on plain text, markdown AST nodes, and JSX string literals.
6
+ * Each rule targets a gap not covered by jsx-a11y, stylelint, or existing markup linters.
7
+ *
8
+ * ─── Rule methodology ────────────────────────────────────────────────────────
9
+ *
10
+ * Rules were included only when all three conditions held:
11
+ * 1. A WCAG Success Criterion directly applies, OR the rule appears in ≥3
12
+ * independent authoritative style guides.
13
+ * 2. The rule can be expressed as a finite pattern (string match, count, or
14
+ * AST shape) without requiring NLP or runtime context.
15
+ * 3. Expert consensus is unambiguous - no credible accessibility authority
16
+ * argues the opposite.
17
+ *
18
+ * Rules that require subjective reading (tone, cultural sensitivity beyond a
19
+ * term list) or are under active community debate are excluded.
20
+ *
21
+ * ─── Primary sources ────────────────────────────────────────────────────────
22
+ *
23
+ * WCAG 2.2 / 3.x w3.org/WAI/WCAG22/Understanding
24
+ * W3C WAI Tips w3.org/WAI/tips/writing/
25
+ * wcag.com/authors wcag.com/authors/
26
+ * Google Dev Style developers.google.com/style/accessibility
27
+ * US Plain Language plainlanguage.gov / digital.gov/guides/plain-language
28
+ * SBA Style Guide advocacy.sba.gov/…/writing-accessible-content/
29
+ * GOV.UK Publishing gov.uk/guidance/publishing-accessible-documents
30
+ * NCDJ Style Guide cronkite.asu.edu/ncdj/disability-language-style-guide
31
+ * AP Stylebook amdisrights.org/ap-stylebook-primer-on-disability
32
+ * ADA National Network adata.org/factsheet/ADANN-writing
33
+ * APA Style apastyle.apa.org/style-grammar-guidelines/bias-free-language/disability
34
+ * SIGACCESS Guide sigaccess.org/welcome-to-sigaccess/resources/accessible-writing-guide/
35
+ * Nicolas Steenhout incl.ca/disability-language-is-a-nuanced-thing/ - "Nothing About Us Without Us"; language as community choice, not external rule
36
+ * Léonie Watson tink.uk - cited by Steenhout: "There is no right or wrong answer because it is a matter of personal choice, and the choice depends on context."
37
+ * A11y Collective a11y-collective.com/blog/accessible-writing/
38
+ * UX Content Co. uxcontent.com/accessible-ux-writing-a-guide-for-inclusive-content-design/
39
+ * Canadian Gov accessible.canada.ca/guidelines-creating-accessible-documents
40
+ * SJSU Writing Center sjsu.edu/writingcenter/docs/handouts/Accessible Writing Strategies.pdf
41
+ * Section 508 section508.gov/create/alternative-text/
42
+ */
43
+
44
+ // ─── Shared term lists ───────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Ableist terms with suggested replacements.
48
+ *
49
+ * Methodology: union of NCDJ, AP Stylebook, ADA National Network, APA Style,
50
+ * and SIGACCESS guides. Each term appears in ≥2 independent sources. Metaphorical
51
+ * uses ("blind to") are tracked separately in DISABILITY_METAPHORS.
52
+ *
53
+ * Note: identity-first vs person-first (e.g. "autistic person" vs "person with
54
+ * autism") is contested within disability communities - this linter does not flag
55
+ * either form. See NCDJ § "Identity-first language" for context.
56
+ */
57
+ export const ABLEIST_TERMS = [
58
+ // Slurs - universal consensus across all sources
59
+ { term: /\bcrip+le[sd]?\b/i, suggest: 'person who uses a wheelchair / person with a mobility disability', sources: 'NCDJ, AP, ADA NN, APA' },
60
+ { term: /\bretard(ed|s)?\b/i, suggest: 'person with an intellectual disability', sources: 'NCDJ, AP, ADA NN, APA' },
61
+ { term: /\bimbecile[s]?\b/i, suggest: 'person with an intellectual disability', sources: 'ADA NN, APA' },
62
+ { term: /\bmoron[s]?\b/i, suggest: 'person with an intellectual disability', sources: 'ADA NN' },
63
+ { term: /\bdumb\b/i, suggest: 'mute / nonverbal / does not use speech', sources: 'NCDJ, ADA NN' },
64
+ { term: /\blame\b/i, suggest: 'weak / unconvincing (for the non-disability sense)', sources: 'NCDJ, A11y Collective' },
65
+ { term: /\bvegetable[s]?\b/i, suggest: 'person in a persistent vegetative state', sources: 'ADA NN' },
66
+ { term: /\bfreak[s]?\b/i, suggest: '(rewrite)', sources: 'ADA NN' },
67
+
68
+ // Condescending euphemisms - ≥3 sources each
69
+ { term: /\bspecial needs\b/i, suggest: 'disability / person with a disability', sources: 'NCDJ, AP, ADA NN, APA' },
70
+ { term: /\bdifferently[- ]abled\b/i, suggest: 'person with a disability', sources: 'NCDJ, AP, ADA NN, APA' },
71
+ { term: /\bhandi[- ]?capable\b/i, suggest: 'person with a disability', sources: 'NCDJ, AP, ADA NN' },
72
+ { term: /\bphysically challenged\b/i, suggest: 'person with a physical disability', sources: 'NCDJ, AP, ADA NN, APA' },
73
+ { term: /\bmentally challenged\b/i, suggest: 'person with an intellectual or cognitive disability', sources: 'ADA NN, APA' },
74
+ { term: /\bspecial\b(?=\s+(ed|education|class|school))/i, suggest: 'special education (acceptable in formal context) / disability services', sources: 'NCDJ' },
75
+
76
+ // Suffering / tragedy framing - ≥3 sources each
77
+ { term: /\bconfined to (a |their )?wheelchair\b/i, suggest: 'wheelchair user / person who uses a wheelchair', sources: 'NCDJ, AP, ADA NN, APA, SIGACCESS' },
78
+ { term: /\bwheelchair[- ]bound\b/i, suggest: 'wheelchair user / person who uses a wheelchair', sources: 'NCDJ, AP, ADA NN, APA, SIGACCESS' },
79
+ { term: /\bsuffers? from\b/i, suggest: 'has / lives with / is diagnosed with', sources: 'NCDJ, AP, ADA NN, APA' },
80
+ { term: /\bafflicted (with|by)\b/i, suggest: 'has / lives with', sources: 'NCDJ, AP, ADA NN, APA, SIGACCESS' },
81
+ { term: /\bvictim of\b/i, suggest: 'person who has / person with', sources: 'NCDJ, AP, ADA NN, APA' },
82
+ { term: /\bbound to (a |the )?(bed|chair|wheelchair)\b/i, suggest: 'uses a bed / uses a wheelchair', sources: 'ADA NN, APA' },
83
+ { term: /\bstricken (with|by)\b/i, suggest: 'has / lives with', sources: 'ADA NN' },
84
+ { term: /\bcrippling\b(?!\s+(blow|defeat|loss))/i, suggest: 'devastating / severe / debilitating', sources: 'NCDJ, A11y Collective' },
85
+
86
+ // Mental health - specific clinical terms misused colloquially
87
+ { term: /\bcommitted suicide\b/i, suggest: 'died by suicide', sources: 'ADA NN, APA (clinical consensus, AP Stylebook 2022)' },
88
+ { term: /\bcra+zy\b/i, suggest: 'wild / unexpected / intense (for non-clinical uses)', sources: 'NCDJ, A11y Collective' },
89
+ { term: /\bpsycho\b/i, suggest: 'reckless / erratic / (rewrite)', sources: 'NCDJ, ADA NN' },
90
+ { term: /\bschizophrenic\b(?!\s+(disorder|diagnosis|symptom))/i, suggest: 'contradictory / inconsistent (for non-clinical uses)', sources: 'NCDJ, APA' },
91
+ { term: /\b(a\s+)?manic[- ]depressive\b/i, suggest: 'person with bipolar disorder', sources: 'ADA NN, APA' },
92
+ { term: /\bjunkie[s]?\b/i, suggest: 'person with a substance use disorder', sources: 'ADA NN, APA' },
93
+ { term: /\baddict[s]?\b(?!\s+up)/i, suggest: 'person with a substance use disorder / person in recovery', sources: 'ADA NN, APA' },
94
+
95
+ // Normalcy framing - ≥3 sources each
96
+ { term: /\bnormal (people|person|individuals?|users?)\b/i, suggest: 'people without disabilities / non-disabled people', sources: 'AP, ADA NN, APA, SIGACCESS' },
97
+ { term: /\bnormal (hearing|vision|sight)\b/i, suggest: 'typical hearing / full hearing / unimpaired vision', sources: 'AP, SIGACCESS' },
98
+ { term: /\bhearing[- ]impaired\b/i, suggest: 'deaf / hard of hearing', sources: 'NCDJ, AP, ADA NN, APA' },
99
+ { term: /\bthe (disabled|blind|deaf|mentally ill)\b/i, suggest: 'people with disabilities / blind people / deaf people / people with mental illness', sources: 'NCDJ, AP, ADA NN, APA, SIGACCESS' },
100
+ { term: /\bhandicapped\b/i, suggest: 'person with a disability / accessible (for facilities)', sources: 'NCDJ, AP, ADA NN' },
101
+ ]
102
+
103
+ /**
104
+ * Disability metaphors - disability used figuratively in non-clinical prose.
105
+ *
106
+ * Methodology: identified in NCDJ, A11y Collective, APA, and UX Content Co.
107
+ * as patterns that reinforce ableism even when used innocuously. Each metaphor
108
+ * listed appears in ≥2 sources. This is the most novel rule in the set - no
109
+ * existing a11y linter flags these.
110
+ */
111
+ export const DISABILITY_METAPHORS = [
112
+ { term: /\bblind (spot|eye|to)\b/i, suggest: 'gap / oversight / unaware of', sources: 'NCDJ, A11y Collective' },
113
+ { term: /\bturning a blind eye\b/i, suggest: 'ignoring / overlooking', sources: 'NCDJ, A11y Collective' },
114
+ { term: /\btone[- ]deaf\b/i, suggest: 'out of touch / insensitive / unaware', sources: 'NCDJ, A11y Collective' },
115
+ { term: /\bfalling on deaf ears\b/i, suggest: 'being ignored / going unheard', sources: 'NCDJ, A11y Collective' },
116
+ { term: /\bdeaf ears\b/i, suggest: 'ignored / unheard', sources: 'A11y Collective' },
117
+ { term: /\bparalyzed (with|by)\b/i, suggest: 'overwhelmed by / unable to act because of', sources: 'NCDJ, A11y Collective' },
118
+ { term: /\bcrippling (debt|fear|blow|anxiety)\b/i, suggest: 'devastating / crushing / severe', sources: 'NCDJ, A11y Collective' },
119
+ { term: /\bschizophrenic (approach|strategy|policy|market)\b/i, suggest: 'contradictory / inconsistent / unpredictable', sources: 'NCDJ, APA' },
120
+ { term: /\bstands? on its own two feet\b/i, suggest: 'self-sufficient / independent', sources: 'A11y Collective' },
121
+ ]
122
+
123
+ /**
124
+ * English idioms that are opaque to ESL readers and international audiences.
125
+ *
126
+ * Methodology: compiled from Canadian Gov accessible docs guide, SJSU accessible
127
+ * writing strategies, UX Content Co., and A11y Collective. This is the largest
128
+ * gap in existing a11y linting - no other tool flags idioms. Terms are those
129
+ * where a fluent English idiom has no transparent literal meaning.
130
+ *
131
+ * Excludes idioms with obvious constituent meaning ("stand up for yourself").
132
+ */
133
+ export const ENGLISH_IDIOMS = [
134
+ // Business jargon idioms - appear in ≥2 accessible writing guides
135
+ { term: /\bboil the ocean\b/i, suggest: 'attempt everything at once / do too much', sources: 'SJSU, UX Content Co.' },
136
+ { term: /\bmove the needle\b/i, suggest: 'make progress / have an impact', sources: 'SJSU, Canadian Gov' },
137
+ { term: /\bblue[- ]?sky thinking\b/i, suggest: 'open-ended brainstorming / creative thinking', sources: 'SJSU, A11y Collective' },
138
+ { term: /\bdrink the (kool[- ]?aid|cool[- ]?aid)\b/i, suggest: 'follow without question / accept uncritically', sources: 'SJSU, Canadian Gov' },
139
+ { term: /\bpeel back the onion\b/i, suggest: 'examine more closely / dig deeper', sources: 'SJSU' },
140
+ { term: /\blow[- ]hanging fruit\b/i, suggest: 'easiest tasks / quick wins', sources: 'SJSU, UX Content Co.' },
141
+ { term: /\bsynergy\b/i, suggest: 'collaboration / combined effect (or rewrite)', sources: 'SJSU, plain language guides' },
142
+ { term: /\bpivot\b(?!\s+(table|point))/i, suggest: 'change direction / shift focus', sources: 'UX Content Co.' },
143
+ { term: /\bbandwidth\b(?!\s*(of|for|between|connection|limit|cap))/i, suggest: 'time / capacity / availability', sources: 'UX Content Co., SJSU' },
144
+ { term: /\bcircle back\b/i, suggest: 'follow up / return to', sources: 'SJSU, Canadian Gov' },
145
+ { term: /\btake (it )?offline\b/i, suggest: 'discuss separately / talk privately', sources: 'SJSU' },
146
+ { term: /\bdeep[- ]?dive\b/i, suggest: 'thorough review / detailed look', sources: 'SJSU, Canadian Gov' },
147
+ { term: /\bgranular\b/i, suggest: 'detailed / specific', sources: 'SJSU' },
148
+ { term: /\blevel[- ]?set\b/i, suggest: 'align / agree on expectations', sources: 'SJSU' },
149
+ { term: /\bpush the envelope\b/i, suggest: 'go beyond limits / innovate', sources: 'SJSU, A11y Collective' },
150
+ { term: /\bthink outside the box\b/i, suggest: 'think creatively / find new approaches', sources: 'SJSU, A11y Collective' },
151
+ { term: /\bwrap (my|your|our|their) head[s]? around\b/i, suggest: 'understand / make sense of', sources: 'A11y Collective' },
152
+ { term: /\bone[- ]?size[- ]?fits[- ]?all\b/i, suggest: 'universal / the same for everyone', sources: 'Canadian Gov' },
153
+ { term: /\bback to square one\b/i, suggest: 'starting over / back to the beginning', sources: 'SJSU, A11y Collective' },
154
+ { term: /\bin the pipeline\b/i, suggest: 'planned / coming soon / in progress', sources: 'SJSU, A11y Collective' },
155
+ { term: /\bon the same page\b/i, suggest: 'in agreement / aligned', sources: 'SJSU, Canadian Gov' },
156
+ { term: /\bcatch[- ]22\b/i, suggest: 'impossible situation / no-win situation', sources: 'SJSU, A11y Collective' },
157
+ { term: /\bwhistle[- ]?stop\b/i, suggest: 'brief / quick', sources: 'Canadian Gov' },
158
+ { term: /\braise the bar\b/i, suggest: 'set a higher standard / improve expectations', sources: 'SJSU' },
159
+ { term: /\bhit the ground running\b/i, suggest: 'start immediately / begin without delay', sources: 'SJSU, A11y Collective' },
160
+ { term: /\bon the fence\b/i, suggest: 'undecided / uncertain', sources: 'SJSU, A11y Collective' },
161
+ { term: /\bbite the bullet\b/i, suggest: 'endure something difficult / proceed despite difficulty', sources: 'SJSU, A11y Collective' },
162
+ { term: /\bunder the weather\b/i, suggest: 'unwell / sick / not feeling well', sources: 'SJSU, A11y Collective' },
163
+ { term: /\bball ?park (figure|estimate|number)?\b/i, suggest: 'rough estimate / approximate', sources: 'SJSU, Canadian Gov' },
164
+ { term: /\bin the ball ?park\b/i, suggest: 'approximately / roughly', sources: 'SJSU, Canadian Gov' },
165
+
166
+ // Sports idioms - opaque to non-sports audiences, appear in ≥2 guides
167
+ { term: /\bhit it out of the park\b/i, suggest: 'succeed greatly / do exceptionally well', sources: 'SJSU, A11y Collective' },
168
+ { term: /\bslam[- ]?dunk\b/i, suggest: 'certain success / easy win', sources: 'SJSU, A11y Collective' },
169
+ { term: /\bdrop the ball\b/i, suggest: 'make a mistake / fail to follow through', sources: 'SJSU, A11y Collective' },
170
+ { term: /\bgame[- ]?changer\b/i, suggest: 'major shift / significant development', sources: 'SJSU, Canadian Gov' },
171
+ { term: /\blevel (the |a )?playing field\b/i, suggest: 'create equal conditions / remove advantages', sources: 'SJSU, A11y Collective' },
172
+ { term: /\bmove the goal[- ]?posts\b/i, suggest: 'change the requirements / shift the target', sources: 'SJSU, A11y Collective' },
173
+ { term: /\bpinch[- ]?hitter\b/i, suggest: 'substitute / stand-in / backup', sources: 'SJSU' },
174
+ { term: /\bkick[- ]?off\b(?!\s+(meeting|call|event))/i, suggest: 'start / begin / launch', sources: 'A11y Collective' },
175
+ { term: /\btouch ?base\b/i, suggest: 'check in / reconnect / follow up', sources: 'SJSU, Canadian Gov' },
176
+ { term: /\bpar for the course\b/i, suggest: 'expected / typical / normal', sources: 'SJSU' },
177
+ { term: /\bfield (a |the )?question\b/i, suggest: 'answer / respond to / address', sources: 'SJSU' },
178
+ ]
179
+
180
+ /**
181
+ * Vague link and button text patterns.
182
+ *
183
+ * Methodology: WCAG SC 2.4.4 (Link Purpose in Context) requires link text to be
184
+ * understandable out of context. These patterns are the most-cited failures in
185
+ * WebAIM Million annual reports and appear in every accessible writing guide
186
+ * surveyed. Highest-confidence rules in this linter.
187
+ *
188
+ * Sources: W3C WAI, wcag.com/authors, Google Dev Style, SBA, UX Content Co.,
189
+ * A11y Collective, GOV.UK.
190
+ */
191
+ export const VAGUE_CTA_PATTERNS = [
192
+ /^click here$/i,
193
+ /^here$/i,
194
+ /^read more$/i,
195
+ /^learn more$/i,
196
+ /^more$/i,
197
+ /^this$/i,
198
+ /^link$/i,
199
+ /^this link$/i,
200
+ /^this page$/i,
201
+ /^click$/i,
202
+ /^tap here$/i,
203
+ /^tap$/i,
204
+ /^go$/i,
205
+ /^details$/i,
206
+ /^info$/i,
207
+ /^information$/i,
208
+ ]
209
+
210
+ /**
211
+ * Alt text anti-patterns.
212
+ *
213
+ * Methodology: WCAG SC 1.1.1 requires text alternatives for non-text content.
214
+ * These specific patterns appear in W3C WAI Tips, Section 508 alt text guide,
215
+ * Google Dev Style, and double-great/alt-text (11-rule library). Independently
216
+ * validated against the WebAIM Million report's most common alt text failures.
217
+ */
218
+ export const ALT_TEXT_PREFIXES = [
219
+ /^image of\s/i,
220
+ /^photo of\s/i,
221
+ /^picture of\s/i,
222
+ /^graphic of\s/i,
223
+ /^icon of\s/i,
224
+ /^illustration of\s/i,
225
+ /^screenshot of\s/i,
226
+ /^thumbnail of\s/i,
227
+ /^an image of\s/i,
228
+ /^a photo of\s/i,
229
+ /^a picture of\s/i,
230
+ ]
231
+
232
+ export const ALT_TEXT_FILENAME_PATTERN = /\.(png|jpe?g|gif|svg|webp|bmp|avif|tiff?|ico)(\s.*)?$/i
233
+
234
+ /**
235
+ * Directional language patterns.
236
+ *
237
+ * Methodology: content that references layout position breaks when users zoom,
238
+ * use screen readers, or view on small screens. Appears in SBA Style Guide and
239
+ * Google Dev Style as an explicit rule. Also aligns with WCAG SC 1.3.3
240
+ * (Sensory Characteristics).
241
+ */
242
+ export const DIRECTIONAL_PATTERNS = [
243
+ { term: /\b(the )?(right[- ]hand |left[- ]hand )?(side)?bar\b/i, suggest: 'use a heading or section name instead', sources: 'SBA, Google Dev Style' },
244
+ { term: /\bin the (right|left) (column|panel|sidebar|navigation|nav|menu)\b/i, suggest: 'use a heading or section name instead', sources: 'SBA, Google Dev Style' },
245
+ { term: /\b(see|refer to|check|click) (the )?(above|below)\b/i, suggest: '"see [section name]" or restructure', sources: 'SBA, Google Dev Style' },
246
+ { term: /as (shown|seen|described|mentioned|noted) (above|below)\b/i, suggest: '"as described in [section]" or restructure', sources: 'SBA, Google Dev Style' },
247
+ { term: /\bthe following (image|figure|table|chart|diagram) (above|below)\b/i, suggest: 'refer to the item by its caption or figure number', sources: 'SBA' },
248
+ ]
249
+
250
+ // ─── Rule factories ───────────────────────────────────────────────────────────
251
+
252
+ /**
253
+ * no-ableist-language
254
+ *
255
+ * Flags slurs, condescending euphemisms, suffering/tragedy framing, and
256
+ * normalcy framing when writing about disability.
257
+ *
258
+ * WCAG basis: SC 3.1.1 (Language of Page) - content must be perceivable and
259
+ * understandable. While WCAG does not enumerate specific words, the intent of
260
+ * SC 3.1.1 and the WCAG understanding document explicitly notes that language
261
+ * that demeans or excludes users undermines accessibility.
262
+ *
263
+ * Expert consensus: Every disability language guide surveyed (NCDJ, AP, ADA NN,
264
+ * APA, SIGACCESS) independently prohibits these terms. Zero credible sources
265
+ * defend them. Severity: error for slurs, warn for euphemisms.
266
+ *
267
+ * Sources: NCDJ, AP Stylebook, ADA National Network, APA Style, SIGACCESS,
268
+ * A11y Collective.
269
+ */
270
+ export function createNoAbleistLanguageRule() {
271
+ return {
272
+ meta: {
273
+ type: 'suggestion',
274
+ docs: {
275
+ description: 'Disallow ableist language, slurs, and suffering-framing when writing about disability',
276
+ url: 'https://github.com/a11yfred/neighbor#no-ableist-language',
277
+ },
278
+ messages: {
279
+ ableist: '"{{term}}" is ableist language. Suggestion: {{suggest}}. ({{sources}})',
280
+ },
281
+ schema: [
282
+ {
283
+ type: 'object',
284
+ properties: {
285
+ allow: { type: 'array', items: { type: 'string' } },
286
+ },
287
+ additionalProperties: false,
288
+ },
289
+ ],
290
+ },
291
+ create(context) {
292
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
293
+ return {
294
+ Literal(node) {
295
+ if (typeof node.value !== 'string') return
296
+ checkTermList(node, node.value, ABLEIST_TERMS, allow, context, 'ableist')
297
+ },
298
+ TemplateLiteral(node) {
299
+ for (const quasi of node.quasis) {
300
+ checkTermList(quasi, quasi.value.raw, ABLEIST_TERMS, allow, context, 'ableist')
301
+ }
302
+ },
303
+ }
304
+ },
305
+ }
306
+ }
307
+
308
+ /**
309
+ * no-disability-metaphor
310
+ *
311
+ * Flags figurative uses of disability-related language ("blind spot",
312
+ * "tone deaf", "paralyzed by").
313
+ *
314
+ * WCAG basis: No direct SC. Rule is grounded in expert consensus across ≥2
315
+ * independent authoritative disability language guides (NCDJ, A11y Collective,
316
+ * APA) and the W3C WAI guidance on inclusive writing. Severity: warn - these
317
+ * are common and authors may need context to revise.
318
+ *
319
+ * Sources: NCDJ, A11y Collective, APA Style.
320
+ */
321
+ export function createNoDisabilityMetaphorRule() {
322
+ return {
323
+ meta: {
324
+ type: 'suggestion',
325
+ docs: {
326
+ description: 'Disallow figurative use of disability-related terms (e.g. "blind spot", "tone deaf")',
327
+ url: 'https://github.com/a11yfred/neighbor#no-disability-metaphor',
328
+ },
329
+ messages: {
330
+ metaphor: '"{{term}}" uses disability as a metaphor. Suggestion: {{suggest}}. ({{sources}})',
331
+ },
332
+ schema: [
333
+ {
334
+ type: 'object',
335
+ properties: {
336
+ allow: { type: 'array', items: { type: 'string' } },
337
+ },
338
+ additionalProperties: false,
339
+ },
340
+ ],
341
+ },
342
+ create(context) {
343
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
344
+ return {
345
+ Literal(node) {
346
+ if (typeof node.value !== 'string') return
347
+ checkTermList(node, node.value, DISABILITY_METAPHORS, allow, context, 'metaphor')
348
+ },
349
+ TemplateLiteral(node) {
350
+ for (const quasi of node.quasis) {
351
+ checkTermList(quasi, quasi.value.raw, DISABILITY_METAPHORS, allow, context, 'metaphor')
352
+ }
353
+ },
354
+ }
355
+ },
356
+ }
357
+ }
358
+
359
+ /**
360
+ * no-english-idiom
361
+ *
362
+ * Flags English-language idioms and sports metaphors that are opaque to
363
+ * ESL readers, non-native English speakers, and international audiences.
364
+ *
365
+ * WCAG basis: SC 3.1.5 (Reading Level) - content should not require reading
366
+ * ability beyond lower secondary education level. Idioms systematically
367
+ * fail this requirement for non-native English speakers because their meaning
368
+ * cannot be inferred from constituent words.
369
+ *
370
+ * Expert consensus: Flagged in Canadian Gov accessible documents guide, SJSU
371
+ * accessible writing strategies, UX Content Co., and A11y Collective as a
372
+ * significant barrier for international and ESL users. This is the most novel
373
+ * rule in this linter - no existing a11y linting tool covers it.
374
+ *
375
+ * Sources: Canadian Gov, SJSU, UX Content Co., A11y Collective.
376
+ */
377
+ export function createNoEnglishIdiomRule() {
378
+ return {
379
+ meta: {
380
+ type: 'suggestion',
381
+ docs: {
382
+ description: 'Disallow English idioms and sports metaphors that are opaque to ESL and international readers',
383
+ url: 'https://github.com/a11yfred/neighbor#no-english-idiom',
384
+ },
385
+ messages: {
386
+ idiom: '"{{term}}" is an idiom that may be unclear to ESL readers. Suggestion: {{suggest}}. ({{sources}})',
387
+ },
388
+ schema: [
389
+ {
390
+ type: 'object',
391
+ properties: {
392
+ allow: { type: 'array', items: { type: 'string' } },
393
+ },
394
+ additionalProperties: false,
395
+ },
396
+ ],
397
+ },
398
+ create(context) {
399
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
400
+ return {
401
+ Literal(node) {
402
+ if (typeof node.value !== 'string') return
403
+ checkTermList(node, node.value, ENGLISH_IDIOMS, allow, context, 'idiom')
404
+ },
405
+ TemplateLiteral(node) {
406
+ for (const quasi of node.quasis) {
407
+ checkTermList(quasi, quasi.value.raw, ENGLISH_IDIOMS, allow, context, 'idiom')
408
+ }
409
+ },
410
+ }
411
+ },
412
+ }
413
+ }
414
+
415
+ /**
416
+ * no-vague-cta
417
+ *
418
+ * Flags vague call-to-action and link text like "click here", "read more",
419
+ * "learn more", or "here" used as the visible text of a link or button.
420
+ *
421
+ * WCAG basis: SC 2.4.4 (Link Purpose, In Context) - link purpose shall be
422
+ * determinable from the link text alone or from the link text together with
423
+ * its programmatically determined context. These patterns systematically fail
424
+ * the "link text alone" criterion.
425
+ *
426
+ * Expert consensus: Most-cited failure in WebAIM Million annual reports.
427
+ * Present in every accessible writing guide surveyed. Severity: error.
428
+ *
429
+ * Sources: W3C WAI, wcag.com/authors (SC 2.4.4), Google Dev Style, SBA,
430
+ * UX Content Co., A11y Collective, GOV.UK, WebAIM Million.
431
+ */
432
+ export function createNoVagueCTARule() {
433
+ return {
434
+ meta: {
435
+ type: 'problem',
436
+ docs: {
437
+ description: 'Disallow vague link and button text like "click here", "read more", or "here" (WCAG 2.4.4)',
438
+ url: 'https://github.com/a11yfred/neighbor#no-vague-cta',
439
+ },
440
+ messages: {
441
+ vagueCta:
442
+ '"{{text}}" is vague link or button text that fails WCAG 2.4.4. Screen reader users navigating by links will hear "{{text}}" with no context. Use descriptive text that explains the destination or action. (W3C WAI, WebAIM Million)',
443
+ },
444
+ schema: [
445
+ {
446
+ type: 'object',
447
+ properties: {
448
+ allow: { type: 'array', items: { type: 'string' } },
449
+ },
450
+ additionalProperties: false,
451
+ },
452
+ ],
453
+ },
454
+ create(context) {
455
+ const allow = new Set((context.options[0]?.allow ?? []).map(s => s.toLowerCase()))
456
+ return {
457
+ JSXElement(node) {
458
+ const opening = node.openingElement
459
+ const tag = opening.name?.name
460
+ if (tag !== 'a' && tag !== 'button' && tag !== 'Link') return
461
+ const text = extractJSXText(node).trim()
462
+ if (!text || allow.has(text.toLowerCase())) return
463
+ if (VAGUE_CTA_PATTERNS.some(p => p.test(text))) {
464
+ context.report({ node: opening, messageId: 'vagueCta', data: { text } })
465
+ }
466
+ },
467
+ }
468
+ },
469
+ }
470
+ }
471
+
472
+ /**
473
+ * no-directional-language
474
+ *
475
+ * Flags content that references position on screen using layout-dependent
476
+ * direction ("above", "in the right sidebar", "see below").
477
+ *
478
+ * WCAG basis: SC 1.3.3 (Sensory Characteristics) - instructions shall not
479
+ * rely solely on sensory characteristics of components, including location.
480
+ * Directional references fail for screen reader users, keyboard users, and
481
+ * users who zoom or reflowed the page.
482
+ *
483
+ * Expert consensus: Explicit rule in SBA Style Guide and Google Dev Style.
484
+ * Also covered in US Plain Language guide and A11y Collective.
485
+ *
486
+ * Sources: SBA Style Guide, Google Dev Style, A11y Collective, WCAG SC 1.3.3.
487
+ */
488
+ export function createNoDirectionalLanguageRule() {
489
+ return {
490
+ meta: {
491
+ type: 'suggestion',
492
+ docs: {
493
+ description: 'Disallow layout-dependent directional references ("above", "in the right sidebar") (WCAG 1.3.3)',
494
+ url: 'https://github.com/a11yfred/neighbor#no-directional-language',
495
+ },
496
+ messages: {
497
+ directional:
498
+ '{{match}} uses layout position to give instructions. Position-based references break when users zoom, reflow, or use screen readers. {{suggest}} (SBA, Google Dev Style, WCAG SC 1.3.3)',
499
+ },
500
+ schema: [],
501
+ },
502
+ create(context) {
503
+ return {
504
+ Literal(node) {
505
+ if (typeof node.value !== 'string') return
506
+ for (const { term, suggest } of DIRECTIONAL_PATTERNS) {
507
+ const m = node.value.match(term)
508
+ if (m) {
509
+ context.report({ node, messageId: 'directional', data: { match: m[0], suggest } })
510
+ }
511
+ }
512
+ },
513
+ }
514
+ },
515
+ }
516
+ }
517
+
518
+ /**
519
+ * no-unexplained-abbreviation
520
+ *
521
+ * Flags abbreviations and acronyms used without a prior explanation in the
522
+ * same file.
523
+ *
524
+ * WCAG basis: SC 3.1.4 (Abbreviations) - a mechanism is available for
525
+ * identifying the expanded form or meaning of abbreviations. Also SC 3.1.5
526
+ * (Reading Level) - unexplained jargon raises effective reading level.
527
+ *
528
+ * Expert consensus: Present in Google Dev Style, GOV.UK, wcag.com/authors,
529
+ * SBA Style Guide, US Plain Language guide, and Canadian Gov guide.
530
+ *
531
+ * Note: This rule tracks first use within each file and exempts known common
532
+ * abbreviations. Authors may configure additional exemptions.
533
+ *
534
+ * Sources: Google Dev Style, GOV.UK, wcag.com/authors (SC 3.1.4), SBA, US PL,
535
+ * Canadian Gov.
536
+ */
537
+ export function createNoUnexplainedAbbreviationRule() {
538
+ // Abbreviations universally understood without expansion per plain language guides
539
+ const ALWAYS_KNOWN = new Set([
540
+ 'HTML', 'CSS', 'JS', 'URL', 'API', 'PDF', 'UI', 'UX', 'ID', 'FAQ',
541
+ 'OK', 'US', 'UK', 'EU', 'UN', 'NATO', 'NASA', 'FBI', 'CIA', 'CDC',
542
+ 'WHO', 'GPS', 'WIFI', 'USB', 'HDMI', 'TV', 'PC', 'AM', 'PM', 'EST',
543
+ 'PST', 'GMT', 'UTC', 'HTTP', 'HTTPS', 'FTP', 'SSH', 'SQL', 'JSON',
544
+ 'XML', 'SVG', 'PNG', 'JPG', 'GIF', 'MP4', 'MP3', 'AI', 'ML', 'CEO',
545
+ 'CFO', 'CTO', 'HR', 'IT', 'PR', 'ROI', 'KPI', 'SLA', 'MVP', 'Q1',
546
+ 'Q2', 'Q3', 'Q4', 'BC', 'AD', 'AKA', 'ETA', 'RSVP', 'DIY', 'ADA',
547
+ 'WCAG', 'ARIA', 'WAI', 'W3C', 'ISO', 'RFC', 'IPv4', 'IPv6', 'DNS',
548
+ 'VPN', 'RAM', 'CPU', 'GPU', 'SSD', 'iOS', 'macOS', 'ARIA',
549
+ ])
550
+
551
+ return {
552
+ meta: {
553
+ type: 'suggestion',
554
+ docs: {
555
+ description: 'Require abbreviations and acronyms to be expanded on first use (WCAG 3.1.4)',
556
+ url: 'https://github.com/a11yfred/neighbor#no-unexplained-abbreviation',
557
+ },
558
+ messages: {
559
+ unexplained:
560
+ '"{{abbr}}" is used without explanation. Expand it on first use: "{{abbr}} (full name)" or "full name ({{abbr}})". (WCAG SC 3.1.4, Google Dev Style, GOV.UK)',
561
+ },
562
+ schema: [
563
+ {
564
+ type: 'object',
565
+ properties: {
566
+ known: { type: 'array', items: { type: 'string' } },
567
+ },
568
+ additionalProperties: false,
569
+ },
570
+ ],
571
+ },
572
+ create(context) {
573
+ const extraKnown = new Set(context.options[0]?.known ?? [])
574
+ const isKnown = abbr => ALWAYS_KNOWN.has(abbr) || extraKnown.has(abbr)
575
+ const defined = new Set()
576
+ const EXPANSION_PATTERN = /\b([A-Z][A-Z0-9]{1,9})\s*\(([^)]{3,})\)|\b([^()]{3,})\s*\(([A-Z][A-Z0-9]{1,9})\)/g
577
+ const ABBR_PATTERN = /\b([A-Z][A-Z0-9]{1,9})\b/g
578
+
579
+ return {
580
+ Program() {
581
+ defined.clear()
582
+ },
583
+ Literal(node) {
584
+ if (typeof node.value !== 'string') return
585
+ const text = node.value
586
+ let m
587
+ EXPANSION_PATTERN.lastIndex = 0
588
+ while ((m = EXPANSION_PATTERN.exec(text)) !== null) {
589
+ defined.add(m[1] ?? m[4])
590
+ }
591
+ ABBR_PATTERN.lastIndex = 0
592
+ while ((m = ABBR_PATTERN.exec(text)) !== null) {
593
+ const abbr = m[1]
594
+ if (isKnown(abbr) || defined.has(abbr)) continue
595
+ context.report({
596
+ node,
597
+ messageId: 'unexplained',
598
+ data: { abbr },
599
+ loc: {
600
+ start: { line: node.loc.start.line, column: node.loc.start.column + 1 + m.index },
601
+ end: { line: node.loc.start.line, column: node.loc.start.column + 1 + m.index + abbr.length },
602
+ },
603
+ })
604
+ defined.add(abbr)
605
+ }
606
+ },
607
+ }
608
+ },
609
+ }
610
+ }
611
+
612
+ /**
613
+ * no-all-caps-prose
614
+ *
615
+ * Flags words written in ALL CAPS in prose content.
616
+ *
617
+ * WCAG basis: No direct SC. However, screen readers using certain verbosity
618
+ * settings read ALL CAPS letter-by-letter (Google Dev Style cites this
619
+ * explicitly). Also degrades readability for users with dyslexia and cognitive
620
+ * disabilities.
621
+ *
622
+ * Expert consensus: Explicit rule in Google Dev Style, GOV.UK publishing guide,
623
+ * and Canadian Gov guide. Excluded: known uppercase acronyms (HTML, CSS, etc.)
624
+ * and words < 3 characters.
625
+ *
626
+ * Sources: Google Dev Style, GOV.UK, Canadian Gov.
627
+ */
628
+ export function createNoAllCapsProse() {
629
+ const KNOWN_ACRONYMS = new Set([
630
+ 'HTML', 'CSS', 'JS', 'URL', 'API', 'PDF', 'UI', 'UX', 'ID', 'FAQ',
631
+ 'OK', 'US', 'UK', 'EU', 'UN', 'NATO', 'NASA', 'FBI', 'CIA', 'CDC',
632
+ 'WHO', 'GPS', 'USB', 'HDMI', 'TV', 'PC', 'AM', 'PM', 'EST', 'PST',
633
+ 'GMT', 'UTC', 'HTTP', 'HTTPS', 'FTP', 'SSH', 'SQL', 'JSON', 'XML',
634
+ 'SVG', 'PNG', 'JPG', 'GIF', 'MP4', 'MP3', 'AI', 'ML', 'CEO', 'CFO',
635
+ 'CTO', 'HR', 'IT', 'PR', 'ROI', 'KPI', 'SLA', 'MVP', 'ADA', 'WCAG',
636
+ 'ARIA', 'WAI', 'W3C', 'ISO', 'RFC', 'VPN', 'RAM', 'CPU', 'GPU', 'SSD',
637
+ 'DIY', 'AKA', 'ETA', 'RSVP', 'TBD', 'TBA', 'FYI', 'ASAP', 'NOTE',
638
+ 'IMPORTANT', 'WARNING', 'CAUTION', 'DEPRECATED', 'TODO',
639
+ ])
640
+ const ALL_CAPS = /\b([A-Z]{3,})\b/g
641
+
642
+ return {
643
+ meta: {
644
+ type: 'suggestion',
645
+ docs: {
646
+ description: 'Disallow ALL CAPS in prose content - screen readers may spell it out letter by letter',
647
+ url: 'https://github.com/a11yfred/neighbor#no-all-caps-prose',
648
+ },
649
+ messages: {
650
+ allCaps:
651
+ '"{{word}}" is written in ALL CAPS. Screen readers using high verbosity may read it letter-by-letter. Use regular casing; add to the `known` option if this is a recognized acronym. (Google Dev Style, GOV.UK)',
652
+ },
653
+ schema: [
654
+ {
655
+ type: 'object',
656
+ properties: {
657
+ known: { type: 'array', items: { type: 'string' } },
658
+ },
659
+ additionalProperties: false,
660
+ },
661
+ ],
662
+ },
663
+ create(context) {
664
+ const extraKnown = new Set(context.options[0]?.known ?? [])
665
+ const isKnown = w => KNOWN_ACRONYMS.has(w) || extraKnown.has(w)
666
+
667
+ return {
668
+ Literal(node) {
669
+ if (typeof node.value !== 'string') return
670
+ let m
671
+ ALL_CAPS.lastIndex = 0
672
+ while ((m = ALL_CAPS.exec(node.value)) !== null) {
673
+ const word = m[1]
674
+ if (isKnown(word)) continue
675
+ context.report({
676
+ node,
677
+ messageId: 'allCaps',
678
+ data: { word },
679
+ loc: {
680
+ start: { line: node.loc.start.line, column: node.loc.start.column + 1 + m.index },
681
+ end: { line: node.loc.start.line, column: node.loc.start.column + 1 + m.index + word.length },
682
+ },
683
+ })
684
+ }
685
+ },
686
+ }
687
+ },
688
+ }
689
+ }
690
+
691
+ /**
692
+ * no-vague-error-message
693
+ *
694
+ * Flags error messages that do not explain what went wrong.
695
+ *
696
+ * WCAG basis: SC 3.3.1 (Error Identification) - if an input error is automatically
697
+ * detected, the item in error shall be described to the user. A message like
698
+ * "An error occurred" fails this because it identifies no item. SC 3.3.3
699
+ * (Error Suggestion) - if input error is detected, suggestions for correction
700
+ * shall be provided.
701
+ *
702
+ * Expert consensus: UX Content Co. and Google Dev Style both explicitly call out
703
+ * these patterns. Severity: warn - false positives possible when the string is
704
+ * a template partial.
705
+ *
706
+ * Sources: UX Content Co., Google Dev Style, WCAG SC 3.3.1, SC 3.3.3.
707
+ */
708
+ export function createNoVagueErrorMessageRule() {
709
+ const VAGUE_ERRORS = [
710
+ /^an? error occurred\.?$/i,
711
+ /^something went wrong\.?$/i,
712
+ /^error\.?$/i,
713
+ /^unknown error\.?$/i,
714
+ /^unexpected error\.?$/i,
715
+ /^oops[!.]?$/i,
716
+ /^oops,? something went wrong[!.]?$/i,
717
+ /^request failed\.?$/i,
718
+ /^operation failed\.?$/i,
719
+ /^failed\.?$/i,
720
+ /^try again later\.?$/i,
721
+ /^please try again\.?$/i,
722
+ ]
723
+
724
+ return {
725
+ meta: {
726
+ type: 'suggestion',
727
+ docs: {
728
+ description: 'Disallow vague error messages that do not explain what went wrong (WCAG 3.3.1)',
729
+ url: 'https://github.com/a11yfred/neighbor#no-vague-error-message',
730
+ },
731
+ messages: {
732
+ vagueError:
733
+ '"{{text}}" does not explain what went wrong or how to fix it, failing WCAG SC 3.3.1 and 3.3.3. Describe the specific error and provide a corrective action. (UX Content Co., Google Dev Style)',
734
+ },
735
+ schema: [],
736
+ },
737
+ create(context) {
738
+ return {
739
+ Literal(node) {
740
+ if (typeof node.value !== 'string') return
741
+ const text = node.value.trim()
742
+ if (text.length > 120) return
743
+ if (VAGUE_ERRORS.some(p => p.test(text))) {
744
+ context.report({ node, messageId: 'vagueError', data: { text } })
745
+ }
746
+ },
747
+ }
748
+ },
749
+ }
750
+ }
751
+
752
+ /**
753
+ * no-ampersand-in-prose
754
+ *
755
+ * Flags `&` used in place of "and" in prose text.
756
+ *
757
+ * WCAG basis: No direct SC. Screen readers may announce `&` inconsistently
758
+ * across AT vendors - some say "ampersand", some skip it. Google Dev Style
759
+ * cites this as an explicit accessibility concern.
760
+ *
761
+ * Expert consensus: Google Dev Style is the primary source; also noted in
762
+ * plain language guides as informal register that reduces clarity.
763
+ * Excludes legitimate code/UI uses (checking for literal `&` not `&amp;`).
764
+ *
765
+ * Sources: Google Dev Style, US Plain Language guide.
766
+ */
767
+ export function createNoAmpersandInProseRule() {
768
+ return {
769
+ meta: {
770
+ type: 'suggestion',
771
+ docs: {
772
+ description: 'Disallow & as a substitute for "and" in prose - screen readers may not announce it consistently',
773
+ url: 'https://github.com/a11yfred/neighbor#no-ampersand-in-prose',
774
+ },
775
+ messages: {
776
+ ampersand:
777
+ '"&" may be announced inconsistently by screen readers. Use "and" in prose. (Google Dev Style)',
778
+ },
779
+ schema: [],
780
+ },
781
+ create(context) {
782
+ return {
783
+ Literal(node) {
784
+ if (typeof node.value !== 'string') return
785
+ if (/\s&\s/.test(node.value)) {
786
+ context.report({ node, messageId: 'ampersand' })
787
+ }
788
+ },
789
+ }
790
+ },
791
+ }
792
+ }
793
+
794
+ // ─── Rule export map ─────────────────────────────────────────────────────────
795
+
796
+ export const CONTENT_RULE_FACTORIES = {
797
+ 'no-ableist-language': createNoAbleistLanguageRule,
798
+ 'no-disability-metaphor': createNoDisabilityMetaphorRule,
799
+ 'no-english-idiom': createNoEnglishIdiomRule,
800
+ 'no-vague-cta': createNoVagueCTARule,
801
+ 'no-directional-language': createNoDirectionalLanguageRule,
802
+ 'no-unexplained-abbreviation': createNoUnexplainedAbbreviationRule,
803
+ 'no-all-caps-prose': createNoAllCapsProse,
804
+ 'no-vague-error-message': createNoVagueErrorMessageRule,
805
+ 'no-ampersand-in-prose': createNoAmpersandInProseRule,
806
+ }
807
+
808
+ /**
809
+ * Recommended severity config.
810
+ *
811
+ * Tiers:
812
+ * error - WCAG statutory basis + universal expert consensus + low false-positive rate
813
+ * warn - strong expert consensus but higher false-positive risk or context-dependence
814
+ * off - valid rule but too noisy for most codebases; opt in individually
815
+ */
816
+ export function buildContentRecommendedRules(ns) {
817
+ return {
818
+ [`${ns}/no-ableist-language`]: 'warn',
819
+ [`${ns}/no-disability-metaphor`]: 'warn',
820
+ [`${ns}/no-english-idiom`]: 'warn',
821
+ [`${ns}/no-vague-cta`]: 'warn',
822
+ [`${ns}/no-directional-language`]: 'warn',
823
+ [`${ns}/no-unexplained-abbreviation`]: 'warn',
824
+ [`${ns}/no-all-caps-prose`]: 'warn',
825
+ [`${ns}/no-vague-error-message`]: 'warn',
826
+ [`${ns}/no-ampersand-in-prose`]: 'warn',
827
+ }
828
+ }
829
+
830
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
831
+
832
+ function checkTermList(node, text, termList, allow, context, messageId) {
833
+ for (const { term, suggest, sources } of termList) {
834
+ const m = text.match(term)
835
+ if (!m) continue
836
+ const matched = m[0]
837
+ if (allow.has(matched.toLowerCase())) continue
838
+ context.report({
839
+ node,
840
+ messageId,
841
+ data: { term: matched, suggest, sources },
842
+ })
843
+ }
844
+ }
845
+
846
+ function extractJSXText(node) {
847
+ let text = ''
848
+ for (const child of node.children ?? []) {
849
+ if (child.type === 'JSXText') {
850
+ text += child.value
851
+ } else if (child.type === 'JSXExpressionContainer' && child.expression.type === 'Literal') {
852
+ text += String(child.expression.value)
853
+ } else if (child.type === 'JSXElement') {
854
+ text += extractJSXText(child)
855
+ }
856
+ }
857
+ return text
858
+ }