diff --git a/README.md b/README.md index 3ddc288..8d59eb5 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,20 @@ This preset configures [`remark-lint`][lint] with the following rules: * [`file-extension`](https://github.com/RichardLitt/standard-readme-preset/blob/master/rules/file-extension.js) — Check that `md` is used as a file extension -* [`file-stem`](https://github.com/RichardLitt/standard-readme-preset/blob/master/rules/file-stem.js) - — Check that `README` is used as a file stem (allows i18n: `README.de`, `README.en-GB`) -* [`require-file-extension`](https://github.com/RichardLitt/standard-readme-preset/blob/master/rules/require-file-extension.js) - — Check that a file extension is used +* [`file-stem`](https://github.com/RichardLitt/standard-readme-preset/blob/master/rules/file-stem.js) + — Check that `README` is used as a file stem (allows i18n: `README.de`, `README.en-GB`) +* [`require-file-extension`](https://github.com/RichardLitt/standard-readme-preset/blob/master/rules/require-file-extension.js) + — Check that a file extension is used +* [`no-unknown-sections`](https://github.com/RichardLitt/standard-readme-preset/blob/master/rules/no-unknown-sections.js) + — Check that only known sections are used, except for in the extra sections +* [`require-sections`](https://github.com/RichardLitt/standard-readme-preset/blob/master/rules/require-sections.js) + — Check that required sections (`contribute`, `license`) exist. + `table-of-contents` is required if `toc: true` is given, optional for + `toc: false`, and otherwise inferred based on if the number of lines in the + file, excluding the ToC itself, exceeds 100. + `install` and `usage` are required if `installable: true` is given. +* [`section-order`](https://github.com/RichardLitt/standard-readme-preset/blob/master/rules/section-order.js) + — Check that sections are used in the order they’re supposed to ## Contribute diff --git a/index.js b/index.js index 11e08b4..6209793 100644 --- a/index.js +++ b/index.js @@ -2,5 +2,8 @@ exports.plugins = [ require('remark-lint-appropriate-heading'), require('./rules/file-stem'), require('./rules/file-extension'), - require('./rules/require-file-extension') + require('./rules/require-file-extension'), + require('./rules/no-unknown-sections'), + require('./rules/require-sections'), + require('./rules/section-order') ] diff --git a/package.json b/package.json index f6602d1..ca4491b 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,20 @@ ], "files": [ "index.js", - "rules" + "rules", + "util" ], "coordinates": [ 52.5006656, 13.4193688 ], "dependencies": { + "github-slugger": "^1.2.0", + "mdast-util-to-string": "^1.0.4", "remark-lint-appropriate-heading": "^2.0.2", - "unified-lint-rule": "^1.0.2" + "unified-lint-rule": "^1.0.2", + "unist-util-position": "^3.0.0", + "unist-util-stringify-position": "^1.1.1" }, "devDependencies": { "nyc": "^11.3.0", diff --git a/rules/no-unknown-sections.js b/rules/no-unknown-sections.js new file mode 100644 index 0000000..f920778 --- /dev/null +++ b/rules/no-unknown-sections.js @@ -0,0 +1,74 @@ +var rule = require('unified-lint-rule') +var position = require('unist-util-position') +var stringify = require('unist-util-stringify-position') +var schema = require('../util/schema') + +module.exports = rule('standard-readme:no-unknown-sections', noUnknownSections) + +var head = [ + 'table-of-contents', + 'security', + 'background', + 'install', + 'usage' +] + +var tail = [ + 'api', + 'maintainer', + 'maintainers', + 'contribute', + 'license' +] + +function noUnknownSections (ast, file) { + var sections = schema(ast) + var length = sections.length + var index = -1 + var state = 'head' + var customPosition + var tailStart + var inHead + var inTail + var section + + while (++index < length) { + section = sections[index] + inHead = head.indexOf(section.id) !== -1 + inTail = tail.indexOf(section.id) !== -1 + + if (state === 'head') { + if (!inHead) { + if (inTail) { + state = 'tail' + tailStart = position.start(section.node) + } else { + state = 'custom' + customPosition = {start: position.start(section.node)} + } + } + } else if (state === 'tail') { + if (!inTail) { + if (customPosition) { + file.message('Unexpected unknown heading in tail: move it to the extra sections (' + stringify(customPosition) + ')', section.node) + } else { + file.message('Unexpected unknown section in tail: move it in front of the tail (' + stringify(tailStart) + ')', section.node) + } + } + } else { + customPosition.end = { + line: position.end(section.node).line - 1, + column: 1 + } + + if (inTail) { + state = 'tail' + tailStart = position.start(section.node) + } else if (inHead) { + /* We had a custom section, and now there’s an entry that should’ve + * been in the head. Suggest switching them around. */ + file.message('Unexpected header section after extra sections (' + stringify(customPosition) + '): move it in front of the extra sections', section.node) + } + } + } +} diff --git a/rules/require-sections.js b/rules/require-sections.js new file mode 100644 index 0000000..de6d55d --- /dev/null +++ b/rules/require-sections.js @@ -0,0 +1,68 @@ +var rule = require('unified-lint-rule') +var position = require('unist-util-position') +var schema = require('../util/schema') + +module.exports = rule('standard-readme:require-sections', requireSections) + +/* Max allowed file-size without a table of contents. */ +var maxLinesWithoutTableOfContents = 100 + +/* Pretty heading names people *should* use. */ +var pretty = { + 'table-of-contents': 'Table of Contents', + install: 'Install', + usage: 'Usage', + contribute: 'Contribute', + license: 'License' +} + +var alwaysRequired = [ + 'contribute', + 'license' +] + +function requireSections (ast, file, options) { + var sections = schema(ast) + var required = alwaysRequired.concat() + var settings = options || {} + + if (settings.toc === null || settings.toc === undefined ? inferToc(ast, sections) : settings.toc) { + required.push('table-of-contents') + } + + if (settings.installable) { + required.push('install', 'usage') + } + + sections = sections.map(id) + required.forEach(check) + + function check (slug) { + if (sections.indexOf(slug) === -1) { + file.message('Missing required `' + pretty[slug] + '` section') + } + } +} + +function inferToc (tree, sections) { + var lines = position.end(tree).line + var length = sections.length + var index = -1 + var section + var end + + while (++index < length) { + section = sections[index] + + if (section.id === 'table-of-contents') { + end = index === length - 1 ? section.node : sections[index + 1].node + lines -= position.end(end).line - position.start(section.node).line + } + } + + return lines > maxLinesWithoutTableOfContents +} + +function id (info) { + return info.id +} diff --git a/rules/section-order.js b/rules/section-order.js new file mode 100644 index 0000000..08b37fa --- /dev/null +++ b/rules/section-order.js @@ -0,0 +1,70 @@ +var rule = require('unified-lint-rule') +// var position = require('unist-util-position') +var stringify = require('unist-util-stringify-position') +var schema = require('../util/schema') + +module.exports = rule('standard-readme:section-order', sectionOrder) + +var order = [ + 'table-of-contents', + 'security', + 'background', + 'install', + 'usage', + 'api', + /* Note: maintainer and maintainers don’t matter in this case... */ + 'maintainer', + 'maintainers', + 'contribute', + 'license' +] + +function sectionOrder (ast, file) { + var sections = schema(ast) + var byId = {} + var length = sections.length + var index = -1 + var actual = [] + var expected + var section + var id + var alt + var idx + + /* First, get all sections usd in the document. */ + while (++index < length) { + section = sections[index] + id = section.id + + if (order.indexOf(id) !== -1) { + actual.push(id) + byId[id] = section.node + } + } + + /* Find the expected order for the used headings. */ + expected = actual.concat().sort(sort) + + /* Check if that’s used! */ + length = expected.length + index = -1 + + while (++index < length) { + id = expected[index] + alt = actual[index] + + if (id !== alt) { + file.message('Expected `' + id + '` before `' + alt + '` (' + stringify(byId[alt]) + ')', byId[id]) + + /* Apply the change. */ + idx = actual.indexOf(id) + + actual.splice(index, 0, id) /* Insert. */ + actual.splice(idx + 1, 1) /* Remove. */ + } + } +} + +function sort (a, b) { + return order.indexOf(a) - order.indexOf(b) +} diff --git a/test.js b/test.js index 9926b01..8b6a90a 100644 --- a/test.js +++ b/test.js @@ -5,6 +5,9 @@ var lint = require('remark-lint') var fileStem = require('./rules/file-stem') var fileExtension = require('./rules/file-extension') var requireFileExtension = require('./rules/require-file-extension') +var noUnknownSections = require('./rules/no-unknown-sections') +var requireSections = require('./rules/require-sections') +var sectionOrder = require('./rules/section-order') test('standard-readme', function (t) { t.test('file-stem', function (st) { @@ -121,5 +124,554 @@ test('standard-readme', function (t) { st.end() }) + t.test('no-unknown-sections', function (st) { + var processor = remark().use(lint).use(noUnknownSections) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Install', + '', + '```sh', + 'npm install something', + '```', + '', + '## Usage', + '', + '```js', + 'some.thing()', + '```', + '', + '### CLI', + '', + '```sh', + 'some --thing', + '```', + '', + '## Custom', + '', + 'A custom heading.', + '', + '## Another Custom', + '', + 'A custom heading.', + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + [], + 'ok for only expected sections and extra sections in the right place' + ) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Install', + '', + '```sh', + 'npm install something', + '```', + '', + '## Custom', + '', + 'An wrongly placed heading.', + '', + '## Usage', + '', + '```js', + 'some.thing()', + '```', + '', + '### CLI', + '', + '```sh', + 'some --thing', + '```', + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + [ + '~/README.md:15:1-15:9: Unexpected header section after extra sections (11:1-14:1): move it in front of the extra sections' + ], + 'not ok for header sections in the extra section' + ) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Install', + '', + '```sh', + 'npm install something', + '```', + '', + '## Usage', + '', + '```js', + 'some.thing()', + '```', + '', + '### CLI', + '', + '```sh', + 'some --thing', + '```', + '', + '## Custom', + '', + 'An wrongly placed heading.', + '', + '## Contribute', + '', + 'Something something.', + '', + '## Another custom', + '', + 'An wrongly placed heading.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + [ + '~/README.md:31:1-31:18: Unexpected unknown heading in tail: move it to the extra sections (23:1-26:1)' + ], + 'not ok for unknown sections in the tail sections, with extra sections' + ) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Install', + '', + '```sh', + 'npm install something', + '```', + '', + '## Usage', + '', + '```js', + 'some.thing()', + '```', + '', + '### CLI', + '', + '```sh', + 'some --thing', + '```', + '', + '## Contribute', + '', + 'Something something.', + '', + '## Custom', + '', + 'An wrongly placed heading.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + [ + '~/README.md:27:1-27:10: Unexpected unknown section in tail: move it in front of the tail (23:1)' + ], + 'not ok for unknown sections in the tail sections, without extra sections' + ) + + st.end() + }) + + t.test('require-sections', function (st) { + var processor = remark().use(lint).use(requireSections) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + [], + 'ok for required sections: `contribute` and `license`' + ) + + st.deepEqual( + remark() + .use(lint) + .use(requireSections, {toc: true}) + .processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + ['~/README.md:1:1: Missing required `Table of Contents` section'], + 'not ok for a missing `table-of-content` section with `toc: true`' + ) + + st.deepEqual( + remark() + .use(lint) + .use(requireSections) + .processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One', + '', + '## Table of Contents' + ].join('\n') + })).messages.map(String), + [], + 'not ok for an optional `table-of-content` as the last section (coverage)' + ) + + st.deepEqual( + remark() + .use(lint) + .use(requireSections) + .processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + new Array(89).map(function (d, i) { + return String.fromCharCode(34 /* '/' */ + i) + }).join('\n'), + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + ['~/README.md:1:1: Missing required `Table of Contents` section'], + 'not ok for a missing `table-of-content` section by default, if 101 lines long' + ) + + st.deepEqual( + remark() + .use(lint) + .use(requireSections) + .processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + new Array(88).map(function (d, i) { + return String.fromCharCode(34 /* '/' */ + i) + }).join('\n'), + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + [], + 'ok for a missing `table-of-content` section by default, if 100 lines long' + ) + + st.deepEqual( + remark() + .use(lint) + .use(requireSections) + .processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Table of Contents', + '', + new Array(100).map(function (d, i) { + return String.fromCharCode(34 /* '/' */ + i) + }).join('\n'), + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + [], + 'the lines used for the table of contents itself are ignored' + ) + + st.deepEqual( + remark() + .use(lint) + .use(requireSections, {installable: true}) + .processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Usage', + '', + 'Something something.', + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + ['~/README.md:1:1: Missing required `Install` section'], + 'not ok for a missing `install` section with `installable: true`' + ) + + st.deepEqual( + remark() + .use(lint) + .use(requireSections, {installable: true}) + .processSync(vfile({ + path: '~/README.md', + contents: [ + '# Title', + '', + '> Example of an OK readme.', + '', + '## Install', + '', + 'Something something.', + '', + '## Contribute', + '', + 'Something something.', + '', + '## License', + '', + 'SPDX © Some One' + ].join('\n') + })).messages.map(String), + ['~/README.md:1:1: Missing required `Usage` section'], + 'not ok for a missing `usage` section with `installable: true`' + ) + + st.end() + }) + + t.test('section-order', function (st) { + var processor = remark().use(lint).use(sectionOrder) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '## Table of Contents', + '## Security', + '## Background', + '## Install', + '## Usage', + '## API', + '## Maintainers', + '## Contribute', + '## License' + ].join('\n\n') + })).messages.map(String), + [], + 'ok for properly ordered sections' + ) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '## Table of Contents', + '## Alpha', + '## Usage', + '## Bravo', + '## Install', + '## Charlie', + '## Contribute', + '## Delta', + '## License' + ].join('\n\n') + })).messages.map(String), + ['~/README.md:9:1-9:11: Expected `install` before `usage` (5:1-5:9)'], + 'should ignore custom extra sections' + ) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '## Table of Contents', + '## Security', + '## Background', + '## Usage', + '## Install', + '## API', + '## Maintainers', + '## Contribute', + '## License' + ].join('\n\n') + })).messages.map(String), + ['~/README.md:9:1-9:11: Expected `install` before `usage` (7:1-7:9)'], + 'not ok for one swapped sections' + ) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '## License', + '## Table of Contents', + '## Security', + '## Background', + '## Install', + '## Usage', + '## API', + '## Maintainers', + '## Contribute' + ].join('\n\n') + })).messages.map(String), + [ + '~/README.md:3:1-3:21: Expected `table-of-contents` before `license` (1:1-1:11)', + '~/README.md:5:1-5:12: Expected `security` before `license` (1:1-1:11)', + '~/README.md:7:1-7:14: Expected `background` before `license` (1:1-1:11)', + '~/README.md:9:1-9:11: Expected `install` before `license` (1:1-1:11)', + '~/README.md:11:1-11:9: Expected `usage` before `license` (1:1-1:11)', + '~/README.md:13:1-13:7: Expected `api` before `license` (1:1-1:11)', + '~/README.md:15:1-15:15: Expected `maintainers` before `license` (1:1-1:11)', + '~/README.md:17:1-17:14: Expected `contribute` before `license` (1:1-1:11)' + ], + 'not ok for a section moved entirely upwards' + ) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '## Security', + '## Background', + '## Install', + '## Usage', + '## API', + '## Maintainers', + '## Contribute', + '## License', + '## Table of Contents' + ].join('\n\n') + })).messages.map(String), + ['~/README.md:17:1-17:21: Expected `table-of-contents` before `security` (1:1-1:12)'], + 'not ok for a section moved entirely downwards' + ) + + st.deepEqual( + processor.processSync(vfile({ + path: '~/README.md', + contents: [ + '## License', + '## Contribute', + '## Maintainers', + '## API', + '## Usage', + '## Install', + '## Background', + '## Security', + '## Table of Contents' + ].join('\n\n') + })).messages.map(String), + [ + '~/README.md:17:1-17:21: Expected `table-of-contents` before `license` (1:1-1:11)', + '~/README.md:15:1-15:12: Expected `security` before `license` (1:1-1:11)', + '~/README.md:13:1-13:14: Expected `background` before `license` (1:1-1:11)', + '~/README.md:11:1-11:11: Expected `install` before `license` (1:1-1:11)', + '~/README.md:9:1-9:9: Expected `usage` before `license` (1:1-1:11)', + '~/README.md:7:1-7:7: Expected `api` before `license` (1:1-1:11)', + '~/README.md:5:1-5:15: Expected `maintainers` before `license` (1:1-1:11)', + '~/README.md:3:1-3:14: Expected `contribute` before `license` (1:1-1:11)' + ], + 'not ok for all sections inverted' + ) + + st.end() + }) + t.end() }) diff --git a/util/schema.js b/util/schema.js new file mode 100644 index 0000000..025882a --- /dev/null +++ b/util/schema.js @@ -0,0 +1,28 @@ +var toString = require('mdast-util-to-string') +var slugs = require('github-slugger')() + +module.exports = schema + +function schema (tree) { + var map = [] + var children = tree.children + var length = children.length + var index = -1 + var node + + slugs.reset() + + /* Get all headings of level 2 and use slugs as IDs for each heading. */ + while (++index < length) { + node = children[index] + + if (node.type === 'heading' && node.depth === 2) { + map.push({ + id: slugs.slug(toString(node)), + node: node + }) + } + } + + return map +}