blob: a62427f9d5fe38077ea471111e7ff9746190375f [file]
/* This file is a part of @mdn/browser-compat-data
* See LICENSE file for more information. */
import { styleText } from 'node:util';
import { IS_WINDOWS, indexToPos, indexToPosRaw } from '../utils.js';
/** @import {Linter, LinterData} from '../types.js' */
/** @import {Logger} from '../utils.js' */
/**
* @typedef {object} LinkError
* @property {string} issue
* @property {[number, number] | [null, null]} pos
* @property {string} posString
* @property {string} actual
* @property {string} [expected]
*/
/**
* Given a RegEx expression, test the link for errors
* @param {LinkError[]} errors The errors object to push the new errors to
* @param {string} actual The link to test
* @param {string | RegExp} regexp The regex to test with
* @param {(match: RegExpMatchArray) => Promise<{ issue: string; expected?: string; actualLink?: string; } | null>} matchHandler The callback
* @returns {Promise<void>}
*/
const processLink = async (errors, actual, regexp, matchHandler) => {
const re = new RegExp(regexp, 'g');
let match;
while ((match = re.exec(actual)) !== null) {
const pos = indexToPosRaw(actual, match.index);
const posString = indexToPos(actual, match.index);
const result = await matchHandler(match);
if (result) {
const { issue, expected, actualLink = match[0] } = result;
errors.push({
issue: issue,
pos: pos,
posString: posString,
actual: actualLink,
expected: expected,
});
}
}
};
/** @type {Map<string, string>} */
const CRBUG_CACHE = new Map();
/**
* Resolves legacy Chrome bugs from the Monorail era.
* @param {string} oldId the old bug id.
* @returns {Promise<string>} the new bug id.
*/
const resolveCrbug = async (oldId) => {
if (oldId.length >= 8) {
// This isn't an old id.
return oldId;
}
let newId = CRBUG_CACHE.get(oldId);
if (newId) {
return newId;
}
const res = await fetch(`https://crbug.com/${oldId}`);
const text = await res.text();
const match = text.match(/https:\/\/issues.chromium.org\/(\d{8,})/);
newId = match ? match[1] : oldId;
CRBUG_CACHE.set(oldId, newId);
return newId;
};
/**
* Process the data for any errors within the links
* @param {string} rawData The raw contents of the file to test
* @returns {Promise<LinkError[]>} A list of errors found in the links
*/
export const processData = async (rawData) => {
/** @type {LinkError[]} */
const errors = [];
let actual = rawData;
// prevent false positives from git.core.autocrlf on Windows
/* c8 ignore start */
if (IS_WINDOWS) {
actual = actual.replace(/\r/g, '');
}
/* c8 ignore stop */
await processLink(
// use https://bugzil.la/1000000 instead
errors,
actual,
/https?:\/\/bugzilla\.mozilla\.org\/show_bug\.cgi\?id=(\d+)/g,
async (match) => ({
issue: 'Use shortenable URL',
expected: `https://bugzil.la/${match[1]}`,
}),
);
await processLink(
// use https://crbug.com/100000 instead
errors,
actual,
/https?:\/\/(issues\.chromium\.org)\/issues\/(\d+)/g,
async (match) => ({
issue: 'Use shortenable URL',
expected: `https://crbug.com/${match[2]}`,
}),
);
await processLink(
// use https://crbug.com/100000 instead
errors,
actual,
/https?:\/\/(bugs\.chromium\.org|code\.google\.com)\/p\/chromium\/issues\/detail\?id=(\d+)/g,
async (match) => ({
issue: 'Use shortenable URL',
expected: `https://crbug.com/${match[2]}`,
}),
);
await processLink(
// use https://crbug.com/category/100000 instead
errors,
actual,
/https?:\/\/(bugs\.chromium\.org|code\.google\.com)\/p\/((?!chromium)\w+)\/issues\/detail\?id=(\d+)/g,
async (match) => ({
issue: 'Use shortenable URL',
expected: `https://crbug.com/${match[2]}/${match[3]}`,
}),
);
await processLink(
// use https://crbug.com/category/100000 instead
errors,
actual,
/https?:\/\/chromium\.googlesource\.com\/chromium\/src\/\+\/([\w\d]+)/g,
async (match) => ({
issue: 'Use shortenable URL',
expected: `https://crrev.com/${match[1]}`,
}),
);
await processLink(
// use https://crbug.com/400000000 instead
errors,
actual,
/https?:\/\/crbug.com\/(\d{1,7})(?!\d)(?:#c(\d+))?/g,
async (match) => ({
issue: 'Use new Google Issue ID',
expected: `https://crbug.com/${await resolveCrbug(match[1])}${match[2] ? `#comment${Number(match[2]) + 1}` : ''}`,
}),
);
await processLink(
// use https://webkit.org/b/100000 instead
errors,
actual,
/https?:\/\/bugs\.webkit\.org\/show_bug\.cgi\?id=(\d+)/g,
async (match) => ({
issue: 'Use shortenable URL',
expected: `https://webkit.org/b/${match[1]}`,
}),
);
await processLink(
// Bug links should use HTTPS and have "bug ###" as link text ("Bug ###" only at the beginning of notes/sentences).
errors,
actual,
/(\w*\s?)\[([^[\]]*)\]\(((https?):\/\/(bugzil\.la|crbug\.com|webkit\.org\/b)\/(\d+))\)/g,
async (match) => {
const [, before, linkText, url, protocol, domain, bugId] = match;
if (protocol !== 'https') {
return {
issue: 'Use HTTPS for bug links',
expected: `https://${domain}/${bugId}`,
actualLink: url,
};
}
if (/^bug $/.test(before)) {
return {
issue: 'Move word "bug" into link text',
expected: `[${before}${bugId}](${url})`,
actualLink: `${before}[${linkText}](${url})`,
};
} else if (linkText === `Bug ${bugId}`) {
if (!/(\. |")$/.test(before)) {
return {
issue: 'Use lowercase "bug" word within sentence',
expected: `bug ${bugId}`,
actualLink: `Bug ${bugId}`,
};
}
} else if (linkText !== `bug ${bugId}`) {
return {
issue: 'Use standard link text',
expected: `bug ${bugId}`,
actualLink: linkText,
};
}
return null;
},
);
await processLink(
errors,
actual,
/(https?):\/\/((?:[a-z][a-z0-9-]*\.)*)?developer.mozilla.org\/(.*?)(?=["'\s])/g,
async (match) => {
const [, protocol, subdomain, path] = match;
if (protocol !== 'https') {
return {
issue: 'Use HTTPS MDN URL',
expected: `https://developer.mozilla.org/${path}`,
};
}
if (subdomain) {
return {
issue: 'Use correct MDN domain',
expected: `https://developer.mozilla.org/${path}`,
};
}
if (!path.startsWith('docs/')) {
const pathMatch = /^(?:(\w\w(?:-\w\w)?)\/)?(.*)$/.exec(path);
if (pathMatch) {
return {
issue: 'Use non-localized MDN URL',
expected: `https://developer.mozilla.org/${pathMatch[2]}`,
};
}
return {
issue: 'MDN URL is invalid',
};
}
return null;
},
);
await processLink(
errors,
actual,
/https?:\/\/developer.microsoft.com\/(\w\w-\w\w)\/(.*?)(?=["'\s])/g,
async (match) => ({
issue: 'Use non-localized Microsoft Developer URL',
expected: `https://developer.microsoft.com/${match[2]}`,
}),
);
await processLink(
errors,
actual,
/\[((?:.(?<!\]))*.)\]\(([^)]+)\)/g,
async (match) => {
if (new URL(match[2]).hostname === null) {
return {
issue: 'Include hostname in URL',
actualLink: match[2],
expected: `https://developer.mozilla.org/${match[2]}`,
};
}
return null;
},
);
return errors;
};
/** @type {Linter} */
export default {
name: 'Links',
description:
'Test links in the file to ensure they conform to BCD guidelines',
scope: 'file',
/**
* Test the data
* @param {Logger} logger The logger to output errors to
* @param {LinterData} root The data to test
*/
check: async (logger, { rawdata = '' }) => {
const errors = await processData(rawdata);
for (const error of errors) {
logger.error(
`${error.posString} ${error.issue} (${styleText('yellow', error.actual)} ${styleText('green', error.expected ?? '')}).`,
{ fixable: true },
);
}
},
};