├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── index.js ├── lib ├── a.js ├── an.js └── index.js ├── license ├── package.json ├── readme.md ├── test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | name: bb 2 | on: 3 | issues: 4 | types: [opened, reopened, edited, closed, labeled, unlabeled] 5 | pull_request_target: 6 | types: [opened, reopened, edited, closed, labeled, unlabeled] 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: unifiedjs/beep-boop-beta@main 12 | with: 13 | repo-token: ${{secrets.GITHUB_TOKEN}} 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | - pull_request 4 | - push 5 | jobs: 6 | main: 7 | name: ${{matrix.node}} 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: ${{matrix.node}} 14 | - run: npm install 15 | - run: npm test 16 | - uses: codecov/codecov-action@v3 17 | strategy: 18 | matrix: 19 | node: 20 | - lts/gallium 21 | - node 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | .DS_Store 4 | *.d.ts 5 | *.log 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.md 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export {default} from './lib/index.js' 2 | -------------------------------------------------------------------------------- /lib/a.js: -------------------------------------------------------------------------------- 1 | /** @type {ReadonlyArray} */ 2 | export const a = [ 3 | // Please sort these entries. 4 | 5 | // Please *only* use casing if it adds something: `url` is fine, but it’s 6 | // `a UN meeting` and `an un-united germany`. 7 | 'U', 8 | 'UN', 9 | 'eucalypt*', 10 | 'eucha*', 11 | 'euchr*', 12 | 'euclid*', 13 | 'eucrite', 14 | 'eucryphia', 15 | 'eugen*', 16 | 'eukar*', 17 | 'eulog*', 18 | 'eunice', 19 | 'eunuch', 20 | 'euph*', 21 | 'eura*', 22 | 'eure*', 23 | 'euro*', 24 | 'eury*', 25 | 'eutha*', 26 | 'ewe', 27 | 'ewer', 28 | 'habitual', 29 | 'hallucin*', 30 | 'herb*', 31 | 'heredit*', 32 | 'hilarious*', 33 | 'histor*', 34 | 'horrend*', 35 | 'horrif*', 36 | 'hotel*', 37 | 'ms', 38 | 'once', 39 | 'one', 40 | 'one-*', 41 | 'one/', 42 | 'oneanother', 43 | 'oneberry', 44 | 'onefold*', 45 | 'oneheart*', 46 | 'oneness*', 47 | 'oneself*', 48 | 'onetime*', 49 | 'oneway', 50 | 'onewhere*', 51 | 'oneyear', 52 | 'ubiq*', 53 | 'uefa', 54 | 'ugandan', 55 | 'uk', 56 | 'ukase', 57 | 'ukrain*', 58 | 'ukulele', 59 | 'ululated', 60 | 'ululation', 61 | 'unanim*', 62 | 'unary', 63 | 'unesco', 64 | 'unhcr', 65 | 'uniam*', 66 | 'uniart*', 67 | 'uniat*', 68 | 'uniaur*', 69 | 'uniax*', 70 | 'unibas*', 71 | 'unible', 72 | 'unicycl*', 73 | 'unidirect*', 74 | 'unif*', 75 | 'union*', 76 | 'uniq*', 77 | 'unist', 78 | 'unit*', 79 | 'univ*', 80 | 'uran*', 81 | 'urate', 82 | 'uri*', 83 | 'url', 84 | 'urologist', 85 | 'uruguay', 86 | 'uruguayan', 87 | 'uruguayans', 88 | 'us', 89 | 'usab*', 90 | 'usage', 91 | 'usb', 92 | 'use*', 93 | 'using', 94 | 'usu*', 95 | 'utah', 96 | 'uten*', 97 | 'uter*', 98 | 'util*', 99 | 'utop*', 100 | 'utrecht', 101 | 'uttoxeter', 102 | 'uvula', 103 | 'uvular', 104 | 'uyghur' 105 | ] 106 | -------------------------------------------------------------------------------- /lib/an.js: -------------------------------------------------------------------------------- 1 | /** @type {ReadonlyArray} */ 2 | export const an = [ 3 | // Please sort these entries. 4 | // Please *only* use casing if it adds something: `url` is fine, but it’s 5 | // `a UN meeting` and `an un-united germany`. 6 | 'IOU', 7 | 'MA', 8 | 'MS', 9 | 'f', 10 | 'fbi', 11 | 'fda', 12 | 'fm', 13 | 'h', 14 | 'h1', 15 | 'h2', 16 | 'h3', 17 | 'h4', 18 | 'h5', 19 | 'h6', 20 | 'habitual', 21 | 'hallucin*', 22 | 'hauteur', 23 | 'hb', 24 | 'heir*', 25 | 'herb*', 26 | 'heredit*', 27 | 'hilarious*', 28 | 'histor*', 29 | 'hiv', 30 | 'homage', 31 | 'honest*', 32 | 'honor*', 33 | 'honour*', 34 | 'horrend*', 35 | 'horrif*', 36 | 'hotel*', 37 | 'hour*', 38 | 'html', 39 | 'l', 40 | 'lbw', 41 | 'lcd', 42 | 'm', 43 | 'mba', 44 | 'mdast', 45 | 'mpg', 46 | 'mph', 47 | 'mri', 48 | 'msc', 49 | 'mtv', 50 | 'n', 51 | 'nba', 52 | 'nbc', 53 | 'nfl', 54 | 'ngo', 55 | 'nhl', 56 | 'nlcst', 57 | 'npm', 58 | 'nvidia', 59 | 'r&d', 60 | 'r', 61 | 'raf', 62 | 's', 63 | 'sgml', 64 | 'sms', 65 | 'sos', 66 | 'spf', 67 | 'std', 68 | 'suv', 69 | 'x', 70 | 'x-ray', 71 | 'x-rays', 72 | 'xbox', 73 | 'xmas', 74 | 'xml', 75 | 'α' 76 | ] 77 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('nlcst').Root} Root 3 | * @typedef {import('nlcst').Sentence} Sentence 4 | * @typedef {import('nlcst').Word} Word 5 | * 6 | * @typedef {import('vfile').VFile} VFile 7 | */ 8 | 9 | /** 10 | * @typedef {'a' | 'a-or-an' | 'an'} Type 11 | * Type. 12 | */ 13 | 14 | import {toString} from 'nlcst-to-string' 15 | import numberToWords from 'number-to-words' 16 | import {visit} from 'unist-util-visit' 17 | import {a} from './a.js' 18 | import {an} from './an.js' 19 | 20 | const needsA = createCheck(a) 21 | const needsAn = createCheck(an) 22 | 23 | /** 24 | * Check `a` and `an`. 25 | * 26 | * @returns 27 | * Transform. 28 | */ 29 | export default function retextIndefiniteArticle() { 30 | /** 31 | * Transform. 32 | * 33 | * @param {Root} tree 34 | * Tree. 35 | * @param {VFile} file 36 | * File. 37 | * @returns {undefined} 38 | * Nothing. 39 | */ 40 | return function (tree, file) { 41 | visit(tree, 'WordNode', function (node, index, parent) { 42 | const actual = toString(node) 43 | const normal = actual.toLowerCase() 44 | 45 | if ( 46 | (normal !== 'a' && normal !== 'an') || 47 | !parent || 48 | index === undefined 49 | ) { 50 | return 51 | } 52 | 53 | const next = after(parent, index) 54 | 55 | if (!next) { 56 | return 57 | } 58 | 59 | const an = actual.length !== 1 60 | const following = toString(next) 61 | 62 | // Exit if `A` and this isn’t sentence-start: `Station A equals` 63 | if (normal !== actual && !an && !firstWord(parent, index)) { 64 | return 65 | } 66 | 67 | // Exit if `a` is used as a letter: `a and b`. 68 | if (normal === actual && !an && /^(and|nor|or)$/i.test(following)) { 69 | return 70 | } 71 | 72 | /** @type {string | undefined} */ 73 | let expected = classify(following) 74 | 75 | if (!(expected === 'an' && !an) && !(expected === 'a' && an)) { 76 | return 77 | } 78 | 79 | if (normal !== actual) { 80 | expected = expected.charAt(0).toUpperCase() + expected.slice(1) 81 | } 82 | 83 | const message = file.message( 84 | 'Unexpected article `' + 85 | actual + 86 | '` before `' + 87 | following + 88 | '`, expected `' + 89 | expected + 90 | '`', 91 | { 92 | ancestors: [parent, node], 93 | place: node.position, 94 | ruleId: 'retext-indefinite-article', 95 | source: 'retext-indefinite-article' 96 | } 97 | ) 98 | 99 | message.actual = actual 100 | message.expected = [expected] 101 | message.url = 102 | 'https://github.com/retextjs/retext-indefinite-article#readme' 103 | }) 104 | } 105 | } 106 | 107 | /** 108 | * Check if there’s no word before `index`. 109 | * 110 | * @param {Root | Sentence} parent 111 | * Node. 112 | * @param {number} index 113 | * Index. 114 | * @returns {boolean} 115 | * Whether there is no word before `index`. 116 | */ 117 | function firstWord(parent, index) { 118 | const siblings = parent.children 119 | 120 | while (index--) { 121 | if (siblings[index].type === 'WordNode') { 122 | return false 123 | } 124 | } 125 | 126 | return true 127 | } 128 | 129 | /** 130 | * Get the next word. 131 | * 132 | * @param {Root | Sentence} parent 133 | * Node. 134 | * @param {number} index 135 | * Index. 136 | * @returns {Word | undefined} 137 | * Next word. 138 | */ 139 | function after(parent, index) { 140 | const siblings = parent.children 141 | let sibling = siblings[++index] 142 | /** @type {Word | undefined} */ 143 | let other 144 | 145 | if (sibling && sibling.type === 'WhiteSpaceNode') { 146 | sibling = siblings[++index] 147 | 148 | if ( 149 | sibling && 150 | sibling.type === 'PunctuationNode' && 151 | /^[“”‘’'"()[\]]$/.test(toString(sibling)) 152 | ) { 153 | sibling = siblings[++index] 154 | } 155 | 156 | if (sibling && sibling.type === 'WordNode') { 157 | other = sibling 158 | } 159 | } 160 | 161 | return other 162 | } 163 | 164 | /** 165 | * Classify a word. 166 | * 167 | * @param {string} value 168 | * Word to classify. 169 | * @returns {Type | undefined} 170 | * Type. 171 | */ 172 | function classify(value) { 173 | const head = value 174 | .replace(/^\d+/, function (value) { 175 | return numberToWords.toWords(value) + ' ' 176 | }) 177 | .split(/['’ -]/, 1)[0] 178 | const a = needsA(head) 179 | const an = needsAn(head) 180 | 181 | return a && an 182 | ? 'a-or-an' 183 | : a 184 | ? 'a' 185 | : an 186 | ? 'an' 187 | : head.toLowerCase() === head 188 | ? /[aeiou]/.test(head.charAt(0).toLowerCase()) 189 | ? 'an' 190 | : 'a' 191 | : undefined 192 | } 193 | 194 | /** 195 | * Create a test based on a list of phrases. 196 | * 197 | * @param {ReadonlyArray} list 198 | * List of phrases. 199 | * @returns 200 | * Check. 201 | */ 202 | function createCheck(list) { 203 | /** @type {Set} */ 204 | const expressions = new Set() 205 | /** @type {Set} */ 206 | const sensitive = new Set() 207 | /** @type {Set} */ 208 | const insensitive = new Set() 209 | let index = -1 210 | 211 | while (++index < list.length) { 212 | const value = list[index] 213 | 214 | if (value.charAt(value.length - 1) === '*') { 215 | // Regexes are insensitive now, once we need them this should check for 216 | // `normal` as well. 217 | expressions.add(new RegExp('^' + value.slice(0, -1), 'i')) 218 | } else if (value === value.toLowerCase()) { 219 | insensitive.add(value) 220 | } else { 221 | sensitive.add(value) 222 | } 223 | } 224 | 225 | return check 226 | 227 | /** 228 | * Check. 229 | * 230 | * @param {string} value 231 | * Value to check. 232 | * @returns {boolean} 233 | * Whether `value` is in `list`. 234 | */ 235 | function check(value) { 236 | if (sensitive.has(value) || insensitive.has(value.toLowerCase())) { 237 | return true 238 | } 239 | 240 | for (const expression of expressions) { 241 | if (expression.test(value)) { 242 | return true 243 | } 244 | } 245 | 246 | return false 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2016 Titus Wormer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "retext-indefinite-article", 3 | "version": "5.0.0", 4 | "description": "retext plugin to check if indefinite articles (`a`, `an`) are used correctly", 5 | "license": "MIT", 6 | "keywords": [ 7 | "a", 8 | "an", 9 | "article", 10 | "articles", 11 | "indefinite", 12 | "plugin", 13 | "retext", 14 | "retext-plugin", 15 | "unified" 16 | ], 17 | "repository": "retextjs/retext-indefinite-article", 18 | "bugs": "https://github.com/retextjs/retext-indefinite-article/issues", 19 | "funding": { 20 | "type": "opencollective", 21 | "url": "https://opencollective.com/unified" 22 | }, 23 | "author": "Titus Wormer (https://wooorm.com)", 24 | "contributors": [ 25 | "Titus Wormer (https://wooorm.com)" 26 | ], 27 | "sideEffects": false, 28 | "type": "module", 29 | "exports": "./index.js", 30 | "files": [ 31 | "lib/", 32 | "index.d.ts", 33 | "index.js" 34 | ], 35 | "dependencies": { 36 | "@types/nlcst": "^2.0.0", 37 | "nlcst-to-string": "^4.0.0", 38 | "number-to-words": "^1.0.0", 39 | "unist-util-visit": "^5.0.0", 40 | "vfile": "^6.0.0" 41 | }, 42 | "devDependencies": { 43 | "@types/node": "^20.0.0", 44 | "@types/number-to-words": "^1.0.0", 45 | "c8": "^8.0.0", 46 | "chalk": "^5.0.0", 47 | "prettier": "^3.0.0", 48 | "remark-cli": "^11.0.0", 49 | "remark-preset-wooorm": "^9.0.0", 50 | "retext": "^9.0.0", 51 | "type-coverage": "^2.0.0", 52 | "typescript": "^5.0.0", 53 | "xo": "^0.56.0" 54 | }, 55 | "scripts": { 56 | "build": "tsc --build --clean && tsc --build && type-coverage", 57 | "format": "remark . --frail --output --quiet && prettier . --log-level warn --write && xo --fix", 58 | "prepack": "npm run build && npm run format", 59 | "test": "npm run build && npm run format && npm run test-coverage", 60 | "test-api": "node --conditions development test.js", 61 | "test-coverage": "c8 --100 --check-coverage --reporter lcov npm run test-api" 62 | }, 63 | "prettier": { 64 | "bracketSpacing": false, 65 | "singleQuote": true, 66 | "semi": false, 67 | "tabWidth": 2, 68 | "trailingComma": "none", 69 | "useTabs": false 70 | }, 71 | "remarkConfig": { 72 | "plugins": [ 73 | "remark-preset-wooorm" 74 | ] 75 | }, 76 | "typeCoverage": { 77 | "atLeast": 100, 78 | "detail": true, 79 | "ignoreCatch": true, 80 | "strict": true 81 | }, 82 | "xo": { 83 | "overrides": [ 84 | { 85 | "files": [ 86 | "test.js" 87 | ], 88 | "rules": { 89 | "no-await-in-loop": "off" 90 | } 91 | } 92 | ], 93 | "prettier": true, 94 | "rules": { 95 | "unicorn/prefer-at": "off", 96 | "unicorn/prefer-string-replace-all": "off" 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # retext-indefinite-article 2 | 3 | [![Build][build-badge]][build] 4 | [![Coverage][coverage-badge]][coverage] 5 | [![Downloads][downloads-badge]][downloads] 6 | [![Size][size-badge]][size] 7 | [![Sponsors][sponsors-badge]][collective] 8 | [![Backers][backers-badge]][collective] 9 | [![Chat][chat-badge]][chat] 10 | 11 | **[retext][]** plugin to check `a` and `an`. 12 | 13 | ## Contents 14 | 15 | * [What is this?](#what-is-this) 16 | * [When should I use this?](#when-should-i-use-this) 17 | * [Install](#install) 18 | * [Use](#use) 19 | * [API](#api) 20 | * [`unified().use(retextIndefiniteArticle)`](#unifieduseretextindefinitearticle) 21 | * [Messages](#messages) 22 | * [Types](#types) 23 | * [Compatibility](#compatibility) 24 | * [Related](#related) 25 | * [Contribute](#contribute) 26 | * [License](#license) 27 | 28 | ## What is this? 29 | 30 | This package is a [unified][] ([retext][]) plugin to check indefinite articles 31 | (`a` and `an`). 32 | It’s more complex than checking vowels, as it has to do with sounds. 33 | This package knows about how digits are pronounced as well. 34 | 35 | ## When should I use this? 36 | 37 | You can use this plugin when you’re dealing with content that might contain 38 | grammar mistakes, and have authors that can fix that content. 39 | 40 | ## Install 41 | 42 | This package is [ESM only][esm]. 43 | In Node.js (version 16+), install with [npm][]: 44 | 45 | ```sh 46 | npm install retext-indefinite-article 47 | ``` 48 | 49 | In Deno with [`esm.sh`][esmsh]: 50 | 51 | ```js 52 | import retextIndefiniteArticle from 'https://esm.sh/retext-indefinite-article@5' 53 | ``` 54 | 55 | In browsers with [`esm.sh`][esmsh]: 56 | 57 | ```html 58 | 61 | ``` 62 | 63 | ## Use 64 | 65 | Say our document `example.txt` contains: 66 | 67 | ```txt 68 | He should, a 8-year old boy, should have arrived a hour 69 | ago on an European flight. 70 | An historic event, or a historic event? Both are fine. 71 | ``` 72 | 73 | …and our module `example.js` contains: 74 | 75 | ```js 76 | import retextEnglish from 'retext-english' 77 | import retextIndefiniteArticle from 'retext-indefinite-article' 78 | import retextStringify from 'retext-stringify' 79 | import {read} from 'to-vfile' 80 | import {unified} from 'unified' 81 | import {reporter} from 'vfile-reporter' 82 | 83 | const file = await unified() 84 | .use(retextEnglish) 85 | .use(retextIndefiniteArticle) 86 | .use(retextStringify) 87 | .process(await read('example.txt')) 88 | 89 | console.error(reporter(file)) 90 | ``` 91 | 92 | …now running `node example.js` yields: 93 | 94 | ```txt 95 | example.txt 96 | 1:12-1:13 warning Unexpected article `a` before `8-year`, expected `an` retext-indefinite-article retext-indefinite-article 97 | 1:50-1:51 warning Unexpected article `a` before `hour`, expected `an` retext-indefinite-article retext-indefinite-article 98 | 2:8-2:10 warning Unexpected article `an` before `European`, expected `a` retext-indefinite-article retext-indefinite-article 99 | 100 | ⚠ 3 warnings 101 | ``` 102 | 103 | ## API 104 | 105 | This package exports no identifiers. 106 | The default export is 107 | [`retextIndefiniteArticle`][api-retext-indefinite-article]. 108 | 109 | ### `unified().use(retextIndefiniteArticle)` 110 | 111 | Check `a` and `an`. 112 | 113 | ###### Parameters 114 | 115 | There are no parameters. 116 | 117 | ###### Returns 118 | 119 | Transform ([`Transformer`][unified-transformer]). 120 | 121 | ## Messages 122 | 123 | Each message is emitted as a [`VFileMessage`][vfile-message] on `file`, with 124 | `source` set to `'retext-indefinite-article'`, `ruleId` to 125 | `'retext-indefinite-article'`, `actual` to the unexpected word, and `expected` 126 | to suggestions. 127 | 128 | ## Types 129 | 130 | This package is fully typed with [TypeScript][]. 131 | It exports no additional types. 132 | 133 | ## Compatibility 134 | 135 | Projects maintained by the unified collective are compatible with maintained 136 | versions of Node.js. 137 | 138 | When we cut a new major release, we drop support for unmaintained versions of 139 | Node. 140 | This means we try to keep the current release line, 141 | `retext-indefinite-article@^5`, compatible with Node.js 16. 142 | 143 | ## Related 144 | 145 | * [`retext-redundant-acronyms`](https://github.com/retextjs/retext-redundant-acronyms) 146 | — check for redundant acronyms (`ATM machine`) 147 | * [`retext-repeated-words`](https://github.com/retextjs/retext-repeated-words) 148 | — check `for for` repeated words 149 | 150 | ## Contribute 151 | 152 | See [`contributing.md`][contributing] in [`retextjs/.github`][health] for ways 153 | to get started. 154 | See [`support.md`][support] for ways to get help. 155 | 156 | This project has a [code of conduct][coc]. 157 | By interacting with this repository, organization, or community you agree to 158 | abide by its terms. 159 | 160 | ## License 161 | 162 | [MIT][license] © [Titus Wormer][author] 163 | 164 | 165 | 166 | [build-badge]: https://github.com/retextjs/retext-indefinite-article/workflows/main/badge.svg 167 | 168 | [build]: https://github.com/retextjs/retext-indefinite-article/actions 169 | 170 | [coverage-badge]: https://img.shields.io/codecov/c/github/retextjs/retext-indefinite-article.svg 171 | 172 | [coverage]: https://codecov.io/github/retextjs/retext-indefinite-article 173 | 174 | [downloads-badge]: https://img.shields.io/npm/dm/retext-indefinite-article.svg 175 | 176 | [downloads]: https://www.npmjs.com/package/retext-indefinite-article 177 | 178 | [size-badge]: https://img.shields.io/bundlejs/size/retext-indefinite-article 179 | 180 | [size]: https://bundlejs.com/?q=retext-indefinite-article 181 | 182 | [sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg 183 | 184 | [backers-badge]: https://opencollective.com/unified/backers/badge.svg 185 | 186 | [collective]: https://opencollective.com/unified 187 | 188 | [chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg 189 | 190 | [chat]: https://github.com/retextjs/retext/discussions 191 | 192 | [npm]: https://docs.npmjs.com/cli/install 193 | 194 | [esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 195 | 196 | [esmsh]: https://esm.sh 197 | 198 | [typescript]: https://www.typescriptlang.org 199 | 200 | [health]: https://github.com/retextjs/.github 201 | 202 | [contributing]: https://github.com/retextjs/.github/blob/main/contributing.md 203 | 204 | [support]: https://github.com/retextjs/.github/blob/main/support.md 205 | 206 | [coc]: https://github.com/retextjs/.github/blob/main/code-of-conduct.md 207 | 208 | [license]: license 209 | 210 | [author]: https://wooorm.com 211 | 212 | [retext]: https://github.com/retextjs/retext 213 | 214 | [unified]: https://github.com/unifiedjs/unified 215 | 216 | [unified-transformer]: https://github.com/unifiedjs/unified#transformer 217 | 218 | [vfile-message]: https://github.com/vfile/vfile-message 219 | 220 | [api-retext-indefinite-article]: #unifieduseretextindefinitearticle 221 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import test from 'node:test' 3 | import chalk from 'chalk' 4 | import {retext} from 'retext' 5 | import retextIndefiniteArticle from 'retext-indefinite-article' 6 | 7 | test('retextIndefiniteArticle', async function (t) { 8 | await t.test('should expose the public api', async function () { 9 | assert.deepEqual( 10 | Object.keys(await import('retext-indefinite-article')).sort(), 11 | ['default'] 12 | ) 13 | }) 14 | 15 | await t.test('should catch indefinite articles', async function () { 16 | const file = await retext() 17 | .use(retextIndefiniteArticle) 18 | .process( 19 | [ 20 | 'He should have arrived a hour ago on an European flight.', 21 | 'an historic event, or a historic event?' 22 | ].join('\n') 23 | ) 24 | 25 | assert.deepEqual(file.messages.map(String), [ 26 | '1:24-1:25: Unexpected article `a` before `hour`, expected `an`', 27 | '1:38-1:40: Unexpected article `an` before `European`, expected `a`' 28 | ]) 29 | }) 30 | 31 | const good = [ 32 | // https://en.wikipedia.org/wiki/Article_(grammar)#Indefinite_article 33 | 'She had a house so large that an elephant would get lost without a map', 34 | 'a European', 35 | 'a hallucination', 36 | 'an hallucination', 37 | 'a hilarious', 38 | 'an hilarious', 39 | 'a historic', 40 | 'an historic', 41 | 'a historical', 42 | 'an historical', 43 | 'a horrendous', 44 | 'an horrendous', 45 | 'a horrific', 46 | 'an horrific', 47 | 'a hotel', 48 | 'an hotel', 49 | 'a herb', 50 | 'an herb', 51 | 'a hereditary', 52 | 'an hereditary', 53 | 'a unist tree', 54 | 'an mdast tree', 55 | 56 | 'This is a test sentence', 57 | 'It was an hour ago', 58 | 'A unidirectional flow', 59 | 'A unified thing', 60 | 'A university is', 61 | 'A one-way street', 62 | 'An hour’s work', 63 | 'Going to an “industry party”', 64 | 'An 8-year old boy', 65 | 'An 18-year old boy', 66 | 'a 1px border', 67 | 'a 100m sprint', 68 | 'a 1rem font size', 69 | 'a 1/4" hole', 70 | 'a 1-off thing', 71 | 'a 1x increase', 72 | 'a 1:1 correspondence', 73 | 'a 1:00 train', 74 | 'a 1.0 release', 75 | 'an h1', 76 | 'an h6', 77 | 'The A-levels are', 78 | 'An NOP check', 79 | 'A USA-wide license', 80 | 'asked a UN member', 81 | 'In an un-united Germany', 82 | 'Here, a and b are supplementary angles', 83 | 'Station A equals station B', 84 | 'A University', 85 | 'a unique identifier', 86 | 'A Europe wide thing', 87 | 'an npm package', 88 | 'A. R.J. Turgot', 89 | 'an MSc', 90 | 'an XMR-based', 91 | "plural A’s, A's, As, as, or a’s, a's.", 92 | 'They form a union and get laws passed.', 93 | // Don’t fail without words. 94 | 'Station N equals station A', 95 | 'Station N equals station A.', 96 | 'a.', 97 | 'an.', 98 | 'a "', 99 | 'a (', 100 | // Punctuation. 101 | 'an “hour', 102 | 'a "bicycle"', 103 | 'a unicycle' 104 | ] 105 | 106 | let index = -1 107 | 108 | while (++index < good.length) { 109 | const value = good[index] 110 | 111 | await t.test( 112 | 'should work on good `' + highlight(value) + '`', 113 | async function () { 114 | const file = await retext().use(retextIndefiniteArticle).process(value) 115 | assert.deepEqual(file.messages.map(String), []) 116 | } 117 | ) 118 | } 119 | 120 | const bad = [ 121 | 'was a hour ago', 122 | 'was an sentence', 123 | 'An unidirectional flow', 124 | 'was a uninteresting', 125 | 'An university', 126 | 'A uninteresting', 127 | 'A hour’s work', 128 | 'to a “industry party”', 129 | '"It was a uninteresting talk"', 130 | 'then an University', 131 | 'A 8-year old', 132 | 'A 18-year old', 133 | 'An 1/4" hole', 134 | 'An 1-off thing', 135 | 'an 1:1 correspondence', 136 | 'an 1:00 train', 137 | 'a h1', 138 | 'a h6', 139 | 'asked an UN member', 140 | 'In a un-united Germany', 141 | 'Anyone for a MSc?', 142 | 'They form an union and get laws passed.', 143 | 'an unicycle', 144 | 'an URL' 145 | ] 146 | 147 | index = -1 148 | 149 | while (++index < bad.length) { 150 | const value = bad[index] 151 | 152 | await t.test( 153 | 'should work on bad `' + highlight(value) + '`', 154 | async function () { 155 | const file = await retext().use(retextIndefiniteArticle).process(value) 156 | assert.equal(file.messages.length, 1) 157 | } 158 | ) 159 | } 160 | }) 161 | 162 | /** 163 | * @param {string} name 164 | * Value. 165 | * @returns {name} 166 | * Value with `a` and `an` highlighted. 167 | */ 168 | function highlight(name) { 169 | return name.replace(/\ban?\b/gi, function ($0) { 170 | return chalk.bold($0) 171 | }) 172 | } 173 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "exactOptionalPropertyTypes": true, 8 | "lib": ["es2022"], 9 | "module": "node16", 10 | "strict": true, 11 | "target": "es2022" 12 | }, 13 | "exclude": ["coverage/", "node_modules/"], 14 | "include": ["**/*.js"] 15 | } 16 | --------------------------------------------------------------------------------