eslint-plugin-jsdoc 63.0.14 → 63.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index-cjs.js CHANGED
@@ -36,6 +36,7 @@ import noDefaults from './rules/noDefaults.js';
36
36
  import noMissingSyntax from './rules/noMissingSyntax.js';
37
37
  import noMultiAsterisks from './rules/noMultiAsterisks.js';
38
38
  import noRestrictedSyntax from './rules/noRestrictedSyntax.js';
39
+ import normalizeSeeLinks from './rules/normalizeSeeLinks.js';
39
40
  import noTypes from './rules/noTypes.js';
40
41
  import noUndefinedTypes from './rules/noUndefinedTypes.js';
41
42
  import preferImportTag from './rules/preferImportTag.js';
@@ -128,6 +129,7 @@ index.rules = {
128
129
  'no-restricted-syntax': noRestrictedSyntax,
129
130
  'no-types': noTypes,
130
131
  'no-undefined-types': noUndefinedTypes,
132
+ 'normalize-see-links': normalizeSeeLinks,
131
133
  'prefer-import-tag': preferImportTag,
132
134
  'reject-any-type': buildRejectOrPreferRuleDefinition({
133
135
  description: 'Reports use of `any` or `*` type',
@@ -318,6 +320,7 @@ const createRecommendedRuleset = (warnOrError, flatName) => {
318
320
  'jsdoc/no-restricted-syntax': 'off',
319
321
  'jsdoc/no-types': 'off',
320
322
  'jsdoc/no-undefined-types': warnOrError,
323
+ 'jsdoc/normalize-see-links': 'off',
321
324
  'jsdoc/prefer-import-tag': 'off',
322
325
  'jsdoc/reject-any-type': warnOrError,
323
326
  'jsdoc/reject-function-type': warnOrError,
package/src/index.js CHANGED
@@ -42,6 +42,7 @@ import noDefaults from './rules/noDefaults.js';
42
42
  import noMissingSyntax from './rules/noMissingSyntax.js';
43
43
  import noMultiAsterisks from './rules/noMultiAsterisks.js';
44
44
  import noRestrictedSyntax from './rules/noRestrictedSyntax.js';
45
+ import normalizeSeeLinks from './rules/normalizeSeeLinks.js';
45
46
  import noTypes from './rules/noTypes.js';
46
47
  import noUndefinedTypes from './rules/noUndefinedTypes.js';
47
48
  import preferImportTag from './rules/preferImportTag.js';
@@ -134,6 +135,7 @@ index.rules = {
134
135
  'no-restricted-syntax': noRestrictedSyntax,
135
136
  'no-types': noTypes,
136
137
  'no-undefined-types': noUndefinedTypes,
138
+ 'normalize-see-links': normalizeSeeLinks,
137
139
  'prefer-import-tag': preferImportTag,
138
140
  'reject-any-type': buildRejectOrPreferRuleDefinition({
139
141
  description: 'Reports use of `any` or `*` type',
@@ -324,6 +326,7 @@ const createRecommendedRuleset = (warnOrError, flatName) => {
324
326
  'jsdoc/no-restricted-syntax': 'off',
325
327
  'jsdoc/no-types': 'off',
326
328
  'jsdoc/no-undefined-types': warnOrError,
329
+ 'jsdoc/normalize-see-links': 'off',
327
330
  'jsdoc/prefer-import-tag': 'off',
328
331
  'jsdoc/reject-any-type': warnOrError,
329
332
  'jsdoc/reject-function-type': warnOrError,
@@ -0,0 +1,355 @@
1
+ import iterateJsdoc from '../iterateJsdoc.js';
2
+
3
+ const markdownLinkRegex = /(?<!\\)\[([^`\]\r\n]+)\]\(([^`\(\)\s]+)\)/gv;
4
+ const prefixLinkRegex = /(?<!\\)\[([^\]\r\n]+)\]\{@link\s+([^\}\s\|]+)\}/gv;
5
+ const pipeLinkRegex = /\{@link\s+([^\}\s\|]+)\s*\|\s*([^\}\r\n]+)\}/gv;
6
+
7
+ const markdownLinkAttemptRegex = /(?<!\\)\[[^\]\r\n]*\]\([^\r\n]*(?:\)|$)/v;
8
+ const prefixLinkAttemptRegex = /(?<!\\)\[[^\]\r\n]*\]\{@link[^\}\r\n]*(?:\}|$)/v;
9
+ const pipeLinkAttemptRegex = /\{@link[^\}\r\n]*\|[^\}\r\n]*(?:\}|$)/v;
10
+
11
+ const scopedPackageNameRegex = /^@[\w.\-]+\/[\w.\-]+/v;
12
+ const markdownCodeSpanRegex = /(?<!\\)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/gv;
13
+ const bareHttpUrlRegex = /^https?:\S+$/v;
14
+
15
+ /**
16
+ * @param {'pipe'|'prefix'} canonicalForm
17
+ * @param {string} label
18
+ * @returns {boolean}
19
+ */
20
+ const cannotSafelyFormatLink = (canonicalForm, label) => {
21
+ if (!label.trim()) {
22
+ return true;
23
+ }
24
+
25
+ if (canonicalForm === 'pipe') {
26
+ return label.includes('}');
27
+ }
28
+
29
+ return label.includes(']');
30
+ };
31
+
32
+ /**
33
+ * @param {string} target
34
+ * @returns {string}
35
+ */
36
+ const encodeLinkTarget = (target) => {
37
+ return encodeURI(target).replaceAll(/%25(?=[\dA-F]{2})/giv, '%');
38
+ };
39
+
40
+ /**
41
+ * @param {string} description
42
+ * @param {boolean} enabled
43
+ * @returns {string|null}
44
+ */
45
+ const getBareHttpUrl = (description, enabled) => {
46
+ if (!enabled) {
47
+ return null;
48
+ }
49
+
50
+ const trimmedDescription = description.trim();
51
+
52
+ return bareHttpUrlRegex.test(trimmedDescription) &&
53
+ URL.canParse(trimmedDescription) ?
54
+ trimmedDescription : null;
55
+ };
56
+
57
+ /**
58
+ * @param {string} target
59
+ * @returns {string}
60
+ */
61
+ const formatBareUrl = (target) => {
62
+ return `{@link ${encodeLinkTarget(target)}}`;
63
+ };
64
+
65
+ /**
66
+ * @param {string|undefined} bareUrl
67
+ * @param {'pipe'|'prefix'} canonicalForm
68
+ * @returns {string}
69
+ */
70
+ const getExpectedMessage = (bareUrl, canonicalForm) => {
71
+ return bareUrl ?
72
+ 'Expected bare @see URL to use a {@link} tag.' :
73
+ `Expected @see link to use the ${canonicalForm} form.`;
74
+ };
75
+
76
+ /**
77
+ * @param {string} description
78
+ * @param {number} index
79
+ * @returns {boolean}
80
+ */
81
+ const isInsideMarkdownCodeSpan = (description, index) => {
82
+ for (const match of description.matchAll(markdownCodeSpanRegex)) {
83
+ const delimiterLength = match[1].length;
84
+ const contentStart = match.index + delimiterLength;
85
+ const contentEnd = match.index + match[0].length - delimiterLength;
86
+
87
+ if (index >= contentStart && index < contentEnd) {
88
+ return true;
89
+ }
90
+ }
91
+
92
+ return false;
93
+ };
94
+
95
+ /**
96
+ * @param {'pipe'|'prefix'} canonicalForm
97
+ * @param {string} label
98
+ * @param {string} target
99
+ * @returns {string}
100
+ */
101
+ const formatLink = (canonicalForm, label, target) => {
102
+ const encodedTarget = encodeLinkTarget(target);
103
+
104
+ return canonicalForm === 'pipe' ?
105
+ `{@link ${encodedTarget}|${label.trim()}}` :
106
+ `[${label.trim()}]{@link ${encodedTarget}}`;
107
+ };
108
+
109
+ /**
110
+ * @param {string} description
111
+ * @param {'pipe'|'prefix'} canonicalForm
112
+ * @returns {string}
113
+ */
114
+ const normalizeDescription = (description, canonicalForm) => {
115
+ const markdownNormalized = description.replaceAll(
116
+ new RegExp(markdownLinkRegex, 'gv'),
117
+ (_match, label, target) => {
118
+ return formatLink(canonicalForm, label, target);
119
+ },
120
+ );
121
+
122
+ if (canonicalForm === 'pipe') {
123
+ return markdownNormalized.replaceAll(
124
+ new RegExp(prefixLinkRegex, 'gv'),
125
+ (_match, label, target) => {
126
+ return formatLink(canonicalForm, label, target);
127
+ },
128
+ );
129
+ }
130
+
131
+ return markdownNormalized.replaceAll(
132
+ new RegExp(pipeLinkRegex, 'gv'),
133
+ (_match, target, label) => {
134
+ return formatLink(canonicalForm, label, target);
135
+ },
136
+ );
137
+ };
138
+
139
+ export default iterateJsdoc(({
140
+ context,
141
+ jsdoc,
142
+ utils,
143
+ }) => {
144
+ const {
145
+ canonicalForm = 'pipe',
146
+ enableFixer = true,
147
+ wrapBareUrls = false,
148
+ } = context.options[0] || {};
149
+
150
+ const descriptionMatcher = canonicalForm === 'pipe' ?
151
+ new RegExp(`${markdownLinkRegex.source}|${prefixLinkRegex.source}`, 'v') :
152
+ new RegExp(`${markdownLinkRegex.source}|${pipeLinkRegex.source}`, 'v');
153
+
154
+ /** @type {import('comment-parser').Spec[]} */
155
+ const fixableTags = [];
156
+ /** @type {Map<import('comment-parser').Spec, string>} */
157
+ const bareUrlTags = new Map();
158
+
159
+ for (const tag of jsdoc.tags) {
160
+ if (tag.tag !== 'see') {
161
+ continue;
162
+ }
163
+
164
+ const firstTokens = tag.source[0].tokens;
165
+ const tagDescription = String(utils.getTagDescription(tag));
166
+ const rawDescription = firstTokens.name ?
167
+ firstTokens.name + firstTokens.postName + tagDescription :
168
+ tagDescription;
169
+
170
+ let hasAmbiguousLink = false;
171
+ let hasNoncanonicalLink = false;
172
+ const bareUrl = getBareHttpUrl(rawDescription, wrapBareUrls);
173
+
174
+ if (bareUrl) {
175
+ bareUrlTags.set(tag, bareUrl);
176
+ hasNoncanonicalLink = true;
177
+ }
178
+
179
+ for (const match of rawDescription.matchAll(markdownLinkRegex)) {
180
+ const label = match[1];
181
+ const target = match[2];
182
+
183
+ if (
184
+ isInsideMarkdownCodeSpan(rawDescription, match.index) ||
185
+ scopedPackageNameRegex.test(target) ||
186
+ cannotSafelyFormatLink(canonicalForm, label)
187
+ ) {
188
+ hasAmbiguousLink = true;
189
+ } else {
190
+ hasNoncanonicalLink = true;
191
+ }
192
+ }
193
+
194
+ for (const inlineTag of tag.inlineTags) {
195
+ if (inlineTag.tag !== 'link' || !inlineTag.text) {
196
+ continue;
197
+ }
198
+
199
+ if (inlineTag.format === 'space') {
200
+ hasAmbiguousLink = true;
201
+ continue;
202
+ }
203
+
204
+ if (cannotSafelyFormatLink(
205
+ canonicalForm,
206
+ inlineTag.text,
207
+ )) {
208
+ hasAmbiguousLink = true;
209
+ continue;
210
+ }
211
+
212
+ if (inlineTag.format === canonicalForm) {
213
+ continue;
214
+ }
215
+
216
+ const {
217
+ start,
218
+ } = /** @type {typeof inlineTag & {start: number}} */ (inlineTag);
219
+
220
+ if (
221
+ isInsideMarkdownCodeSpan(rawDescription, start) ||
222
+ scopedPackageNameRegex.test(inlineTag.namepathOrURL)
223
+ ) {
224
+ hasAmbiguousLink = true;
225
+ } else {
226
+ hasNoncanonicalLink = true;
227
+ }
228
+ }
229
+
230
+ const unrecognizedLinkText = rawDescription
231
+ .replaceAll(new RegExp(markdownLinkRegex, 'gv'), '')
232
+ .replaceAll(new RegExp(prefixLinkRegex, 'gv'), '')
233
+ .replaceAll(new RegExp(pipeLinkRegex, 'gv'), '');
234
+
235
+ hasAmbiguousLink ||= markdownLinkAttemptRegex.test(unrecognizedLinkText) ||
236
+ prefixLinkAttemptRegex.test(unrecognizedLinkText) ||
237
+ pipeLinkAttemptRegex.test(unrecognizedLinkText);
238
+
239
+ if (hasAmbiguousLink) {
240
+ utils.reportJSDoc('@see link cannot be safely normalized.', {
241
+ line: tag.source[0].number,
242
+ }, null);
243
+ continue;
244
+ }
245
+
246
+ if (!hasNoncanonicalLink) {
247
+ continue;
248
+ }
249
+
250
+ fixableTags.push(tag);
251
+ }
252
+
253
+ for (const [
254
+ reportIdx,
255
+ tag,
256
+ ] of fixableTags.entries()) {
257
+ const bareUrl = bareUrlTags.get(tag);
258
+
259
+ utils.reportJSDoc(
260
+ getExpectedMessage(bareUrl, canonicalForm),
261
+ {
262
+ line: tag.source[0].number,
263
+ },
264
+ enableFixer && reportIdx === 0 ?
265
+ () => {
266
+ for (const fixableTag of fixableTags) {
267
+ const firstTokens = fixableTag.source[0].tokens;
268
+
269
+ if (firstTokens.name) {
270
+ firstTokens.description = firstTokens.name +
271
+ firstTokens.postName + firstTokens.description;
272
+ firstTokens.name = '';
273
+ firstTokens.postName = '';
274
+ }
275
+
276
+ const fixableBareUrl = bareUrlTags.get(fixableTag);
277
+
278
+ if (fixableBareUrl) {
279
+ for (const source of fixableTag.source) {
280
+ if (!source.tokens.description.includes(fixableBareUrl)) {
281
+ continue;
282
+ }
283
+
284
+ source.tokens.description = source.tokens.description.replace(
285
+ fixableBareUrl,
286
+ () => {
287
+ return formatBareUrl(fixableBareUrl);
288
+ },
289
+ );
290
+ break;
291
+ }
292
+
293
+ continue;
294
+ }
295
+
296
+ for (
297
+ let sourceIdx = 0;
298
+ sourceIdx < fixableTag.source.length;
299
+ sourceIdx++
300
+ ) {
301
+ if (!descriptionMatcher.test(
302
+ fixableTag.source[sourceIdx].tokens.description,
303
+ )) {
304
+ continue;
305
+ }
306
+
307
+ utils.setTagDescription(
308
+ fixableTag,
309
+ descriptionMatcher,
310
+ (description) => {
311
+ return normalizeDescription(description, canonicalForm);
312
+ },
313
+ );
314
+ }
315
+ }
316
+ } :
317
+ null,
318
+ true,
319
+ );
320
+ }
321
+ }, {
322
+ iterateAllJsdocs: true,
323
+ meta: {
324
+ docs: {
325
+ description: 'Normalizes labeled links in `@see` tags to a canonical `{@link}` form.',
326
+ url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/normalize-see-links.md#repos-sticky-header',
327
+ },
328
+ fixable: 'code',
329
+ schema: [
330
+ {
331
+ additionalProperties: false,
332
+ properties: {
333
+ canonicalForm: {
334
+ description: 'The canonical `{@link}` form: `"pipe"` produces `{@link url|label}`, while `"prefix"` produces `[label]{@link url}`. Defaults to `"pipe"`.',
335
+ enum: [
336
+ 'pipe',
337
+ 'prefix',
338
+ ],
339
+ type: 'string',
340
+ },
341
+ enableFixer: {
342
+ description: 'Whether to enable the fixer. Defaults to `true`.',
343
+ type: 'boolean',
344
+ },
345
+ wrapBareUrls: {
346
+ description: 'Whether to wrap an `@see` description containing only a lowercase `http:` or `https:` URL (for example, `@see https://example.com`) in a plain `{@link https://example.com}`. Defaults to `false`.',
347
+ type: 'boolean',
348
+ },
349
+ },
350
+ type: 'object',
351
+ },
352
+ ],
353
+ type: 'suggestion',
354
+ },
355
+ });
package/src/rules.d.ts CHANGED
@@ -1292,6 +1292,26 @@ export interface Rules {
1292
1292
  }
1293
1293
  ];
1294
1294
 
1295
+ /** Normalizes labeled links in `@see` tags to a canonical `{@link}` form. */
1296
+ "jsdoc/normalize-see-links":
1297
+ | []
1298
+ | [
1299
+ {
1300
+ /**
1301
+ * The canonical `{@link}` form: `"pipe"` produces `{@link url|label}`, while `"prefix"` produces `[label]{@link url}`. Defaults to `"pipe"`.
1302
+ */
1303
+ canonicalForm?: "pipe" | "prefix";
1304
+ /**
1305
+ * Whether to enable the fixer. Defaults to `true`.
1306
+ */
1307
+ enableFixer?: boolean;
1308
+ /**
1309
+ * Whether to wrap an `@see` description containing only a lowercase `http:` or `https:` URL (for example, `@see https://example.com`) in a plain `{@link https://example.com}`. Defaults to `false`.
1310
+ */
1311
+ wrapBareUrls?: boolean;
1312
+ }
1313
+ ];
1314
+
1295
1315
  /** Prefer `@import` tags to inline `import()` statements. */
1296
1316
  "jsdoc/prefer-import-tag":
1297
1317
  | []