eslint-plugin-jsdoc 63.0.13 → 63.1.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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalizeSeeLinks.cjs","names":["iterateJsdoc"],"sources":["../../src/rules/normalizeSeeLinks.js"],"sourcesContent":["import iterateJsdoc from '../iterateJsdoc.js';\n\nconst markdownLinkRegex = /(?<!\\\\)\\[([^`\\]\\r\\n]+)\\]\\(([^`\\(\\)\\s]+)\\)/gv;\nconst prefixLinkRegex = /(?<!\\\\)\\[([^\\]\\r\\n]+)\\]\\{@link\\s+([^\\}\\s\\|]+)\\}/gv;\nconst pipeLinkRegex = /\\{@link\\s+([^\\}\\s\\|]+)\\s*\\|\\s*([^\\}\\r\\n]+)\\}/gv;\n\nconst markdownLinkAttemptRegex = /(?<!\\\\)\\[[^\\]\\r\\n]*\\]\\([^\\r\\n]*(?:\\)|$)/v;\nconst prefixLinkAttemptRegex = /(?<!\\\\)\\[[^\\]\\r\\n]*\\]\\{@link[^\\}\\r\\n]*(?:\\}|$)/v;\nconst pipeLinkAttemptRegex = /\\{@link[^\\}\\r\\n]*\\|[^\\}\\r\\n]*(?:\\}|$)/v;\n\nconst scopedPackageNameRegex = /^@[\\w.\\-]+\\/[\\w.\\-]+/v;\nconst markdownCodeSpanRegex = /(?<!\\\\)(`+)(?!`)[\\s\\S]*?(?<!`)\\1(?!`)/gv;\n\n/**\n * @param {'pipe'|'prefix'} canonicalForm\n * @param {string} label\n * @returns {boolean}\n */\nconst cannotSafelyFormatLink = (canonicalForm, label) => {\n if (!label.trim()) {\n return true;\n }\n\n if (canonicalForm === 'pipe') {\n return label.includes('}');\n }\n\n return label.includes(']');\n};\n\n/**\n * @param {string} target\n * @returns {string}\n */\nconst encodeLinkTarget = (target) => {\n return encodeURI(target).replaceAll(/%25(?=[\\dA-F]{2})/giv, '%');\n};\n\n/**\n * @param {string} description\n * @param {number} index\n * @returns {boolean}\n */\nconst isInsideMarkdownCodeSpan = (description, index) => {\n for (const match of description.matchAll(markdownCodeSpanRegex)) {\n const delimiterLength = match[1].length;\n const contentStart = match.index + delimiterLength;\n const contentEnd = match.index + match[0].length - delimiterLength;\n\n if (index >= contentStart && index < contentEnd) {\n return true;\n }\n }\n\n return false;\n};\n\n/**\n * @param {'pipe'|'prefix'} canonicalForm\n * @param {string} label\n * @param {string} target\n * @returns {string}\n */\nconst formatLink = (canonicalForm, label, target) => {\n const encodedTarget = encodeLinkTarget(target);\n\n return canonicalForm === 'pipe' ?\n `{@link ${encodedTarget}|${label.trim()}}` :\n `[${label.trim()}]{@link ${encodedTarget}}`;\n};\n\n/**\n * @param {string} description\n * @param {'pipe'|'prefix'} canonicalForm\n * @returns {string}\n */\nconst normalizeDescription = (description, canonicalForm) => {\n const markdownNormalized = description.replaceAll(\n new RegExp(markdownLinkRegex, 'gv'),\n (_match, label, target) => {\n return formatLink(canonicalForm, label, target);\n },\n );\n\n if (canonicalForm === 'pipe') {\n return markdownNormalized.replaceAll(\n new RegExp(prefixLinkRegex, 'gv'),\n (_match, label, target) => {\n return formatLink(canonicalForm, label, target);\n },\n );\n }\n\n return markdownNormalized.replaceAll(\n new RegExp(pipeLinkRegex, 'gv'),\n (_match, target, label) => {\n return formatLink(canonicalForm, label, target);\n },\n );\n};\n\nexport default iterateJsdoc(({\n context,\n jsdoc,\n utils,\n}) => {\n const {\n canonicalForm = 'pipe',\n enableFixer = true,\n } = context.options[0] || {};\n\n const descriptionMatcher = canonicalForm === 'pipe' ?\n new RegExp(`${markdownLinkRegex.source}|${prefixLinkRegex.source}`, 'v') :\n new RegExp(`${markdownLinkRegex.source}|${pipeLinkRegex.source}`, 'v');\n\n /** @type {import('comment-parser').Spec[]} */\n const fixableTags = [];\n\n for (const tag of jsdoc.tags) {\n if (tag.tag !== 'see') {\n continue;\n }\n\n const firstTokens = tag.source[0].tokens;\n const tagDescription = String(utils.getTagDescription(tag));\n const rawDescription = firstTokens.name ?\n firstTokens.name + firstTokens.postName + tagDescription :\n tagDescription;\n\n let hasAmbiguousLink = false;\n let hasNoncanonicalLink = false;\n\n for (const match of rawDescription.matchAll(markdownLinkRegex)) {\n const label = match[1];\n const target = match[2];\n\n if (\n isInsideMarkdownCodeSpan(rawDescription, match.index) ||\n scopedPackageNameRegex.test(target) ||\n cannotSafelyFormatLink(canonicalForm, label)\n ) {\n hasAmbiguousLink = true;\n } else {\n hasNoncanonicalLink = true;\n }\n }\n\n for (const inlineTag of tag.inlineTags) {\n if (inlineTag.tag !== 'link' || !inlineTag.text) {\n continue;\n }\n\n if (inlineTag.format === 'space') {\n hasAmbiguousLink = true;\n continue;\n }\n\n if (cannotSafelyFormatLink(\n canonicalForm,\n inlineTag.text,\n )) {\n hasAmbiguousLink = true;\n continue;\n }\n\n if (inlineTag.format === canonicalForm) {\n continue;\n }\n\n const {\n start,\n } = /** @type {typeof inlineTag & {start: number}} */ (inlineTag);\n\n if (\n isInsideMarkdownCodeSpan(rawDescription, start) ||\n scopedPackageNameRegex.test(inlineTag.namepathOrURL)\n ) {\n hasAmbiguousLink = true;\n } else {\n hasNoncanonicalLink = true;\n }\n }\n\n const unrecognizedLinkText = rawDescription\n .replaceAll(new RegExp(markdownLinkRegex, 'gv'), '')\n .replaceAll(new RegExp(prefixLinkRegex, 'gv'), '')\n .replaceAll(new RegExp(pipeLinkRegex, 'gv'), '');\n\n hasAmbiguousLink ||= markdownLinkAttemptRegex.test(unrecognizedLinkText) ||\n prefixLinkAttemptRegex.test(unrecognizedLinkText) ||\n pipeLinkAttemptRegex.test(unrecognizedLinkText);\n\n if (hasAmbiguousLink) {\n utils.reportJSDoc('@see link cannot be safely normalized.', {\n line: tag.source[0].number,\n }, null);\n continue;\n }\n\n if (!hasNoncanonicalLink) {\n continue;\n }\n\n fixableTags.push(tag);\n }\n\n for (const [\n reportIdx,\n tag,\n ] of fixableTags.entries()) {\n utils.reportJSDoc(\n `Expected @see link to use the ${canonicalForm} form.`,\n {\n line: tag.source[0].number,\n },\n enableFixer && reportIdx === 0 ?\n () => {\n for (const fixableTag of fixableTags) {\n const firstTokens = fixableTag.source[0].tokens;\n\n if (firstTokens.name) {\n firstTokens.description = firstTokens.name +\n firstTokens.postName + firstTokens.description;\n firstTokens.name = '';\n firstTokens.postName = '';\n }\n\n for (\n let sourceIdx = 0;\n sourceIdx < fixableTag.source.length;\n sourceIdx++\n ) {\n if (!descriptionMatcher.test(\n fixableTag.source[sourceIdx].tokens.description,\n )) {\n continue;\n }\n\n utils.setTagDescription(\n fixableTag,\n descriptionMatcher,\n (description) => {\n return normalizeDescription(description, canonicalForm);\n },\n );\n }\n }\n } :\n null,\n true,\n );\n }\n}, {\n iterateAllJsdocs: true,\n meta: {\n docs: {\n description: 'Normalizes labeled links in `@see` tags to a canonical `{@link}` form.',\n url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/normalize-see-links.md#repos-sticky-header',\n },\n fixable: 'code',\n schema: [\n {\n additionalProperties: false,\n properties: {\n canonicalForm: {\n description: 'The canonical `{@link}` form: `\"pipe\"` produces `{@link url|label}`, while `\"prefix\"` produces `[label]{@link url}`. Defaults to `\"pipe\"`.',\n enum: [\n 'pipe',\n 'prefix',\n ],\n type: 'string',\n },\n enableFixer: {\n description: 'Whether to enable the fixer. Defaults to `true`.',\n type: 'boolean',\n },\n },\n type: 'object',\n },\n ],\n type: 'suggestion',\n },\n});\n"],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,3 @@
1
+ declare const _default: import("eslint").Rule.RuleModule;
2
+ export default _default;
3
+ //# sourceMappingURL=normalizeSeeLinks.d.ts.map
package/dist/rules.d.ts CHANGED
@@ -1292,6 +1292,22 @@ 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
+ ];
1310
+
1295
1311
  /** Prefer `@import` tags to inline `import()` statements. */
1296
1312
  "jsdoc/prefer-import-tag":
1297
1313
  | []
package/package.json CHANGED
@@ -179,5 +179,5 @@
179
179
  "test-cov": "TIMING=1 c8 --reporter text pnpm run test-no-cov",
180
180
  "test-index": "pnpm run test-no-cov test/rules/index.js"
181
181
  },
182
- "version": "63.0.13"
182
+ "version": "63.1.0"
183
183
  }
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,
@@ -41,6 +41,34 @@ export default iterateJsdoc(({
41
41
  'linkplain',
42
42
  ]);
43
43
 
44
+ const markdownCodeSpanRegex = /(?<!\\)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/gv;
45
+
46
+ /**
47
+ * @param {string} desc
48
+ * @param {number} index
49
+ * @returns {boolean}
50
+ */
51
+ const isInsideMarkdownCodeSpan = (desc, index) => {
52
+ markdownCodeSpanRegex.lastIndex = 0;
53
+
54
+ let match;
55
+ while ((match = markdownCodeSpanRegex.exec(desc)) !== null) {
56
+ const delimiterLength = match[1].length;
57
+ const contentStart = match.index + delimiterLength;
58
+ const contentEnd = match.index + match[0].length - delimiterLength;
59
+
60
+ if (index < contentStart) {
61
+ return false;
62
+ }
63
+
64
+ if (index < contentEnd) {
65
+ return true;
66
+ }
67
+ }
68
+
69
+ return false;
70
+ };
71
+
44
72
  /**
45
73
  * @param {string} desc
46
74
  * @param {number} atSignIndex
@@ -72,8 +100,9 @@ export default iterateJsdoc(({
72
100
  const atSignIndex = offset + match.lastIndexOf('@');
73
101
  const inlineTagName = getInlineTagName(desc, atSignIndex);
74
102
 
75
- return declarationReferenceInlineTags.has(inlineTagName) &&
76
- scopedPackageNameRegex.test(desc.slice(atSignIndex));
103
+ return isInsideMarkdownCodeSpan(desc, atSignIndex) ||
104
+ declarationReferenceInlineTags.has(inlineTagName) &&
105
+ scopedPackageNameRegex.test(desc.slice(atSignIndex));
77
106
  };
78
107
 
79
108
  /**
@@ -0,0 +1,283 @@
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
+
14
+ /**
15
+ * @param {'pipe'|'prefix'} canonicalForm
16
+ * @param {string} label
17
+ * @returns {boolean}
18
+ */
19
+ const cannotSafelyFormatLink = (canonicalForm, label) => {
20
+ if (!label.trim()) {
21
+ return true;
22
+ }
23
+
24
+ if (canonicalForm === 'pipe') {
25
+ return label.includes('}');
26
+ }
27
+
28
+ return label.includes(']');
29
+ };
30
+
31
+ /**
32
+ * @param {string} target
33
+ * @returns {string}
34
+ */
35
+ const encodeLinkTarget = (target) => {
36
+ return encodeURI(target).replaceAll(/%25(?=[\dA-F]{2})/giv, '%');
37
+ };
38
+
39
+ /**
40
+ * @param {string} description
41
+ * @param {number} index
42
+ * @returns {boolean}
43
+ */
44
+ const isInsideMarkdownCodeSpan = (description, index) => {
45
+ for (const match of description.matchAll(markdownCodeSpanRegex)) {
46
+ const delimiterLength = match[1].length;
47
+ const contentStart = match.index + delimiterLength;
48
+ const contentEnd = match.index + match[0].length - delimiterLength;
49
+
50
+ if (index >= contentStart && index < contentEnd) {
51
+ return true;
52
+ }
53
+ }
54
+
55
+ return false;
56
+ };
57
+
58
+ /**
59
+ * @param {'pipe'|'prefix'} canonicalForm
60
+ * @param {string} label
61
+ * @param {string} target
62
+ * @returns {string}
63
+ */
64
+ const formatLink = (canonicalForm, label, target) => {
65
+ const encodedTarget = encodeLinkTarget(target);
66
+
67
+ return canonicalForm === 'pipe' ?
68
+ `{@link ${encodedTarget}|${label.trim()}}` :
69
+ `[${label.trim()}]{@link ${encodedTarget}}`;
70
+ };
71
+
72
+ /**
73
+ * @param {string} description
74
+ * @param {'pipe'|'prefix'} canonicalForm
75
+ * @returns {string}
76
+ */
77
+ const normalizeDescription = (description, canonicalForm) => {
78
+ const markdownNormalized = description.replaceAll(
79
+ new RegExp(markdownLinkRegex, 'gv'),
80
+ (_match, label, target) => {
81
+ return formatLink(canonicalForm, label, target);
82
+ },
83
+ );
84
+
85
+ if (canonicalForm === 'pipe') {
86
+ return markdownNormalized.replaceAll(
87
+ new RegExp(prefixLinkRegex, 'gv'),
88
+ (_match, label, target) => {
89
+ return formatLink(canonicalForm, label, target);
90
+ },
91
+ );
92
+ }
93
+
94
+ return markdownNormalized.replaceAll(
95
+ new RegExp(pipeLinkRegex, 'gv'),
96
+ (_match, target, label) => {
97
+ return formatLink(canonicalForm, label, target);
98
+ },
99
+ );
100
+ };
101
+
102
+ export default iterateJsdoc(({
103
+ context,
104
+ jsdoc,
105
+ utils,
106
+ }) => {
107
+ const {
108
+ canonicalForm = 'pipe',
109
+ enableFixer = true,
110
+ } = context.options[0] || {};
111
+
112
+ const descriptionMatcher = canonicalForm === 'pipe' ?
113
+ new RegExp(`${markdownLinkRegex.source}|${prefixLinkRegex.source}`, 'v') :
114
+ new RegExp(`${markdownLinkRegex.source}|${pipeLinkRegex.source}`, 'v');
115
+
116
+ /** @type {import('comment-parser').Spec[]} */
117
+ const fixableTags = [];
118
+
119
+ for (const tag of jsdoc.tags) {
120
+ if (tag.tag !== 'see') {
121
+ continue;
122
+ }
123
+
124
+ const firstTokens = tag.source[0].tokens;
125
+ const tagDescription = String(utils.getTagDescription(tag));
126
+ const rawDescription = firstTokens.name ?
127
+ firstTokens.name + firstTokens.postName + tagDescription :
128
+ tagDescription;
129
+
130
+ let hasAmbiguousLink = false;
131
+ let hasNoncanonicalLink = false;
132
+
133
+ for (const match of rawDescription.matchAll(markdownLinkRegex)) {
134
+ const label = match[1];
135
+ const target = match[2];
136
+
137
+ if (
138
+ isInsideMarkdownCodeSpan(rawDescription, match.index) ||
139
+ scopedPackageNameRegex.test(target) ||
140
+ cannotSafelyFormatLink(canonicalForm, label)
141
+ ) {
142
+ hasAmbiguousLink = true;
143
+ } else {
144
+ hasNoncanonicalLink = true;
145
+ }
146
+ }
147
+
148
+ for (const inlineTag of tag.inlineTags) {
149
+ if (inlineTag.tag !== 'link' || !inlineTag.text) {
150
+ continue;
151
+ }
152
+
153
+ if (inlineTag.format === 'space') {
154
+ hasAmbiguousLink = true;
155
+ continue;
156
+ }
157
+
158
+ if (cannotSafelyFormatLink(
159
+ canonicalForm,
160
+ inlineTag.text,
161
+ )) {
162
+ hasAmbiguousLink = true;
163
+ continue;
164
+ }
165
+
166
+ if (inlineTag.format === canonicalForm) {
167
+ continue;
168
+ }
169
+
170
+ const {
171
+ start,
172
+ } = /** @type {typeof inlineTag & {start: number}} */ (inlineTag);
173
+
174
+ if (
175
+ isInsideMarkdownCodeSpan(rawDescription, start) ||
176
+ scopedPackageNameRegex.test(inlineTag.namepathOrURL)
177
+ ) {
178
+ hasAmbiguousLink = true;
179
+ } else {
180
+ hasNoncanonicalLink = true;
181
+ }
182
+ }
183
+
184
+ const unrecognizedLinkText = rawDescription
185
+ .replaceAll(new RegExp(markdownLinkRegex, 'gv'), '')
186
+ .replaceAll(new RegExp(prefixLinkRegex, 'gv'), '')
187
+ .replaceAll(new RegExp(pipeLinkRegex, 'gv'), '');
188
+
189
+ hasAmbiguousLink ||= markdownLinkAttemptRegex.test(unrecognizedLinkText) ||
190
+ prefixLinkAttemptRegex.test(unrecognizedLinkText) ||
191
+ pipeLinkAttemptRegex.test(unrecognizedLinkText);
192
+
193
+ if (hasAmbiguousLink) {
194
+ utils.reportJSDoc('@see link cannot be safely normalized.', {
195
+ line: tag.source[0].number,
196
+ }, null);
197
+ continue;
198
+ }
199
+
200
+ if (!hasNoncanonicalLink) {
201
+ continue;
202
+ }
203
+
204
+ fixableTags.push(tag);
205
+ }
206
+
207
+ for (const [
208
+ reportIdx,
209
+ tag,
210
+ ] of fixableTags.entries()) {
211
+ utils.reportJSDoc(
212
+ `Expected @see link to use the ${canonicalForm} form.`,
213
+ {
214
+ line: tag.source[0].number,
215
+ },
216
+ enableFixer && reportIdx === 0 ?
217
+ () => {
218
+ for (const fixableTag of fixableTags) {
219
+ const firstTokens = fixableTag.source[0].tokens;
220
+
221
+ if (firstTokens.name) {
222
+ firstTokens.description = firstTokens.name +
223
+ firstTokens.postName + firstTokens.description;
224
+ firstTokens.name = '';
225
+ firstTokens.postName = '';
226
+ }
227
+
228
+ for (
229
+ let sourceIdx = 0;
230
+ sourceIdx < fixableTag.source.length;
231
+ sourceIdx++
232
+ ) {
233
+ if (!descriptionMatcher.test(
234
+ fixableTag.source[sourceIdx].tokens.description,
235
+ )) {
236
+ continue;
237
+ }
238
+
239
+ utils.setTagDescription(
240
+ fixableTag,
241
+ descriptionMatcher,
242
+ (description) => {
243
+ return normalizeDescription(description, canonicalForm);
244
+ },
245
+ );
246
+ }
247
+ }
248
+ } :
249
+ null,
250
+ true,
251
+ );
252
+ }
253
+ }, {
254
+ iterateAllJsdocs: true,
255
+ meta: {
256
+ docs: {
257
+ description: 'Normalizes labeled links in `@see` tags to a canonical `{@link}` form.',
258
+ url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/normalize-see-links.md#repos-sticky-header',
259
+ },
260
+ fixable: 'code',
261
+ schema: [
262
+ {
263
+ additionalProperties: false,
264
+ properties: {
265
+ canonicalForm: {
266
+ description: 'The canonical `{@link}` form: `"pipe"` produces `{@link url|label}`, while `"prefix"` produces `[label]{@link url}`. Defaults to `"pipe"`.',
267
+ enum: [
268
+ 'pipe',
269
+ 'prefix',
270
+ ],
271
+ type: 'string',
272
+ },
273
+ enableFixer: {
274
+ description: 'Whether to enable the fixer. Defaults to `true`.',
275
+ type: 'boolean',
276
+ },
277
+ },
278
+ type: 'object',
279
+ },
280
+ ],
281
+ type: 'suggestion',
282
+ },
283
+ });
package/src/rules.d.ts CHANGED
@@ -1292,6 +1292,22 @@ 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
+ ];
1310
+
1295
1311
  /** Prefer `@import` tags to inline `import()` statements. */
1296
1312
  "jsdoc/prefer-import-tag":
1297
1313
  | []