Skip to content

Commit 4e57a37

Browse files
authored
Support a user-defined function for --url-rule-doc option (#369)
1 parent 57b8e22 commit 4e57a37

File tree

8 files changed

+306
-61
lines changed

8 files changed

+306
-61
lines changed

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ There's also a `postprocess` option that's only available via a [config file](#c
146146
| `--rule-list-columns` | Ordered, comma-separated list of columns to display in rule list. Empty columns will be hidden. See choices in below [table](#column-and-notice-types). Default: `name,description,configsError,configsWarn,configsOff,fixable,hasSuggestions,requiresTypeChecking,deprecated`. |
147147
| `--rule-list-split` | Rule property(s) to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`. A function can also be provided for this option via a [config file](#configuration-file). |
148148
| `--url-configs` | Link to documentation about the ESLint configurations exported by the plugin. |
149-
| `--url-rule-doc` | Link to documentation for each rule. Useful when it differs from the rule doc path on disk (e.g. custom documentation site in use). Use `{name}` placeholder for the rule name. |
149+
| `--url-rule-doc` | Link to documentation for each rule. Useful when it differs from the rule doc path on disk (e.g. custom documentation site in use). Use `{name}` placeholder for the rule name. A function can also be provided for this option via a [config file](#configuration-file). |
150150

151151
### Column and notice types
152152

@@ -241,6 +241,22 @@ const config = {
241241
module.exports = config;
242242
```
243243

244+
Example `.eslint-doc-generatorrc.js` with `urlRuleDoc` function:
245+
246+
```js
247+
/** @type {import('eslint-doc-generator').GenerateOptions} */
248+
const config = {
249+
urlRuleDoc(name, page) {
250+
if (page === 'README.md') {
251+
// Use URLs only in the readme.
252+
return `https://example.com/rules/${name}.html`;
253+
}
254+
},
255+
};
256+
257+
module.exports = config;
258+
```
259+
244260
### Badges
245261

246262
While config emojis are the recommended representations of configs that a rule belongs to (see [`--config-emoji`](#configuration-options)), you can alternatively define badges for configs at the bottom of your `README.md`.

lib/cli.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,14 @@ async function loadConfigFileOptions(): Promise<GenerateOptions> {
116116
}
117117
: { anyOf: [{ type: 'string' }, schemaStringArray] },
118118
urlConfigs: { type: 'string' },
119-
urlRuleDoc: { type: 'string' },
119+
urlRuleDoc:
120+
/* istanbul ignore next -- TODO: haven't tested JavaScript config files yet https://github.com/bmish/eslint-doc-generator/issues/366 */
121+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
122+
typeof explorerResults.config.urlRuleDoc === 'function'
123+
? {
124+
/* Functions are allowed but JSON Schema can't validate them so no-op in this case. */
125+
}
126+
: { type: 'string' },
120127
};
121128
const schema = {
122129
type: 'object',
@@ -290,7 +297,7 @@ export async function run(
290297
)
291298
.option(
292299
'--url-rule-doc <url>',
293-
'(optional) Link to documentation for each rule. Useful when it differs from the rule doc path on disk (e.g. custom documentation site in use). Use `{name}` placeholder for the rule name.'
300+
'(optional) Link to documentation for each rule. Useful when it differs from the rule doc path on disk (e.g. custom documentation site in use). Use `{name}` placeholder for the rule name. To specify a function, use a JavaScript-based config file.'
294301
)
295302
.action(async function (path: string, options: GenerateOptions) {
296303
// Load config file options and merge with CLI options.

lib/rule-doc-notices.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import {
1515
ConfigEmojis,
1616
SEVERITY_TYPE,
1717
NOTICE_TYPE,
18-
RULE_SOURCE,
18+
UrlRuleDocFunction,
1919
} from './types.js';
2020
import { RULE_TYPE, RULE_TYPE_MESSAGES_NOTICES } from './rule-type.js';
2121
import { RuleDocTitleFormat } from './rule-doc-title-format.js';
2222
import { hasOptions } from './rule-options.js';
23-
import { getLinkToRule, getUrlToRule } from './rule-link.js';
23+
import { getLinkToRule, replaceRulePlaceholder } from './rule-link.js';
2424
import {
2525
toSentenceCase,
2626
removeTrailingPeriod,
@@ -102,7 +102,7 @@ const RULE_NOTICES: {
102102
pathPlugin: string;
103103
pathRuleDoc: string;
104104
type?: `${RULE_TYPE}`;
105-
urlRuleDoc?: string;
105+
urlRuleDoc?: string | UrlRuleDocFunction;
106106
}) => string);
107107
} = {
108108
// Configs notice varies based on whether the rule is configured in one or more configs.
@@ -189,27 +189,14 @@ const RULE_NOTICES: {
189189
ruleName,
190190
urlRuleDoc,
191191
}) => {
192-
const urlCurrentPage = getUrlToRule(
193-
ruleName,
194-
RULE_SOURCE.self,
195-
pluginPrefix,
196-
pathPlugin,
197-
pathRuleDoc,
198-
pathPlugin,
199-
urlRuleDoc
200-
);
201-
/* istanbul ignore next -- this shouldn't happen */
202-
if (!urlCurrentPage) {
203-
throw new Error('Missing URL to our own rule');
204-
}
205192
const replacementRuleList = (replacedBy ?? []).map((replacementRuleName) =>
206193
getLinkToRule(
207194
replacementRuleName,
208195
plugin,
209196
pluginPrefix,
210197
pathPlugin,
211198
pathRuleDoc,
212-
urlCurrentPage,
199+
replaceRulePlaceholder(pathRuleDoc, ruleName),
213200
true,
214201
true,
215202
urlRuleDoc
@@ -318,7 +305,7 @@ function getRuleNoticeLines(
318305
ignoreConfig: readonly string[],
319306
ruleDocNotices: readonly NOTICE_TYPE[],
320307
urlConfigs?: string,
321-
urlRuleDoc?: string
308+
urlRuleDoc?: string | UrlRuleDocFunction
322309
) {
323310
const lines: string[] = [];
324311

@@ -502,7 +489,7 @@ export function generateRuleHeaderLines(
502489
ruleDocNotices: readonly NOTICE_TYPE[],
503490
ruleDocTitleFormat: RuleDocTitleFormat,
504491
urlConfigs?: string,
505-
urlRuleDoc?: string
492+
urlRuleDoc?: string | UrlRuleDocFunction
506493
): string {
507494
return [
508495
makeRuleDocTitle(name, description, pluginPrefix, ruleDocTitleFormat),

lib/rule-link.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { countOccurrencesInString } from './string.js';
22
import { join, sep, relative } from 'node:path';
3-
import { Plugin, RULE_SOURCE } from './types.js';
3+
import { Plugin, RULE_SOURCE, UrlRuleDocFunction } from './types.js';
44

55
export function replaceRulePlaceholder(pathOrUrl: string, ruleName: string) {
66
return pathOrUrl.replace(/\{name\}/gu, ruleName);
@@ -35,8 +35,8 @@ export function getUrlToRule(
3535
pluginPrefix: string,
3636
pathPlugin: string,
3737
pathRuleDoc: string,
38-
urlCurrentPage: string,
39-
urlRuleDoc?: string
38+
pathCurrentPage: string,
39+
urlRuleDoc?: string | UrlRuleDocFunction
4040
) {
4141
switch (ruleSource) {
4242
case RULE_SOURCE.eslintCore:
@@ -50,7 +50,7 @@ export function getUrlToRule(
5050
}
5151

5252
const nestingDepthOfCurrentPage = countOccurrencesInString(
53-
relative(pathPlugin, urlCurrentPage),
53+
relative(pathPlugin, pathCurrentPage),
5454
sep
5555
);
5656
const relativePathPluginRoot = goUpLevel(nestingDepthOfCurrentPage);
@@ -61,14 +61,26 @@ export function getUrlToRule(
6161
? ruleName.slice(pluginPrefix.length + 1)
6262
: ruleName;
6363

64-
return urlRuleDoc
65-
? replaceRulePlaceholder(urlRuleDoc, ruleNameWithoutPluginPrefix)
66-
: pathToUrl(
67-
join(
68-
relativePathPluginRoot,
69-
replaceRulePlaceholder(pathRuleDoc, ruleNameWithoutPluginPrefix)
70-
)
71-
);
64+
// If the URL is a function, evaluate it.
65+
const urlRuleDocFunctionEvaluated =
66+
typeof urlRuleDoc === 'function'
67+
? urlRuleDoc(ruleName, pathToUrl(relative(pathPlugin, pathCurrentPage)))
68+
: undefined;
69+
70+
return (
71+
// If the function returned a URL, use it.
72+
urlRuleDocFunctionEvaluated ??
73+
(typeof urlRuleDoc === 'string'
74+
? // Otherwise, use the URL if it's a string.
75+
replaceRulePlaceholder(urlRuleDoc, ruleNameWithoutPluginPrefix)
76+
: // Finally, fallback to the relative path.
77+
pathToUrl(
78+
join(
79+
relativePathPluginRoot,
80+
replaceRulePlaceholder(pathRuleDoc, ruleNameWithoutPluginPrefix)
81+
)
82+
))
83+
);
7284
}
7385

7486
/**
@@ -80,10 +92,10 @@ export function getLinkToRule(
8092
pluginPrefix: string,
8193
pathPlugin: string,
8294
pathRuleDoc: string,
83-
urlCurrentPage: string,
95+
pathCurrentPage: string,
8496
includeBackticks: boolean,
8597
includePrefix: boolean,
86-
urlRuleDoc?: string
98+
urlRuleDoc?: string | UrlRuleDocFunction
8799
) {
88100
const ruleNameWithoutPluginPrefix = ruleName.startsWith(`${pluginPrefix}/`)
89101
? ruleName.slice(pluginPrefix.length + 1)
@@ -112,7 +124,7 @@ export function getLinkToRule(
112124
pluginPrefix,
113125
pathPlugin,
114126
pathRuleDoc,
115-
urlCurrentPage,
127+
pathCurrentPage,
116128
urlRuleDoc
117129
);
118130

lib/rule-list.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
RuleListSplitFunction,
2121
RuleModule,
2222
SEVERITY_TYPE,
23+
UrlRuleDocFunction,
2324
} from './types.js';
2425
import { markdownTable } from 'markdown-table';
2526
import type {
@@ -107,7 +108,7 @@ function buildRuleRow(
107108
pathRuleList: string,
108109
configEmojis: ConfigEmojis,
109110
ignoreConfig: readonly string[],
110-
urlRuleDoc?: string
111+
urlRuleDoc?: string | UrlRuleDocFunction
111112
): readonly string[] {
112113
const columns: {
113114
[key in COLUMN_TYPE]: string | (() => string);
@@ -190,7 +191,7 @@ function generateRulesListMarkdown(
190191
pathRuleList: string,
191192
configEmojis: ConfigEmojis,
192193
ignoreConfig: readonly string[],
193-
urlRuleDoc?: string
194+
urlRuleDoc?: string | UrlRuleDocFunction
194195
): string {
195196
const listHeaderRow = (
196197
Object.entries(columns) as readonly [COLUMN_TYPE, boolean][]
@@ -245,7 +246,7 @@ function generateRuleListMarkdownForRulesAndHeaders(
245246
pathRuleList: string,
246247
configEmojis: ConfigEmojis,
247248
ignoreConfig: readonly string[],
248-
urlRuleDoc?: string
249+
urlRuleDoc?: string | UrlRuleDocFunction
249250
): string {
250251
const parts: string[] = [];
251252

@@ -388,7 +389,7 @@ export function updateRulesList(
388389
ruleListColumns: readonly COLUMN_TYPE[],
389390
ruleListSplit: readonly string[] | RuleListSplitFunction,
390391
urlConfigs?: string,
391-
urlRuleDoc?: string
392+
urlRuleDoc?: string | UrlRuleDocFunction
392393
): string {
393394
let listStartIndex = markdown.indexOf(BEGIN_RULE_LIST_MARKER);
394395
let listEndIndex = markdown.indexOf(END_RULE_LIST_MARKER);

lib/types.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,18 @@ export type RuleListSplitFunction = (rules: RuleNamesAndRules) => readonly {
126126
rules: RuleNamesAndRules;
127127
}[];
128128

129+
/**
130+
* Function for generating the URL to a rule doc.
131+
* Can be provided via a JavaScript-based config file using the `urlRuleDoc` option.
132+
* @param name - the name of the rule
133+
* @param path - the file path to the current page displaying the link, relative to the project root
134+
* @returns the URL to the rule doc, or `undefined` to fallback to the default logic (relative URL)
135+
*/
136+
export type UrlRuleDocFunction = (
137+
name: string,
138+
path: string
139+
) => string | undefined;
140+
129141
// JSDocs for options should be kept in sync with README.md and the CLI runner in cli.ts.
130142
/** The type for the config file (e.g. `.eslint-doc-generatorrc.js`) and internal `generate()` function. */
131143
export type GenerateOptions = {
@@ -194,9 +206,9 @@ export type GenerateOptions = {
194206
/** Link to documentation about the ESLint configurations exported by the plugin. */
195207
readonly urlConfigs?: string;
196208
/**
197-
* Link to documentation for each rule.
209+
* Link (or function to generate a link) to documentation for each rule.
198210
* Useful when it differs from the rule doc path on disk (e.g. custom documentation site in use).
199-
* Use `{name}` placeholder for the rule name.
211+
* For the string version, use `{name}` placeholder for the rule name.
200212
*/
201-
readonly urlRuleDoc?: string;
213+
readonly urlRuleDoc?: string | UrlRuleDocFunction;
202214
};

test/lib/generate/__snapshots__/option-url-rule-doc-test.ts.snap

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,95 @@ exports[`generate (--url-rule-doc) basic uses the right URLs 3`] = `
3030
<!-- end auto-generated rule header -->
3131
"
3232
`;
33+
34+
exports[`generate (--url-rule-doc) function returns undefined should fallback to the normal URL 1`] = `
35+
"## Rules
36+
<!-- begin auto-generated rules list -->
37+
38+
❌ Deprecated.
39+
40+
| Name | Description | ❌ |
41+
| :----------------------------- | :---------------------- | :- |
42+
| [no-bar](docs/rules/no-bar.md) | Description for no-bar. | |
43+
| [no-foo](docs/rules/no-foo.md) | Description for no-foo. | ❌ |
44+
45+
<!-- end auto-generated rules list -->
46+
"
47+
`;
48+
49+
exports[`generate (--url-rule-doc) function returns undefined should fallback to the normal URL 2`] = `
50+
"## Rules
51+
<!-- begin auto-generated rules list -->
52+
53+
❌ Deprecated.
54+
55+
| Name | Description | ❌ |
56+
| :-------------------------------- | :---------------------- | :- |
57+
| [no-bar](../docs/rules/no-bar.md) | Description for no-bar. | |
58+
| [no-foo](../docs/rules/no-foo.md) | Description for no-foo. | ❌ |
59+
60+
<!-- end auto-generated rules list -->
61+
"
62+
`;
63+
64+
exports[`generate (--url-rule-doc) function returns undefined should fallback to the normal URL 3`] = `
65+
"# Description for no-foo (\`test/no-foo\`)
66+
67+
❌ This rule is deprecated. It was replaced by [\`test/no-bar\`](../../docs/rules/no-bar.md).
68+
69+
<!-- end auto-generated rule header -->
70+
"
71+
`;
72+
73+
exports[`generate (--url-rule-doc) function returns undefined should fallback to the normal URL 4`] = `
74+
"# Description for no-bar (\`test/no-bar\`)
75+
76+
<!-- end auto-generated rule header -->
77+
"
78+
`;
79+
80+
exports[`generate (--url-rule-doc) function uses the custom URL 1`] = `
81+
"## Rules
82+
<!-- begin auto-generated rules list -->
83+
84+
❌ Deprecated.
85+
86+
| Name | Description | ❌ |
87+
| :----------------------------------------------------------------- | :---------------------- | :- |
88+
| [no-bar](https://example.com/rule-docs/name:no-bar/path:README.md) | Description for no-bar. | |
89+
| [no-foo](https://example.com/rule-docs/name:no-foo/path:README.md) | Description for no-foo. | ❌ |
90+
91+
<!-- end auto-generated rules list -->
92+
"
93+
`;
94+
95+
exports[`generate (--url-rule-doc) function uses the custom URL 2`] = `
96+
"## Rules
97+
<!-- begin auto-generated rules list -->
98+
99+
❌ Deprecated.
100+
101+
| Name | Description | ❌ |
102+
| :------------------------------------------------------------------------ | :---------------------- | :- |
103+
| [no-bar](https://example.com/rule-docs/name:no-bar/path:nested/README.md) | Description for no-bar. | |
104+
| [no-foo](https://example.com/rule-docs/name:no-foo/path:nested/README.md) | Description for no-foo. | ❌ |
105+
106+
<!-- end auto-generated rules list -->
107+
"
108+
`;
109+
110+
exports[`generate (--url-rule-doc) function uses the custom URL 3`] = `
111+
"# Description for no-foo (\`test/no-foo\`)
112+
113+
❌ This rule is deprecated. It was replaced by [\`test/no-bar\`](https://example.com/rule-docs/name:no-bar/path:docs/rules/no-foo.md).
114+
115+
<!-- end auto-generated rule header -->
116+
"
117+
`;
118+
119+
exports[`generate (--url-rule-doc) function uses the custom URL 4`] = `
120+
"# Description for no-bar (\`test/no-bar\`)
121+
122+
<!-- end auto-generated rule header -->
123+
"
124+
`;

0 commit comments

Comments
 (0)