UNPKG

eslint-plugin-jsdoc

Version:
368 lines (318 loc) 10.4 kB
import iterateJsdoc from '../iterateJsdoc.js'; const markdownLinkRegex = /(?<!\\)\[((?:[^\\`\]\r\n]|\\[^\r\n])+)\]\(([^`\(\)\s]+)\)/gv; const prefixLinkRegex = /(?<!\\)\[((?:[^\\\]\r\n]|\\[^\r\n])+)\]\{@link\s+([^\}\s\|]+)\}/gv; const pipeLinkRegex = /\{@link\s+([^\}\s\|]+)\s*\|\s*((?:[^\\\}\r\n]|\\[^\r\n])+)\}/gv; const markdownLinkAttemptRegex = /(?<!\\)\[[^\]\r\n]*\]\([^\r\n]*(?:\)|$)/v; const prefixLinkAttemptRegex = /(?<!\\)\[[^\]\r\n]*\]\{@link[^\}\r\n]*(?:\}|$)/v; const pipeLinkAttemptRegex = /\{@link[^\}\r\n]*\|[^\}\r\n]*(?:\}|$)/v; const scopedPackageNameRegex = /^@[\w.\-]+\/[\w.\-]+/v; const markdownCodeSpanRegex = /(?<!\\)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/gv; const bareHttpUrlRegex = /^https?:\S+$/v; /** * @param {string} label * @returns {boolean} */ const cannotSafelyFormatLink = (label) => { return !label.trim(); }; /** * @param {'pipe'|'prefix'} canonicalForm * @param {string} label * @param {'markdown'|'pipe'|'prefix'} sourceForm * @returns {string} */ const escapeLinkLabel = (canonicalForm, label, sourceForm) => { const sourceEscapePattern = sourceForm === 'pipe' ? /\\([\\\}])/gv : (sourceForm === 'prefix' ? /\\([\\\]])/gv : /\\([\\\}\]])/gv); const targetEscapePattern = canonicalForm === 'pipe' ? /\\(?=$|[\\\}])|\}/gv : /\\(?=$|[\\\]])|\]/gv; const decodedLabel = label.trim().replaceAll(sourceEscapePattern, '$1'); return decodedLabel.replaceAll(targetEscapePattern, (character) => { return `\\${character}`; }); }; /** * @param {string} target * @returns {string} */ const encodeLinkTarget = (target) => { return encodeURI(target).replaceAll(/%25(?=[\dA-F]{2})/giv, '%'); }; /** * @param {string} description * @param {boolean} enabled * @returns {string|null} */ const getBareHttpUrl = (description, enabled) => { if (!enabled) { return null; } const trimmedDescription = description.trim(); return bareHttpUrlRegex.test(trimmedDescription) && URL.canParse(trimmedDescription) ? trimmedDescription : null; }; /** * @param {string} target * @returns {string} */ const formatBareUrl = (target) => { return `{@link ${encodeLinkTarget(target)}}`; }; /** * @param {string|undefined} bareUrl * @param {'pipe'|'prefix'} canonicalForm * @returns {string} */ const getExpectedMessage = (bareUrl, canonicalForm) => { return bareUrl ? 'Expected bare @see URL to use a {@link} tag.' : `Expected @see link to use the ${canonicalForm} form.`; }; /** * @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 {'markdown'|'pipe'|'prefix'} sourceForm * @param {string} target * @returns {string} */ const formatLink = (canonicalForm, label, sourceForm, target) => { const encodedTarget = encodeLinkTarget(target); const escapedLabel = escapeLinkLabel(canonicalForm, label, sourceForm); return canonicalForm === 'pipe' ? `{@link ${encodedTarget}|${escapedLabel}}` : `[${escapedLabel}]{@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, 'markdown', target); }, ); if (canonicalForm === 'pipe') { return markdownNormalized.replaceAll( new RegExp(prefixLinkRegex, 'gv'), (_match, label, target) => { return formatLink(canonicalForm, label, 'prefix', target); }, ); } return markdownNormalized.replaceAll( new RegExp(pipeLinkRegex, 'gv'), (_match, target, label) => { return formatLink(canonicalForm, label, 'pipe', target); }, ); }; export default iterateJsdoc(({ context, jsdoc, utils, }) => { const { canonicalForm = 'pipe', enableFixer = true, wrapBareUrls = false, } = 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 = []; /** @type {Map<import('comment-parser').Spec, string>} */ const bareUrlTags = new Map(); 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; const bareUrl = getBareHttpUrl(rawDescription, wrapBareUrls); if (bareUrl) { bareUrlTags.set(tag, bareUrl); hasNoncanonicalLink = true; } for (const match of rawDescription.matchAll(markdownLinkRegex)) { const label = match[1]; const target = match[2]; if ( isInsideMarkdownCodeSpan(rawDescription, match.index) || scopedPackageNameRegex.test(target) || cannotSafelyFormatLink(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(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()) { const bareUrl = bareUrlTags.get(tag); utils.reportJSDoc( getExpectedMessage(bareUrl, canonicalForm), { 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 = ''; } const fixableBareUrl = bareUrlTags.get(fixableTag); if (fixableBareUrl) { for (const source of fixableTag.source) { if (!source.tokens.description.includes(fixableBareUrl)) { continue; } source.tokens.description = source.tokens.description.replace( fixableBareUrl, () => { return formatBareUrl(fixableBareUrl); }, ); break; } continue; } 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', }, wrapBareUrls: { 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`.', type: 'boolean', }, }, type: 'object', }, ], type: 'suggestion', }, });