Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add postprocess config file option (useful for applying prettier formatting) #285

Merged
merged 14 commits into from
Nov 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Expand Up @@ -158,6 +158,8 @@ There are a few ways to create a config file:

Config files support all the [CLI options](#configuration-options) but in camelCase. CLI options take precedence over config file options.

Using a JavaScript-based config also allows you to provide a `postprocess` function which gets called with the generated content and path to each file as they're processed, to make it easy to apply any custom transformations such as formatting - this is useful if you are using formatting tools such as [`prettier`](#prettier).

Example `.eslint-doc-generatorrc.js`:

```js
Expand All @@ -182,7 +184,22 @@ If you have a build step for your code like [Babel](https://babeljs.io/) or [Typ

### prettier

If you use [prettier](https://prettier.io/) to format your markdown, you may need to adjust your scripts to run prettier formatting after running this tool:
If you use [prettier](https://prettier.io/) to format your markdown, you can provide a `postprocess` function to ensure the documentation generated by this tool is formatted correctly:

```javascript
const prettier = require('prettier');
const { prettier: prettierRC } = require('./package.json'); // or wherever your prettier config lies

/** @type {import('eslint-doc-generator').GenerateOptions} */
const config = {
postprocess: content =>
prettier.format(content, { ...prettierRC, parser: 'markdown' }),
};

module.exports = config;
```

Alternatively, you can configure your scripts to run `prettier` after this tool:

```json
{
Expand Down
8 changes: 8 additions & 0 deletions lib/cli.ts
Expand Up @@ -47,6 +47,7 @@ async function loadConfigFileOptions(): Promise<GenerateOptions> {
initRuleDocs: { type: 'boolean' },
pathRuleDoc: { type: 'string' },
pathRuleList: { anyOf: [{ type: 'string' }, schemaStringArray] },
postprocess: {},
ruleDocNotices: { type: 'string' },
ruleDocSectionExclude: schemaStringArray,
ruleDocSectionInclude: schemaStringArray,
Expand Down Expand Up @@ -75,6 +76,13 @@ async function loadConfigFileOptions(): Promise<GenerateOptions> {
);
}

if (
explorerResults.config.postprocess &&
typeof explorerResults.config.postprocess !== 'function'
) {
throw new Error('postprocess must be a function');
}

return explorerResults.config;
}
return {};
Expand Down
44 changes: 24 additions & 20 deletions lib/generator.ts
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { dirname, join, relative, resolve } from 'node:path';
import { getAllNamedOptions, hasOptions } from './rule-options.js';
import {
loadPlugin,
Expand Down Expand Up @@ -140,6 +140,8 @@ export async function generate(path: string, options?: GenerateOptions) {
options?.urlConfigs ?? OPTION_DEFAULTS[OPTION_TYPE.URL_CONFIGS];
const urlRuleDoc =
options?.urlRuleDoc ?? OPTION_DEFAULTS[OPTION_TYPE.URL_RULE_DOC];
const postprocess =
options?.postprocess ?? OPTION_DEFAULTS[OPTION_TYPE.POSTPROCESS];

// Gather details about rules.
const details: RuleDetails[] = Object.entries(plugin.rules)
Expand Down Expand Up @@ -211,10 +213,9 @@ export async function generate(path: string, options?: GenerateOptions) {
);

const contents = readFileSync(pathToDoc).toString();
const contentsNew = replaceOrCreateHeader(
contents,
newHeaderLines,
END_RULE_HEADER_MARKER
const contentsNew = await postprocess(
replaceOrCreateHeader(contents, newHeaderLines, END_RULE_HEADER_MARKER),
resolve(pathToDoc)
);

if (check) {
Expand Down Expand Up @@ -277,21 +278,24 @@ export async function generate(path: string, options?: GenerateOptions) {

// Update the rules list in this file.
const fileContents = readFileSync(pathToFile, 'utf8');
const fileContentsNew = updateRulesList(
details,
fileContents,
plugin,
configsToRules,
pluginPrefix,
pathRuleDoc,
pathToFile,
path,
configEmojis,
ignoreConfig,
ruleListColumns,
urlConfigs,
urlRuleDoc,
splitBy
const fileContentsNew = await postprocess(
updateRulesList(
details,
fileContents,
plugin,
configsToRules,
pluginPrefix,
pathRuleDoc,
pathToFile,
path,
configEmojis,
ignoreConfig,
ruleListColumns,
urlConfigs,
urlRuleDoc,
splitBy
),
resolve(pathToFile)
);

if (check) {
Expand Down
1 change: 1 addition & 0 deletions lib/options.ts
Expand Up @@ -66,4 +66,5 @@ export const OPTION_DEFAULTS = {
[OPTION_TYPE.SPLIT_BY]: undefined,
[OPTION_TYPE.URL_CONFIGS]: undefined,
[OPTION_TYPE.URL_RULE_DOC]: undefined,
[OPTION_TYPE.POSTPROCESS]: (content: string) => content,
} satisfies Record<OPTION_TYPE, unknown>; // Satisfies is used to ensure all options are included, but without losing type information.
5 changes: 5 additions & 0 deletions lib/types.ts
Expand Up @@ -100,6 +100,7 @@ export enum OPTION_TYPE {
SPLIT_BY = 'splitBy',
URL_CONFIGS = 'urlConfigs',
URL_RULE_DOC = 'urlRuleDoc',
POSTPROCESS = 'postprocess',
}

/** The type for the config file and internal generate() function. */
Expand All @@ -111,6 +112,10 @@ export type GenerateOptions = {
initRuleDocs?: boolean;
pathRuleDoc?: string;
pathRuleList?: string | string[];
postprocess?: (
content: string,
pathToFile: string
) => string | Promise<string>;
ruleDocNotices?: string;
ruleDocSectionExclude?: string[];
ruleDocSectionInclude?: string[];
Expand Down
44 changes: 37 additions & 7 deletions test/lib/cli-test.ts
Expand Up @@ -14,6 +14,7 @@ const configFileOptionsAll: { [key in OPTION_TYPE]: unknown } = {
initRuleDocs: true,
pathRuleDoc: 'www.example.com/rule-doc-from-config-file',
pathRuleList: 'www.example.com/rule-list-from-config-file',
postprocess: (content: string) => content,
ruleDocNotices: 'type',
ruleDocSectionExclude: [
'excludedSectionFromConfigFile1',
Expand Down Expand Up @@ -93,6 +94,7 @@ const cliOptionsAll: { [key in OPTION_TYPE]: string[] } = {
'--url-rule-doc',
'https://example.com/rule-doc-url-from-cli',
],
[OPTION_TYPE.POSTPROCESS]: [],
};

describe('cli', function () {
Expand Down Expand Up @@ -316,7 +318,11 @@ describe('cli', function () {
});

describe('invalid config file', function () {
beforeEach(function () {
afterEach(function () {
mockFs.restore();
});

it('throws an error', async function () {
mockFs({
'package.json': JSON.stringify({
name: 'eslint-plugin-test',
Expand All @@ -325,15 +331,39 @@ describe('cli', function () {
version: '1.0.0',
}),

'.eslint-doc-generatorrc.json': '{ "unknown": true }', // Doesn't match schema.
'.eslint-doc-generatorrc.json': JSON.stringify({
// Doesn't match schema.
unknown: true,
}),
});
});

afterEach(function () {
mockFs.restore();
const stub = sinon.stub().resolves();
await expect(
run(
[
'node', // Path to node.
'eslint-doc-generator.js', // Path to this binary.
],
stub
)
).rejects.toThrow('config file must NOT have additional properties');
});

it('throws an error', async function () {
it('requires that postprocess be a function', async function () {
mockFs({
'package.json': JSON.stringify({
name: 'eslint-plugin-test',
main: 'index.js',
type: 'module',
version: '1.0.0',
}),

'.eslint-doc-generatorrc.json': JSON.stringify({
// Doesn't match schema.
postprocess: './my-file.js',
}),
});

const stub = sinon.stub().resolves();
await expect(
run(
Expand All @@ -343,7 +373,7 @@ describe('cli', function () {
],
stub
)
).rejects.toThrow('config file must NOT have additional properties');
).rejects.toThrow('postprocess must be a function');
});
});
});
24 changes: 24 additions & 0 deletions test/lib/generate/__snapshots__/option-postprocess-test.ts.snap
@@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`generate (postprocess option) basic calls the postprocessor 1`] = `
"## Rules
<!-- begin auto-generated rules list -->

| Name |
| :----------------------------- |
| [no-foo](docs/rules/no-foo.md) |

<!-- end auto-generated rules list -->


Located at README.md"
`;

exports[`generate (postprocess option) basic calls the postprocessor 2`] = `
"# test/no-foo

<!-- end auto-generated rule header -->


Located at docs/rules/no-foo.md"
`;
59 changes: 59 additions & 0 deletions test/lib/generate/option-postprocess-test.ts
@@ -0,0 +1,59 @@
import { generate } from '../../../lib/generator.js';
import mockFs from 'mock-fs';
import { dirname, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { jest } from '@jest/globals';

const __dirname = dirname(fileURLToPath(import.meta.url));

const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules');

describe('generate (postprocess option)', function () {
describe('basic', function () {
beforeEach(function () {
mockFs({
'package.json': JSON.stringify({
name: 'eslint-plugin-test',
exports: 'index.js',
type: 'module',
}),

'index.js': `
export default {
rules: {
'no-foo': {
meta: {},
create(context) {}
},
},
};`,

'README.md': '## Rules\n',

'docs/rules/no-foo.md': '',

// Needed for some of the test infrastructure to work.
node_modules: mockFs.load(PATH_NODE_MODULES),
});
});

afterEach(function () {
mockFs.restore();
jest.resetModules();
});

it('calls the postprocessor', async function () {
await generate('.', {
postprocess: (content, path) =>
[
content,
'',
`Located at ${relative('.', path).replace(/\\/gu, '/')}`, // Always use forward slashes in the path so the snapshot is right even when testing on Windows.
].join('\n'),
});
expect(readFileSync('README.md', 'utf8')).toMatchSnapshot();
expect(readFileSync('docs/rules/no-foo.md', 'utf8')).toMatchSnapshot();
});
});
});