diff --git a/.README/rules/normalize-see-links.md b/.README/rules/normalize-see-links.md
new file mode 100644
index 000000000..9ddb2ec93
--- /dev/null
+++ b/.README/rules/normalize-see-links.md
@@ -0,0 +1,23 @@
+# `normalize-see-links`
+
+Normalizes labeled links in `@see` tags to a canonical `{@link}` form.
+
+## Options
+
+{"gitdown": "options"}
+
+|||
+|---|---|
+|Context|everywhere|
+|Tags|`see`|
+|Recommended|false|
+|Settings||
+|Options|`canonicalForm`, `enableFixer`|
+
+## Failing examples
+
+
+
+## Passing examples
+
+
diff --git a/README.md b/README.md
index 01249a09a..7d2095f2f 100644
--- a/README.md
+++ b/README.md
@@ -491,6 +491,7 @@ non-default-recommended fixer).
||| [no-restricted-syntax](./docs/rules/no-restricted-syntax.md#readme) | Reports when certain comment structures are present. |
|On in TS; Off in TS flavor|:wrench:| [no-types](./docs/rules/no-types.md#readme) | This rule reports types being used on `@param` or `@returns` (redundant with TypeScript). |
|:heavy_check_mark: (Off in TS; Off in TS flavor)|| [no-undefined-types](./docs/rules/no-undefined-types.md#readme) | Besides some expected built-in types, prohibits any types not specified as globals or within `@typedef`. |
+||:wrench:| [normalize-see-links](./docs/rules/normalize-see-links.md#readme) | Normalizes labeled links in `@see` tags to a canonical `{@link}` form. |
||:wrench:| [prefer-import-tag](./docs/rules/prefer-import-tag.md#readme) | Prefer `@import` tags to inline `import()` statements. |
|:heavy_check_mark:|| [reject-any-type](./docs/rules/reject-any-type.md#readme) | Reports use of `any` or `*` type |
|:heavy_check_mark:|| [reject-function-type](./docs/rules/reject-function-type.md#readme) | Reports use of `Function` type |
diff --git a/docs/rules/normalize-see-links.md b/docs/rules/normalize-see-links.md
new file mode 100644
index 000000000..0e6273344
--- /dev/null
+++ b/docs/rules/normalize-see-links.md
@@ -0,0 +1,420 @@
+
+
+# normalize-see-links
+
+Normalizes labeled links in `@see` tags to a canonical `{@link}` form.
+
+
+
+## Options
+
+A single options object has the following properties.
+
+
+
+### canonicalForm
+
+The canonical `{@link}` form: `"pipe"` produces `{@link url|label}`, while `"prefix"` produces `[label]{@link url}`. Defaults to `"pipe"`.
+
+
+
+### enableFixer
+
+Whether to enable the fixer. Defaults to `true`.
+
+
+|||
+|---|---|
+|Context|everywhere|
+|Tags|`see`|
+|Recommended|false|
+|Settings||
+|Options|`canonicalForm`, `enableFixer`|
+
+
+
+## Failing examples
+
+The following patterns are considered problems:
+
+````ts
+/**
+ * @see [Docs](https://api.example.com/v1?ids=1|2|3)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [See {options}](https://example.com/api)
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [a}b](https://example.com)
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [Foo](https://example.com/a|b)
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+// Message: Expected @see link to use the prefix form.
+
+/**
+ * @see [ ](https://example.com)
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see {@link https://example.com|A]B}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [ ]{@link https://example.com}
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [Foo](https://example.com/a}b)
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+// Message: Expected @see link to use the prefix form.
+
+/**
+ * @see [Foo](https://example.com/a{b)
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+// Message: Expected @see link to use the prefix form.
+
+/**
+ * @see [Braces](https://example.com/a{b}c)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [Existing](https://example.com/%7C?x=1&y=2#frag)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [Percent](https://example.com/100%)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [A{B](https://example.com)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [a}b](https://example.com)
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+// Message: Expected @see link to use the prefix form.
+
+/**
+ * @see {@link https://example.com|A|B}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+// Message: Expected @see link to use the prefix form.
+
+/**
+ * @see {@link https://example.com|A`B}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+// Message: Expected @see link to use the prefix form.
+
+/**
+ * @see [a\}b](https://example.com)
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [See {{one} and {two}}](https://example.com)
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [A}B|C](https://example.com)
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [Read the guide](https://example.com/read)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [Browse the API]{@link https://example.com/api}
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [A](https://a.com) and [B](https://b.com)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [A](https://a.com)
+ * @see [B](https://b.com)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [Unsafe](https://example.com/a|b?x=1&y=2#frag) and [Safe](https://example.com/safe)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [JSDoc Markdown](https://example.com/jsdoc|markdown)
+ */
+// Settings: {"jsdoc":{"mode":"jsdoc"}}
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [Package](@scope/pkg)
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [First](https://example.com/first)
+ * and [Second](https://example.com/second)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [Example](https://example.com)
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [Guide]{@link https://example.com/guide}
+ */
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [API](https://example.com/api)
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+// Message: Expected @see link to use the prefix form.
+
+/**
+ * @see {@link https://example.com/reference|Reference}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+// Message: Expected @see link to use the prefix form.
+
+/**
+ * @see [No fix](https://example.com/no-fix)
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"enableFixer":false}]
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [Incomplete](https://example.com/incomplete
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [Incomplete]{@link}
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see {@link https://example.com|}
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see `[Code](https://example.com/code)`
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [Package]{@link @scope/pkg}
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see {@link https://example.com Space label}
+ */
+// Message: @see link cannot be safely normalized.
+
+/**
+ * @see [JSDoc]{@link https://example.com/jsdoc}
+ */
+// Settings: {"jsdoc":{"mode":"jsdoc"}}
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [TypeScript](https://example.com/type{script})
+ */
+const value: string = 'value';
+// Message: Expected @see link to use the pipe form.
+
+/**
+ * @see [Complex](https://example.com/a_(b))
+ */
+// Message: @see link cannot be safely normalized.
+````
+
+
+
+
+
+## Passing examples
+
+The following patterns are not considered problems:
+
+````ts
+/**
+ * @see {@link https://api.example.com/v1?ids=1%7C2%7C3|Docs}
+ */
+
+/**
+ * @see [Foo]{@link https://example.com/a%7Cb}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+
+/**
+ * @see [Foo]{@link https://example.com/a%7Db}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+
+/**
+ * @see [Foo]{@link https://example.com/a%7Bb}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+
+/**
+ * @see {@link https://example.com/a%7Bb%7Dc|Braces}
+ */
+
+/**
+ * @see {@link https://example.com/%7C?x=1&y=2#frag|Existing}
+ */
+
+/**
+ * @see {@link https://example.com/100%25|Percent}
+ */
+
+/**
+ * @see {@link https://example.com|A{B}
+ */
+
+/**
+ * @see [a}b]{@link https://example.com}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+
+/**
+ * @see {@link https://example.com|A|B}
+ */
+
+/**
+ * @see [A|B]{@link https://example.com}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+
+/**
+ * @see [A`B]{@link https://example.com}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+
+/**
+ * @see {@link https://example.com/a%7Cb?x=1&y=2#frag|Unsafe} and {@link https://example.com/safe|Safe}
+ */
+
+/**
+ * @see {@link https://example.com/read|Read the guide}
+ */
+
+/**
+ * @see {@link https://example.com/api|Browse the API}
+ */
+
+/**
+ * @see {@link https://a.com|A} and {@link https://b.com|B}
+ */
+
+/**
+ * @see {@link https://a.com|A}
+ * @see {@link https://b.com|B}
+ */
+
+/**
+ * @see {@link https://example.com/jsdoc%7Cmarkdown|JSDoc Markdown}
+ */
+// Settings: {"jsdoc":{"mode":"jsdoc"}}
+
+/**
+ * @see {@link https://example.com/first|First}
+ * and {@link https://example.com/second|Second}
+ */
+
+/**
+ * @see {@link https://example.com|Example}
+ */
+
+/**
+ * @see {@link https://example.com/guide|Guide}
+ */
+
+/**
+ * @see [API]{@link https://example.com/api}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+
+/**
+ * @see [Reference]{@link https://example.com/reference}
+ */
+// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}]
+
+/**
+ * @see {@link https://example.com/jsdoc|JSDoc}
+ */
+// Settings: {"jsdoc":{"mode":"jsdoc"}}
+
+/**
+ * @see {@link https://example.com/type%7Bscript%7D|TypeScript}
+ */
+const value: string = 'value';
+
+/**
+ * @see https://example.com
+ */
+
+/**
+ * @see MyClass#method
+ */
+
+/**
+ * @see some text
+ */
+
+/**
+ * @see {@link https://example.com}
+ */
+
+/**
+ * @see {@link @scope/pkg|Package}
+ */
+
+/**
+ * @see @scope/pkg
+ */
+
+/**
+ * @returns [Example](https://example.com)
+ */
+
+/**
+ * @see {@code foo|bar}
+ */
+
+/**
+ * @see \[Escaped](https://example.com)
+ */
+````
+
diff --git a/src/index-cjs.js b/src/index-cjs.js
index 96b599b99..777ca8397 100644
--- a/src/index-cjs.js
+++ b/src/index-cjs.js
@@ -36,6 +36,7 @@ import noDefaults from './rules/noDefaults.js';
import noMissingSyntax from './rules/noMissingSyntax.js';
import noMultiAsterisks from './rules/noMultiAsterisks.js';
import noRestrictedSyntax from './rules/noRestrictedSyntax.js';
+import normalizeSeeLinks from './rules/normalizeSeeLinks.js';
import noTypes from './rules/noTypes.js';
import noUndefinedTypes from './rules/noUndefinedTypes.js';
import preferImportTag from './rules/preferImportTag.js';
@@ -128,6 +129,7 @@ index.rules = {
'no-restricted-syntax': noRestrictedSyntax,
'no-types': noTypes,
'no-undefined-types': noUndefinedTypes,
+ 'normalize-see-links': normalizeSeeLinks,
'prefer-import-tag': preferImportTag,
'reject-any-type': buildRejectOrPreferRuleDefinition({
description: 'Reports use of `any` or `*` type',
@@ -318,6 +320,7 @@ const createRecommendedRuleset = (warnOrError, flatName) => {
'jsdoc/no-restricted-syntax': 'off',
'jsdoc/no-types': 'off',
'jsdoc/no-undefined-types': warnOrError,
+ 'jsdoc/normalize-see-links': 'off',
'jsdoc/prefer-import-tag': 'off',
'jsdoc/reject-any-type': warnOrError,
'jsdoc/reject-function-type': warnOrError,
diff --git a/src/index.js b/src/index.js
index b1b3c6636..660225736 100644
--- a/src/index.js
+++ b/src/index.js
@@ -42,6 +42,7 @@ import noDefaults from './rules/noDefaults.js';
import noMissingSyntax from './rules/noMissingSyntax.js';
import noMultiAsterisks from './rules/noMultiAsterisks.js';
import noRestrictedSyntax from './rules/noRestrictedSyntax.js';
+import normalizeSeeLinks from './rules/normalizeSeeLinks.js';
import noTypes from './rules/noTypes.js';
import noUndefinedTypes from './rules/noUndefinedTypes.js';
import preferImportTag from './rules/preferImportTag.js';
@@ -134,6 +135,7 @@ index.rules = {
'no-restricted-syntax': noRestrictedSyntax,
'no-types': noTypes,
'no-undefined-types': noUndefinedTypes,
+ 'normalize-see-links': normalizeSeeLinks,
'prefer-import-tag': preferImportTag,
'reject-any-type': buildRejectOrPreferRuleDefinition({
description: 'Reports use of `any` or `*` type',
@@ -324,6 +326,7 @@ const createRecommendedRuleset = (warnOrError, flatName) => {
'jsdoc/no-restricted-syntax': 'off',
'jsdoc/no-types': 'off',
'jsdoc/no-undefined-types': warnOrError,
+ 'jsdoc/normalize-see-links': 'off',
'jsdoc/prefer-import-tag': 'off',
'jsdoc/reject-any-type': warnOrError,
'jsdoc/reject-function-type': warnOrError,
diff --git a/src/rules.d.ts b/src/rules.d.ts
index 0990466e7..1d16e4d93 100644
--- a/src/rules.d.ts
+++ b/src/rules.d.ts
@@ -1292,6 +1292,22 @@ export interface Rules {
}
];
+ /** Normalizes labeled links in `@see` tags to a canonical `{@link}` form. */
+ "jsdoc/normalize-see-links":
+ | []
+ | [
+ {
+ /**
+ * The canonical `{@link}` form: `"pipe"` produces `{@link url|label}`, while `"prefix"` produces `[label]{@link url}`. Defaults to `"pipe"`.
+ */
+ canonicalForm?: "pipe" | "prefix";
+ /**
+ * Whether to enable the fixer. Defaults to `true`.
+ */
+ enableFixer?: boolean;
+ }
+ ];
+
/** Prefer `@import` tags to inline `import()` statements. */
"jsdoc/prefer-import-tag":
| []
diff --git a/src/rules/normalizeSeeLinks.js b/src/rules/normalizeSeeLinks.js
new file mode 100644
index 000000000..13b6395c2
--- /dev/null
+++ b/src/rules/normalizeSeeLinks.js
@@ -0,0 +1,283 @@
+import iterateJsdoc from '../iterateJsdoc.js';
+
+const markdownLinkRegex = /(? {
+ if (!label.trim()) {
+ return true;
+ }
+
+ if (canonicalForm === 'pipe') {
+ return label.includes('}');
+ }
+
+ return label.includes(']');
+};
+
+/**
+ * @param {string} target
+ * @returns {string}
+ */
+const encodeLinkTarget = (target) => {
+ return encodeURI(target).replaceAll(/%25(?=[\dA-F]{2})/giv, '%');
+};
+
+/**
+ * @param {string} description
+ * @param {number} index
+ * @returns {boolean}
+ */
+const isInsideMarkdownCodeSpan = (description, index) => {
+ for (const match of description.matchAll(markdownCodeSpanRegex)) {
+ const delimiterLength = match[1].length;
+ const contentStart = match.index + delimiterLength;
+ const contentEnd = match.index + match[0].length - delimiterLength;
+
+ if (index >= contentStart && index < contentEnd) {
+ return true;
+ }
+ }
+
+ return false;
+};
+
+/**
+ * @param {'pipe'|'prefix'} canonicalForm
+ * @param {string} label
+ * @param {string} target
+ * @returns {string}
+ */
+const formatLink = (canonicalForm, label, target) => {
+ const encodedTarget = encodeLinkTarget(target);
+
+ return canonicalForm === 'pipe' ?
+ `{@link ${encodedTarget}|${label.trim()}}` :
+ `[${label.trim()}]{@link ${encodedTarget}}`;
+};
+
+/**
+ * @param {string} description
+ * @param {'pipe'|'prefix'} canonicalForm
+ * @returns {string}
+ */
+const normalizeDescription = (description, canonicalForm) => {
+ const markdownNormalized = description.replaceAll(
+ new RegExp(markdownLinkRegex, 'gv'),
+ (_match, label, target) => {
+ return formatLink(canonicalForm, label, target);
+ },
+ );
+
+ if (canonicalForm === 'pipe') {
+ return markdownNormalized.replaceAll(
+ new RegExp(prefixLinkRegex, 'gv'),
+ (_match, label, target) => {
+ return formatLink(canonicalForm, label, target);
+ },
+ );
+ }
+
+ return markdownNormalized.replaceAll(
+ new RegExp(pipeLinkRegex, 'gv'),
+ (_match, target, label) => {
+ return formatLink(canonicalForm, label, target);
+ },
+ );
+};
+
+export default iterateJsdoc(({
+ context,
+ jsdoc,
+ utils,
+}) => {
+ const {
+ canonicalForm = 'pipe',
+ enableFixer = true,
+ } = context.options[0] || {};
+
+ const descriptionMatcher = canonicalForm === 'pipe' ?
+ new RegExp(`${markdownLinkRegex.source}|${prefixLinkRegex.source}`, 'v') :
+ new RegExp(`${markdownLinkRegex.source}|${pipeLinkRegex.source}`, 'v');
+
+ /** @type {import('comment-parser').Spec[]} */
+ const fixableTags = [];
+
+ for (const tag of jsdoc.tags) {
+ if (tag.tag !== 'see') {
+ continue;
+ }
+
+ const firstTokens = tag.source[0].tokens;
+ const tagDescription = String(utils.getTagDescription(tag));
+ const rawDescription = firstTokens.name ?
+ firstTokens.name + firstTokens.postName + tagDescription :
+ tagDescription;
+
+ let hasAmbiguousLink = false;
+ let hasNoncanonicalLink = false;
+
+ for (const match of rawDescription.matchAll(markdownLinkRegex)) {
+ const label = match[1];
+ const target = match[2];
+
+ if (
+ isInsideMarkdownCodeSpan(rawDescription, match.index) ||
+ scopedPackageNameRegex.test(target) ||
+ cannotSafelyFormatLink(canonicalForm, label)
+ ) {
+ hasAmbiguousLink = true;
+ } else {
+ hasNoncanonicalLink = true;
+ }
+ }
+
+ for (const inlineTag of tag.inlineTags) {
+ if (inlineTag.tag !== 'link' || !inlineTag.text) {
+ continue;
+ }
+
+ if (inlineTag.format === 'space') {
+ hasAmbiguousLink = true;
+ continue;
+ }
+
+ if (cannotSafelyFormatLink(
+ canonicalForm,
+ inlineTag.text,
+ )) {
+ hasAmbiguousLink = true;
+ continue;
+ }
+
+ if (inlineTag.format === canonicalForm) {
+ continue;
+ }
+
+ const {
+ start,
+ } = /** @type {typeof inlineTag & {start: number}} */ (inlineTag);
+
+ if (
+ isInsideMarkdownCodeSpan(rawDescription, start) ||
+ scopedPackageNameRegex.test(inlineTag.namepathOrURL)
+ ) {
+ hasAmbiguousLink = true;
+ } else {
+ hasNoncanonicalLink = true;
+ }
+ }
+
+ const unrecognizedLinkText = rawDescription
+ .replaceAll(new RegExp(markdownLinkRegex, 'gv'), '')
+ .replaceAll(new RegExp(prefixLinkRegex, 'gv'), '')
+ .replaceAll(new RegExp(pipeLinkRegex, 'gv'), '');
+
+ hasAmbiguousLink ||= markdownLinkAttemptRegex.test(unrecognizedLinkText) ||
+ prefixLinkAttemptRegex.test(unrecognizedLinkText) ||
+ pipeLinkAttemptRegex.test(unrecognizedLinkText);
+
+ if (hasAmbiguousLink) {
+ utils.reportJSDoc('@see link cannot be safely normalized.', {
+ line: tag.source[0].number,
+ }, null);
+ continue;
+ }
+
+ if (!hasNoncanonicalLink) {
+ continue;
+ }
+
+ fixableTags.push(tag);
+ }
+
+ for (const [
+ reportIdx,
+ tag,
+ ] of fixableTags.entries()) {
+ utils.reportJSDoc(
+ `Expected @see link to use the ${canonicalForm} form.`,
+ {
+ line: tag.source[0].number,
+ },
+ enableFixer && reportIdx === 0 ?
+ () => {
+ for (const fixableTag of fixableTags) {
+ const firstTokens = fixableTag.source[0].tokens;
+
+ if (firstTokens.name) {
+ firstTokens.description = firstTokens.name +
+ firstTokens.postName + firstTokens.description;
+ firstTokens.name = '';
+ firstTokens.postName = '';
+ }
+
+ for (
+ let sourceIdx = 0;
+ sourceIdx < fixableTag.source.length;
+ sourceIdx++
+ ) {
+ if (!descriptionMatcher.test(
+ fixableTag.source[sourceIdx].tokens.description,
+ )) {
+ continue;
+ }
+
+ utils.setTagDescription(
+ fixableTag,
+ descriptionMatcher,
+ (description) => {
+ return normalizeDescription(description, canonicalForm);
+ },
+ );
+ }
+ }
+ } :
+ null,
+ true,
+ );
+ }
+}, {
+ iterateAllJsdocs: true,
+ meta: {
+ docs: {
+ description: 'Normalizes labeled links in `@see` tags to a canonical `{@link}` form.',
+ url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/normalize-see-links.md#repos-sticky-header',
+ },
+ fixable: 'code',
+ schema: [
+ {
+ additionalProperties: false,
+ properties: {
+ canonicalForm: {
+ description: 'The canonical `{@link}` form: `"pipe"` produces `{@link url|label}`, while `"prefix"` produces `[label]{@link url}`. Defaults to `"pipe"`.',
+ enum: [
+ 'pipe',
+ 'prefix',
+ ],
+ type: 'string',
+ },
+ enableFixer: {
+ description: 'Whether to enable the fixer. Defaults to `true`.',
+ type: 'boolean',
+ },
+ },
+ type: 'object',
+ },
+ ],
+ type: 'suggestion',
+ },
+});
diff --git a/test/rules/assertions/normalizeSeeLinks.js b/test/rules/assertions/normalizeSeeLinks.js
new file mode 100644
index 000000000..5c1c86d6a
--- /dev/null
+++ b/test/rules/assertions/normalizeSeeLinks.js
@@ -0,0 +1,1055 @@
+import {
+ parser as typescriptEslintParser,
+} from 'typescript-eslint';
+
+export default {
+ invalid: [
+ {
+ code: `
+ /**
+ * @see [Docs](https://api.example.com/v1?ids=1|2|3)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://api.example.com/v1?ids=1%7C2%7C3|Docs}
+ */
+ `,
+ },
+ // `\}` truncates pipe-label text in @es-joy/jsdoccomment.
+ {
+ code: `
+ /**
+ * @see [See {options}](https://example.com/api)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ // `\}` truncates pipe-label text in @es-joy/jsdoccomment.
+ {
+ code: `
+ /**
+ * @see [a}b](https://example.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see [Foo](https://example.com/a|b)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the prefix form.',
+ },
+ ],
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ output: `
+ /**
+ * @see [Foo]{@link https://example.com/a%7Cb}
+ */
+ `,
+ },
+ // A blank label has no intended text the fixer can preserve.
+ {
+ code: `
+ /**
+ * @see [ ](https://example.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ // `\]` prevents prefix-form parsing in @es-joy/jsdoccomment.
+ {
+ code: `
+ /**
+ * @see {@link https://example.com|A]B}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ output: null,
+ },
+ // A blank label has no intended text the fixer can preserve.
+ {
+ code: `
+ /**
+ * @see [ ]{@link https://example.com}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see [Foo](https://example.com/a}b)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the prefix form.',
+ },
+ ],
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ output: `
+ /**
+ * @see [Foo]{@link https://example.com/a%7Db}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Foo](https://example.com/a{b)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the prefix form.',
+ },
+ ],
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ output: `
+ /**
+ * @see [Foo]{@link https://example.com/a%7Bb}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Braces](https://example.com/a{b}c)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/a%7Bb%7Dc|Braces}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Existing](https://example.com/%7C?x=1&y=2#frag)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/%7C?x=1&y=2#frag|Existing}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Percent](https://example.com/100%)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/100%25|Percent}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [A{B](https://example.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com|A{B}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [a}b](https://example.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the prefix form.',
+ },
+ ],
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ output: `
+ /**
+ * @see [a}b]{@link https://example.com}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com|A|B}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the prefix form.',
+ },
+ ],
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ output: `
+ /**
+ * @see [A|B]{@link https://example.com}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com|A\`B}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the prefix form.',
+ },
+ ],
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ output: `
+ /**
+ * @see [A\`B]{@link https://example.com}
+ */
+ `,
+ },
+ // An existing `\}` still truncates pipe-label text in the parser.
+ {
+ code: `
+ /**
+ * @see [a\\}b](https://example.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ // Nested and repeated braces still contain an unparseable pipe-label `}`.
+ {
+ code: `
+ /**
+ * @see [See {{one} and {two}}](https://example.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ // The extra pipe is safe, but the closing brace still truncates the label.
+ {
+ code: `
+ /**
+ * @see [A}B|C](https://example.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see [Read the guide](https://example.com/read)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/read|Read the guide}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Browse the API]{@link https://example.com/api}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/api|Browse the API}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [A](https://a.com) and [B](https://b.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://a.com|A} and {@link https://b.com|B}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [A](https://a.com)
+ * @see [B](https://b.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ {
+ line: 4,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://a.com|A}
+ * @see {@link https://b.com|B}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Unsafe](https://example.com/a|b?x=1&y=2#frag) and [Safe](https://example.com/safe)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/a%7Cb?x=1&y=2#frag|Unsafe} and {@link https://example.com/safe|Safe}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [JSDoc Markdown](https://example.com/jsdoc|markdown)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/jsdoc%7Cmarkdown|JSDoc Markdown}
+ */
+ `,
+ settings: {
+ jsdoc: {
+ mode: 'jsdoc',
+ },
+ },
+ },
+ {
+ code: `
+ /**
+ * @see [Package](@scope/pkg)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see [First](https://example.com/first)
+ * and [Second](https://example.com/second)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/first|First}
+ * and {@link https://example.com/second|Second}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Example](https://example.com)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com|Example}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Guide]{@link https://example.com/guide}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/guide|Guide}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [API](https://example.com/api)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the prefix form.',
+ },
+ ],
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ output: `
+ /**
+ * @see [API]{@link https://example.com/api}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/reference|Reference}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the prefix form.',
+ },
+ ],
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ output: `
+ /**
+ * @see [Reference]{@link https://example.com/reference}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [No fix](https://example.com/no-fix)
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ options: [
+ {
+ enableFixer: false,
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see [Incomplete](https://example.com/incomplete
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see [Incomplete]{@link}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com|}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see \`[Code](https://example.com/code)\`
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see [Package]{@link @scope/pkg}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com Space label}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ {
+ code: `
+ /**
+ * @see [JSDoc]{@link https://example.com/jsdoc}
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ output: `
+ /**
+ * @see {@link https://example.com/jsdoc|JSDoc}
+ */
+ `,
+ settings: {
+ jsdoc: {
+ mode: 'jsdoc',
+ },
+ },
+ },
+ {
+ code: `
+ /**
+ * @see [TypeScript](https://example.com/type{script})
+ */
+ const value: string = 'value';
+ `,
+ errors: [
+ {
+ line: 3,
+ message: 'Expected @see link to use the pipe form.',
+ },
+ ],
+ languageOptions: {
+ parser: typescriptEslintParser,
+ },
+ output: `
+ /**
+ * @see {@link https://example.com/type%7Bscript%7D|TypeScript}
+ */
+ const value: string = 'value';
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Complex](https://example.com/a_(b))
+ */
+ `,
+ errors: [
+ {
+ line: 3,
+ message: '@see link cannot be safely normalized.',
+ },
+ ],
+ output: null,
+ },
+ ],
+ valid: [
+ {
+ code: `
+ /**
+ * @see {@link https://api.example.com/v1?ids=1%7C2%7C3|Docs}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [Foo]{@link https://example.com/a%7Cb}
+ */
+ `,
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ },
+ {
+ code: `
+ /**
+ * @see [Foo]{@link https://example.com/a%7Db}
+ */
+ `,
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ },
+ {
+ code: `
+ /**
+ * @see [Foo]{@link https://example.com/a%7Bb}
+ */
+ `,
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/a%7Bb%7Dc|Braces}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/%7C?x=1&y=2#frag|Existing}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/100%25|Percent}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com|A{B}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [a}b]{@link https://example.com}
+ */
+ `,
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com|A|B}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [A|B]{@link https://example.com}
+ */
+ `,
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ },
+ {
+ code: `
+ /**
+ * @see [A\`B]{@link https://example.com}
+ */
+ `,
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/a%7Cb?x=1&y=2#frag|Unsafe} and {@link https://example.com/safe|Safe}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/read|Read the guide}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/api|Browse the API}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://a.com|A} and {@link https://b.com|B}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://a.com|A}
+ * @see {@link https://b.com|B}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/jsdoc%7Cmarkdown|JSDoc Markdown}
+ */
+ `,
+ settings: {
+ jsdoc: {
+ mode: 'jsdoc',
+ },
+ },
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/first|First}
+ * and {@link https://example.com/second|Second}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com|Example}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/guide|Guide}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see [API]{@link https://example.com/api}
+ */
+ `,
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ },
+ {
+ code: `
+ /**
+ * @see [Reference]{@link https://example.com/reference}
+ */
+ `,
+ options: [
+ {
+ canonicalForm: 'prefix',
+ },
+ ],
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/jsdoc|JSDoc}
+ */
+ `,
+ settings: {
+ jsdoc: {
+ mode: 'jsdoc',
+ },
+ },
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com/type%7Bscript%7D|TypeScript}
+ */
+ const value: string = 'value';
+ `,
+ languageOptions: {
+ parser: typescriptEslintParser,
+ },
+ },
+ {
+ code: `
+ /**
+ * @see https://example.com
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see MyClass#method
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see some text
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link https://example.com}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@link @scope/pkg|Package}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see @scope/pkg
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @returns [Example](https://example.com)
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see {@code foo|bar}
+ */
+ `,
+ },
+ {
+ code: `
+ /**
+ * @see \\[Escaped](https://example.com)
+ */
+ `,
+ },
+ ],
+};
diff --git a/test/rules/ruleNames.json b/test/rules/ruleNames.json
index 907ad5f8d..7ff8f2ab9 100644
--- a/test/rules/ruleNames.json
+++ b/test/rules/ruleNames.json
@@ -30,6 +30,7 @@
"no-restricted-syntax",
"no-types",
"no-undefined-types",
+ "normalize-see-links",
"prefer-import-tag",
"reject-any-type",
"reject-function-type",