diff --git a/.gitignore b/.gitignore index 33d4929..53a29e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ coverage/ node_modules/ .DS_Store +*.d.ts *.log yarn.lock diff --git a/cli.js b/cli.js index ac19ba7..214946b 100755 --- a/cli.js +++ b/cli.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from 'node:fs' import process from 'node:process' +import {URL} from 'node:url' import notifier from 'update-notifier' import supportsColor from 'supports-color' import meow from 'meow' @@ -20,8 +21,9 @@ import retextProfanities from 'retext-profanities' import unifiedDiff from 'unified-diff' import {filter} from './filter.js' +/** @type {import('type-fest').PackageJson} */ const pack = JSON.parse( - fs.readFileSync(new URL('./package.json', import.meta.url)) + String(fs.readFileSync(new URL('./package.json', import.meta.url))) ) const textExtensions = [ @@ -38,6 +40,7 @@ const htmlExtensions = ['htm', 'html'] const mdxExtensions = ['mdx'] // Update messages. +/** @ts-expect-error: `package.json` is fine. */ notifier({pkg: pack}).notify() // Set-up meow. @@ -88,7 +91,9 @@ const extensions = cli.flags.html ? mdxExtensions : textExtensions const defaultGlobs = ['{docs/**/,doc/**/,}*.{' + extensions.join(',') + '}'] +/** @type {boolean|undefined} */ let silentlyIgnore +/** @type {string[]|undefined} */ let globs if (cli.flags.stdin) { @@ -112,7 +117,7 @@ engine( output: false, rcName: '.alexrc', packageField: 'alex', - color: supportsColor.stderr, + color: Boolean(supportsColor.stderr), reporter: cli.flags.reporter || vfileReporter, reporterOptions: { verbose: cli.flags.why @@ -121,7 +126,7 @@ engine( ignoreName: '.alexignore', silentlyIgnore, frail: true, - defaultConfig: transform() + defaultConfig: transform({}) }, function (error, code) { if (error) console.error(error.message) @@ -129,28 +134,35 @@ engine( } ) -function transform(options) { - const settings = options || {} +/** + * @type {import('unified-engine').ConfigTransform} + * @param {import('./index.js').OptionsObject} [options] + */ +function transform(options = {}) { + /** @type {import('unified').PluggableList} */ let plugins = [ retextEnglish, - [retextProfanities, {sureness: settings.profanitySureness}], - [retextEquality, {noBinary: settings.noBinary}] + [retextProfanities, {sureness: options.profanitySureness}], + [retextEquality, {noBinary: options.noBinary}] ] if (cli.flags.html) { + // @ts-expect-error: types are having a hard time for bridges. plugins = [rehypeParse, [rehypeRetext, unified().use({plugins})]] } else if (cli.flags.mdx) { + // @ts-expect-error: types are having a hard time for bridges. plugins = [remarkParse, remarkMdx, [remarkRetext, unified().use({plugins})]] } else if (!cli.flags.text) { plugins = [ remarkParse, remarkGfm, [remarkFrontmatter, ['yaml', 'toml']], + // @ts-expect-error: types are having a hard time for bridges. [remarkRetext, unified().use({plugins})] ] } - plugins.push([filter, {allow: settings.allow, deny: settings.deny}]) + plugins.push([filter, {allow: options.allow, deny: options.deny}]) // Hard to check. /* c8 ignore next 3 */ diff --git a/filter.js b/filter.js index f04be92..9671fb4 100644 --- a/filter.js +++ b/filter.js @@ -1,10 +1,25 @@ -import remarkMessageControl from 'remark-message-control' +/** + * @typedef {import('mdast').Root} Root + * + * @typedef Options + * Configuration. + * @property {string[]} [deny] + * The `deny` field should be an array of rules or `undefined` (the default is + * `undefined`). + * When provided, *only* the rules specified are reported. + * You cannot use both `allow` and `deny` at the same time. + * @property {string[]} [allow] + * The `allow` field should be an array of rules or `undefined` (the default + * is `undefined`). + * When provided, the rules specified are skipped and not reported. + * You cannot use both `allow` and `deny` at the same time. + */ -export function filter(options) { - /* c8 ignore next */ - const settings = options || {} +import remarkMessageControl from 'remark-message-control' - if (settings.allow && settings.deny) { +/** @type {import('unified').Plugin<[Options?]|[], Root>} */ +export function filter(options = {}) { + if (options.allow && options.deny) { throw new Error( 'Do not provide both allow and deny configuration parameters' ) @@ -12,9 +27,9 @@ export function filter(options) { return remarkMessageControl({ name: 'alex', - reset: Boolean(settings.deny), - enable: settings.deny, - disable: settings.allow, + reset: Boolean(options.deny), + enable: options.deny, + disable: options.allow, source: ['retext-equality', 'retext-profanities'] }) } diff --git a/index.js b/index.js index 13dad05..c04c5a0 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,17 @@ +/** + * @typedef {import('nlcst').Root} Root + * @typedef {import('./filter.js').Options} FilterOptions + * + * @typedef {boolean|undefined} NoBinaryOption + * @typedef {0|1|2|undefined} SurenessOption + * + * @typedef {{noBinary: NoBinaryOption, sureness: SurenessOption}} TextOptions + * + * @typedef {{noBinary?: NoBinaryOption, profanitySureness?: SurenessOption} & FilterOptions} OptionsObject + * @typedef {import('vfile').VFileCompatible} Input + * @typedef {OptionsObject|string[]|undefined} Options + */ + import {VFile} from 'vfile' import {unified} from 'unified' import remarkParse from 'remark-parse' @@ -13,31 +27,24 @@ import rehypeRetext from 'rehype-retext' import {sort} from 'vfile-sort' import {filter} from './filter.js' -function makeText(config) { +/** @param {TextOptions} options */ +function makeText(options) { return unified() .use(retextEnglish) - .use(retextEquality, { - noBinary: config && config.noBinary - }) - .use(retextProfanities, { - sureness: config && config.profanitySureness - }) + .use(retextEquality, options) + .use(retextProfanities, options) } -// Alex’s core. -function core(value, config, processor) { - let allow - let deny - - if (Array.isArray(config)) { - allow = config - } else if (config) { - allow = config.allow - deny = config.deny - } - +/** + * Alex’s core. + * + * @param {Input} value + * @param {FilterOptions} options + * @param {import('unified').Processor} processor + */ +function core(value, options, processor) { const file = new VFile(value) - const tree = processor.use(filter, {allow, deny}).parse(file) + const tree = processor.use(filter, options).parse(file) processor.runSync(tree, file) @@ -48,41 +55,90 @@ function core(value, config, processor) { export default markdown -// Alex. +/** + * Alex (markdown). + * + * @param {Input} value + * @param {Options} [config] + */ export function markdown(value, config) { + const options = splitOptions(config) return core( value, - config, + options.filter, unified() .use(remarkParse) .use(remarkGfm) .use(remarkFrontmatter, ['yaml', 'toml']) - .use(remarkRetext, makeText(config)) + .use(remarkRetext, makeText(options.text)) ) } -// Alex, for MDX. +/** + * Alex (MDX). + * + * @param {Input} value + * @param {Options} [config] + */ export function mdx(value, config) { + const options = splitOptions(config) return core( value, - config, + options.filter, unified() .use(remarkParse) .use(remarkMdx) - .use(remarkRetext, makeText(config)) + .use(remarkRetext, makeText(options.text)) ) } -// Alex, for HTML. +/** + * Alex (HTML). + * + * @param {Input} value + * @param {Options} [config] + */ export function html(value, config) { + const options = splitOptions(config) return core( value, - config, - unified().use(rehypeParse).use(rehypeRetext, makeText(config)) + options.filter, + unified().use(rehypeParse).use(rehypeRetext, makeText(options.text)) ) } -// Alex, without the markdown. +/** + * Alex (plain text). + * + * @param {Input} value + * @param {Options} [config] + */ export function text(value, config) { - return core(value, config, makeText(config)) + const options = splitOptions(config) + return core(value, options.filter, makeText(options.text)) +} + +/** + * @param {Options} options + */ +function splitOptions(options) { + /** @type {string[]|undefined} */ + let allow + /** @type {string[]|undefined} */ + let deny + /** @type {boolean|undefined} */ + let noBinary + /** @type {SurenessOption} */ + let sureness + + if (Array.isArray(options)) { + allow = options + } else if (options) { + allow = options.allow + deny = options.deny + noBinary = options.noBinary + sureness = options.profanitySureness + } + + return {filter: {allow, deny}, text: {noBinary, sureness}} } diff --git a/package.json b/package.json index 96f1261..f20de54 100644 --- a/package.json +++ b/package.json @@ -58,12 +58,17 @@ "type": "module", "sideEffects": false, "bin": "cli.js", + "types": "index.d.ts", "files": [ + "index.d.ts", "index.js", + "filter.d.ts", "filter.js", "cli.js" ], "dependencies": { + "@types/mdast": "^3.0.0", + "@types/nlcst": "^1.0.0", "meow": "^10.0.0", "rehype-parse": "^8.0.0", "rehype-retext": "^3.0.0", @@ -85,19 +90,26 @@ "vfile-sort": "^3.0.0" }, "devDependencies": { + "@types/tape": "^4.0.0", + "@types/update-notifier": "^5.0.0", "c8": "^7.10.0", "prettier": "^2.0.0", "remark-cli": "^10.0.0", "remark-preset-wooorm": "^9.0.0", + "rimraf": "^3.0.0", "tape": "^5.0.0", + "type-coverage": "^2.0.0", + "type-fest": "^2.0.0", + "typescript": "^4.0.0", "vfile-reporter-json": "^3.0.0", "xo": "^0.45.0" }, "scripts": { + "build": "rimraf \"test/**/*.d.ts\" \"*.d.ts\" && tsc && type-coverage", "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", "test-api": "node test/index.js", "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov npm run test-api", - "test": "npm run format && npm run test-coverage" + "test": "npm run build && npm run format && npm run test-coverage" }, "alex": { "allow": [ @@ -129,5 +141,11 @@ } ] ] + }, + "typeCoverage": { + "atLeast": 100, + "detail": true, + "strict": true, + "ignoreCatch": true } } diff --git a/test/api.js b/test/api.js index 5c13560..e3cfbb5 100644 --- a/test/api.js +++ b/test/api.js @@ -1,4 +1,5 @@ import fs from 'node:fs' +import {URL} from 'node:url' import test from 'tape' import alex, {markdown, mdx, text, html} from '../index.js' diff --git a/test/cli.js b/test/cli.js index c14f538..5650c11 100644 --- a/test/cli.js +++ b/test/cli.js @@ -4,65 +4,63 @@ import path from 'node:path' import process from 'node:process' import test from 'tape' -const pkg = JSON.parse(fs.readFileSync('package.json')) +/** @type {import('type-fest').PackageJson} */ +const pkg = JSON.parse(String(fs.readFileSync('package.json'))) test('alex-cli', function (t) { t.test('version', function (t) { t.plan(1) - childProcess.exec('./cli.js -v', onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js -v', (error, stdout, stderr) => { t.deepEqual( [error, stderr, stdout], [null, '', pkg.version + '\n'], 'should work' ) - } + }) }) t.test('help', function (t) { t.plan(1) - childProcess.exec('./cli.js -h', onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js -h', (error, stdout, stderr) => { t.deepEqual( [error, stderr, /Usage: alex \[ ...] /.test(stdout)], [null, '', true], 'should work' ) - } + }) }) t.test('stdin', function (t) { t.plan(1) - const subprocess = childProcess.exec('./cli.js --stdin', onexec) - - setTimeout(end, 10) - - function end() { - subprocess.stdin.end('His') - } - - function onexec(error, stdout, stderr) { - t.deepEqual( - [error.code, stderr, stdout], - [ - 1, + const subprocess = childProcess.exec( + './cli.js --stdin', + (error, stdout, stderr) => { + t.deepEqual( + [error && error.code, stderr, stdout], [ - '', - ' 1:1-1:4 warning `His` may be insensitive, when referring to a person, use `Their`, `Theirs`, `Them` instead her-him retext-equality', - '', - '⚠ 1 warning', + 1, + [ + '', + ' 1:1-1:4 warning `His` may be insensitive, when referring to a person, use `Their`, `Theirs`, `Them` instead her-him retext-equality', + '', + '⚠ 1 warning', + '' + ].join('\n'), '' - ].join('\n'), - '' - ], - 'should work' - ) - } + ], + 'should work' + ) + } + ) + + setTimeout(function () { + if (subprocess.stdin) { + subprocess.stdin.end('His') + } + }, 10) }) t.test('stdin and globs', function (t) { @@ -70,15 +68,17 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js --stdin ' + fp, onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js --stdin ' + fp, (error, stdout, stderr) => { t.deepEqual( - [error.code, /Do not pass globs with `--stdin`/.test(stderr), stdout], + [ + error && error.code, + /Do not pass globs with `--stdin`/.test(stderr), + stdout + ], [1, true, ''], 'should work' ) - } + }) }) t.test('markdown by default', function (t) { @@ -86,15 +86,13 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp, onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp, (error, stdout, stderr) => { t.deepEqual( [error, stderr, stdout], [null, fp + ': no issues found\n', ''], 'should work' ) - } + }) }) t.test('optionally html', function (t) { @@ -102,15 +100,13 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp + ' --html', onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp + ' --html', (error, stdout, stderr) => { t.deepEqual( - [error.code, /3 warnings/.test(stderr), stdout], + [error && error.code, /3 warnings/.test(stderr), stdout], [1, true, ''], 'should work' ) - } + }) }) t.test('optionally text (on markdown)', function (t) { @@ -118,15 +114,13 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp + ' --text', onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp + ' --text', (error, stdout, stderr) => { t.deepEqual( - [error.code, /1 warning/.test(stderr), stdout], + [error && error.code, /1 warning/.test(stderr), stdout], [1, true, ''], 'should work' ) - } + }) }) t.test('optionally text (on html)', function (t) { @@ -134,15 +128,13 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp + ' --text', onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp + ' --text', (error, stdout, stderr) => { t.deepEqual( - [error.code, /10 warnings/.test(stderr), stdout], + [error && error.code, /10 warnings/.test(stderr), stdout], [1, true, ''], 'should work' ) - } + }) }) t.test('mdx', function (t) { @@ -150,15 +142,13 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp + ' --mdx', onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp + ' --mdx', (error, stdout, stderr) => { t.deepEqual( - [error.code, /2 warnings/.test(stderr), stdout], + [error && error.code, /2 warnings/.test(stderr), stdout], [1, true, ''], 'should work' ) - } + }) }) t.test('successful', function (t) { @@ -166,15 +156,13 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp, onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp, (error, stdout, stderr) => { t.deepEqual( [error, stderr, stdout], [null, fp + ': no issues found\n', ''], 'should work' ) - } + }) }) t.test('quiet (ok)', function (t) { @@ -182,11 +170,9 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp + ' -q', onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp + ' -q', (error, stdout, stderr) => { t.deepEqual([error, stderr, stdout], [null, '', ''], 'should work') - } + }) }) t.test('quiet (on error)', function (t) { @@ -194,25 +180,26 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp + ' -q --text', onexec) - - function onexec(error, stdout, stderr) { - t.deepEqual( - [error.code, stderr, stdout], - [ - 1, + childProcess.exec( + './cli.js ' + fp + ' -q --text', + (error, stdout, stderr) => { + t.deepEqual( + [error && error.code, stderr, stdout], [ - fp, - ' 1:18-1:21 warning `his` may be insensitive, when referring to a person, use `their`, `theirs`, `them` instead her-him retext-equality', - '', - '⚠ 1 warning', + 1, + [ + fp, + ' 1:18-1:21 warning `his` may be insensitive, when referring to a person, use `their`, `theirs`, `them` instead her-him retext-equality', + '', + '⚠ 1 warning', + '' + ].join('\n'), '' - ].join('\n'), - '' - ], - 'should work' - ) - } + ], + 'should work' + ) + } + ) }) t.test('binary (default: ok)', function (t) { @@ -220,15 +207,13 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp, onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp, (error, stdout, stderr) => { t.deepEqual( [error, stderr, stdout], [null, fp + ': no issues found\n', ''], 'should work' ) - } + }) }) t.test('binary (with config file)', function (t) { @@ -236,11 +221,9 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp, onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp, (error, stdout, stderr) => { t.deepEqual( - [error.code, stderr, stdout], + [error && error.code, stderr, stdout], [ 1, [ @@ -255,7 +238,7 @@ test('alex-cli', function (t) { ], 'should work' ) - } + }) }) t.test('profanity (default)', function (t) { @@ -263,11 +246,9 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp, onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp, (error, stdout, stderr) => { t.deepEqual( - [error.code, stderr, stdout], + [error && error.code, stderr, stdout], [ 1, [ @@ -281,7 +262,7 @@ test('alex-cli', function (t) { ], 'should work' ) - } + }) }) t.test('profanity (with config file)', function (t) { @@ -289,15 +270,13 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp, onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp, (error, stdout, stderr) => { t.deepEqual( [error, stderr, stdout], [null, fp + ': no issues found\n', ''], 'should work' ) - } + }) }) t.test('custom reporter', function (t) { @@ -305,7 +284,16 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js --reporter json ' + fp, onexec) + childProcess.exec( + './cli.js --reporter json ' + fp, + (error, stdout, stderr) => { + t.deepEqual( + [error, stdout, stderr], + [null, '', expectedJson + '\n'], + 'should work' + ) + } + ) const expectedJson = JSON.stringify([ { @@ -315,14 +303,6 @@ test('alex-cli', function (t) { messages: [] } ]) - - function onexec(error, stdout, stderr) { - t.deepEqual( - [error, stdout, stderr], - [null, '', expectedJson + '\n'], - 'should work' - ) - } }) t.test("custom formatter that isn't installed", function (t) { @@ -330,15 +310,16 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js --reporter doesntexist ' + fp, onexec) - - function onexec(error, stdout, stderr) { - t.deepEqual( - [error, stderr, stdout], - [null, 'Could not find reporter `doesntexist`\n', ''], - 'should work' - ) - } + childProcess.exec( + './cli.js --reporter doesntexist ' + fp, + (error, stdout, stderr) => { + t.deepEqual( + [error, stderr, stdout], + [null, 'Could not find reporter `doesntexist`\n', ''], + 'should work' + ) + } + ) }) t.test('deny', function (t) { @@ -346,11 +327,9 @@ test('alex-cli', function (t) { t.plan(1) - childProcess.exec('./cli.js ' + fp, onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js ' + fp, (error, stdout, stderr) => { t.deepEqual( - [error.code, stderr, stdout], + [error && error.code, stderr, stdout], [ 1, [ @@ -364,21 +343,19 @@ test('alex-cli', function (t) { ], 'should work' ) - } + }) }) t.test('default globs', function (t) { t.plan(1) - childProcess.exec('./cli.js', onexec) - - function onexec(error, stdout, stderr) { + childProcess.exec('./cli.js', (error, stdout, stderr) => { t.deepEqual( [error, stderr, stdout], [null, 'readme.md: no issues found\n', ''], 'should work' ) - } + }) }) t.end() diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a93b9f9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "include": ["test/**/*.js", "*.js"], + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ES2020", + "moduleResolution": "node", + "allowJs": true, + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "strict": true + } +}