Skip to content

Commit

Permalink
Add postprocess config file option (useful for applying prettier fo…
Browse files Browse the repository at this point in the history
…rmatting) (#285)
  • Loading branch information
G-Rath committed Nov 27, 2022
1 parent 0fee612 commit 150fdb9
Show file tree
Hide file tree
Showing 8 changed files with 176 additions and 28 deletions.
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();
});
});
});

0 comments on commit 150fdb9

Please sign in to comment.