import { checkDiscordInvite, fetchBitbucketRepo, fetchGiteaRepo, fetchGiteeRepo, fetchGitHubRepo, fetchGitLabRepo, type GitRepoFacts, probeGiteaHost, } from './remote-checks.ts' import { anchored, check, fallback, getBlockedProjectContentLink, getBlockedProjectExternalLink, hasFieldSpecificDescendant, matchesField, matchNode, matchNodeSyntax, named, validUrlPrefix, } from './syntax-checks.ts' import type { LinkCheckBuilder, LinkCheckContext, LinkCheckNode, LinkCheckResult, MatchResult, MessageDescriptor, } from './types.ts' function defineMessage(descriptor: T): T { return descriptor } function defineMessages>(descriptors: T): T { return descriptors } const valid: LinkCheckResult = { severity: 'valid' } function warn(message: MessageDescriptor, values?: Record): LinkCheckResult { return { severity: 'warn', message, values } } function error(message: MessageDescriptor, values?: Record): LinkCheckResult { return { severity: 'error', message, values } } const coreMessages = defineMessages({ wrongField: { id: 'nags.link.wrong-field', defaultMessage: "{label} links aren't valid for this field.", }, neverValid: { id: 'nags.link.never-valid', defaultMessage: "{label} links aren't allowed here.", }, expectedType: { id: 'nags.link.expected-type', defaultMessage: "This isn't a valid {label} link.", }, }) //TODO: we should probably just let you not provide https but backend currently requires it const invalidUrlMessage = defineMessage({ id: 'nags.link.invalid-url', defaultMessage: 'This URL is invalid', }) const invalidDescriptionUrlMessage = defineMessage({ id: 'nags.link.description.invalid-url', defaultMessage: 'The description has an invalid link', }) const checks = check(validUrlPrefix).message(invalidUrlMessage).transparent() const rootNode = checks as unknown as LinkCheckNode type PreparedLinkValidation = | LinkCheckResult | (() => LinkCheckResult | Promise) | undefined function prepareMatchedLinkValidation( context: LinkCheckContext, found: MatchResult, includeRemoteChecks: boolean, ): PreparedLinkValidation { const { node: matched, match, expectedChild } = found const isLeaf = !matched.childNodes?.length const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context)) if (!applies) { if (context.generalContent && hasFieldSpecificDescendant(matched)) return valid const build = matched.unrecognizedSeverity === 'warn' ? warn : error if (matched.unrecognizedMessage && isLeaf) { const message = context.field === 'description' && matched.unrecognizedMessage.id === invalidUrlMessage.id ? invalidDescriptionUrlMessage : matched.unrecognizedMessage return build(message, { label: matched.label }) } if (expectedChild) { if (matched.unrecognizedMessage) { return build(matched.unrecognizedMessage, { label: matched.label }) } return build(coreMessages.expectedType, { label: expectedChild.label }) } const validElsewhere = matched.forMatchers && matched.forMatchers.length > 0 const message = validElsewhere ? coreMessages.wrongField : coreMessages.neverValid return build(message, { label: matched.label }) } if (!matched.verifyMatch || (matched.isRemoteVerification && !includeRemoteChecks)) return valid return () => matched.verifyMatch!(match, context) } function getBlockedLinkResult(context: LinkCheckContext): LinkCheckResult | undefined { const url = context.url if (!url) return const blockedLink = context.generalContent ? getBlockedProjectContentLink(url) : getBlockedProjectExternalLink(url) return blockedLink ? error(coreMessages.neverValid, { label: blockedLink.label }) : undefined } export function validateLinkSyntax(context: LinkCheckContext): LinkCheckResult | undefined { const url = context.url if (!url) return const blockedResult = getBlockedLinkResult(context) if (blockedResult) return blockedResult const normalizedUrl = url.replace(/^(https:\/\/)www\./i, '$1') const found = matchNodeSyntax(rootNode, normalizedUrl, context, true) if (!found) return context.generalContent && validUrlPrefix(normalizedUrl) !== null ? valid : undefined const prepared = prepareMatchedLinkValidation(context, found, false) if (typeof prepared !== 'function') return prepared const result = prepared() return result instanceof Promise ? undefined : result } export async function validateLink( context: LinkCheckContext, ): Promise { const url = context.url if (!url) return const blockedResult = getBlockedLinkResult(context) if (blockedResult) return blockedResult const normalizedUrl = url.replace(/^(https:\/\/)www\./i, '$1') const found = await matchNode(rootNode, normalizedUrl, context, true) if (!found) { return context.generalContent && validUrlPrefix(normalizedUrl) !== null ? valid : undefined } const prepared = prepareMatchedLinkValidation(context, found, true) if (typeof prepared !== 'function') return prepared try { return await prepared() } catch { return undefined } } checks.children( ...named('Discord', [ check(/^discord\.gg/i).children( check(/^\/([\w-]+)/i) .for('discord') .verifyRemotely((match) => checkDiscordInvite(match[1])), ), check(/^(?:discord\.com|discordapp\.com)/i).children( check(/^\/invite\/([\w-]+)/i) .for('discord') .verifyRemotely((match) => checkDiscordInvite(match[1])), check(/^\/channels\//i).message( defineMessage({ id: 'nags.link.discord.channel', defaultMessage: 'This is a link to a Discord channel, not a server invite.', }), ), check(/^\/users\//i).message( defineMessage({ id: 'nags.link.discord.user', defaultMessage: 'This is a link to a Discord user, not a server invite.', }), ), ), ]), ) const gitRepoMessages = defineMessages({ notFound: { id: 'nags.link.git.not-found', defaultMessage: 'This repository could not be found (it may be private or deleted).', }, empty: { id: 'nags.link.git.empty', defaultMessage: 'This repository appears to be empty.', }, archived: { id: 'nags.link.git.archived', defaultMessage: 'This repository is archived, which disables issues.', }, issuesDisabled: { id: 'nags.link.git.issues-disabled', defaultMessage: 'Issues are disabled on this repository.', }, wikiDisabled: { id: 'nags.link.git.wiki-disabled', defaultMessage: 'The wiki is disabled on this repository.', }, }) async function checkRepo( fetchRepo: (path: string) => Promise, path: string, evaluate: (facts: GitRepoFacts) => LinkCheckResult, ): Promise { const facts = await fetchRepo(path) if (!facts) return error(gitRepoMessages.notFound) return evaluate(facts) } function gitHost( name: string, domain: string, fetchRepo: (path: string) => Promise, options: { pathPattern?: string subPageSeparator?: string wikiPath?: string } = {}, ): LinkCheckBuilder { const path = options.pathPattern ?? /[^/]+\/[^/]+/.source const sep = options.subPageSeparator ?? '' const wikiPath = options.wikiPath ?? 'wiki' return check(anchored(domain), name) .severity('warn') .children( check(anchored(`/(${path})/?$`), 'repo') .for('source') .verifyRemotely((match) => checkRepo(fetchRepo, match[1], (facts) => facts.empty ? error(gitRepoMessages.empty) : valid, ), ), check(anchored(`/(${path})${sep}/issues`), 'issues') .for('issues') .verifyRemotely((match) => checkRepo(fetchRepo, match[1], (facts) => { if (facts.archived) return error(gitRepoMessages.archived) if (facts.issues === false) return error(gitRepoMessages.issuesDisabled) return valid }), ), check(anchored(`/(${path})${sep}/${wikiPath}`), 'wiki') .for('wiki') .verifyRemotely((match) => checkRepo(fetchRepo, match[1], (facts) => facts.wiki === false ? error(gitRepoMessages.wikiDisabled) : valid, ), ), ) } // Repo Platforms, includes most source/issues/wiki + Github Sponsor checks.children( gitHost('GitHub', 'github\\.com', fetchGitHubRepo) // Github sponsor is here //TODO: we can't actually check if sponsors is setup with auth or cuz of cors im not really sure but regardless it doesn't works from browser .children(check(/^\/sponsors\/[^/]+/i, 'sponsors').for('github')), gitHost('Codeberg', 'codeberg\\.org', (path) => fetchGiteaRepo('codeberg.org', path)), gitHost('GitLab', 'gitlab\\.com', fetchGitLabRepo, { pathPattern: /[^/]+(?:\/[^/]+)+/.source, subPageSeparator: '/-', wikiPath: 'wikis', }), gitHost('Bitbucket', 'bitbucket\\.org', fetchBitbucketRepo), gitHost('Gitee', 'gitee\\.com', fetchGiteeRepo), ) checks.children( check(async (remaining) => { const hostMatch = /^[^/]+/.exec(remaining) if (!hostMatch) return null return (await probeGiteaHost(hostMatch[0])) ? 0 : null }, 'Self-hosted Gitea/Forgejo') .severity('warn') .children( check(/^([^/]+)\/([^/]+\/[^/]+)\/?$/i, 'repo') .for('source') .verifyRemotely((match) => checkRepo( (path) => fetchGiteaRepo(match[1], path), match[2], (facts) => (facts.empty ? error(gitRepoMessages.empty) : valid), ), ), check(/^([^/]+)\/([^/]+\/[^/]+)\/issues/i, 'issues') .for('issues') .verifyRemotely((match) => checkRepo( (path) => fetchGiteaRepo(match[1], path), match[2], (facts) => { if (facts.archived) return error(gitRepoMessages.archived) if (facts.issues === false) return error(gitRepoMessages.issuesDisabled) return valid }, ), ), check(/^([^/]+)\/([^/]+\/[^/]+)\/wiki/i, 'wiki') .for('wiki') .verifyRemotely((match) => checkRepo( (path) => fetchGiteaRepo(match[1], path), match[2], (facts) => (facts.wiki === false ? error(gitRepoMessages.wikiDisabled) : valid), ), ), ), ) // Donation checks.children( check(/^patreon\.com/i, 'Patreon').children(check(/^\/(?:user\?u=\d+|[\w.-]+)/i).for('patreon')), check(/^(?:buymeacoffee\.com|buymeacoff\.ee)/i, 'Buy Me a Coffee').children( check(/^\/([\w-]+)/i).for('bmac'), ), check(/^paypal\.[a-z.]{2,}/i, 'PayPal') .for('paypal') .children( check(/^\/paypalme\/[\w.-]+/i), check(/^\/donate/i), check(/^\/cgi-bin\/webscr\?cmd=_donations/i), ), check(/^paypal\.me/i, 'PayPal').children(check(/^\/([\w.-]+)/i).for('paypal')), // Github sponsor is with the rest of github. check(/^ko-fi\.com/i, 'Ko-fi').children(check(/^\/([\w-]+)/i).for('ko-fi')), (() => { const YOUTUBE_CHANNEL = '(?:@[\\w.-]+|channel/[\\w-]+|c/[\\w-]+|user/[\\w-]+)' return check(/^(?:youtube\.com|youtu\.be)/i, 'YouTube') .message( defineMessage({ id: 'nags.link.youtube.unrecognized', defaultMessage: "This doesn't look like a YouTube donation link.", }), ) .for('other') .children( check(anchored(`/${YOUTUBE_CHANNEL}/join`)), check(anchored(`/${YOUTUBE_CHANNEL}/store`)), ) })(), ) //TODO: remove this if/when we move this to the backend as we can know this if its backend // tho actually we will probably still need it even then if we're fine with non immediate redirects // we at the very least need to reword it in that case idk man checks.children( fallback('Unrecognized redirect link') .for(['discord', 'github', 'patreon', 'ko-fi', 'paypal', 'bmac']) .verify((_match, context) => warn( defineMessage({ id: 'nags.link.unverifiable-redirect', defaultMessage: "This doesn't look like a {platform} link.", }), { platform: context.platformName ?? context.field }, ), ), ) const licenseCheckMessages = defineMessages({ urlMismatch: { id: 'nags.link.license.url-mismatch', defaultMessage: 'This link points to the {detected} license, but your project is set to {selected}.', }, urlRedundant: { id: 'nags.link.license.url-redundant', defaultMessage: "You don't need to link to a generic license page for a supported license — consider linking to your repository's own license file instead, or leaving this blank.", }, }) function licenseVerify(detected: string | null, context: Record): LinkCheckResult { const expectedLicense = context.expectedLicense as string | undefined const isCustom = context.isCustom as boolean | undefined if (detected && expectedLicense && !isCustom) { return detected.toLowerCase() === expectedLicense.toLowerCase() ? valid : warn(licenseCheckMessages.urlMismatch, { detected, selected: expectedLicense }) } return isCustom ? valid : warn(licenseCheckMessages.urlRedundant) } checks.children( check(anchored('spdx\\.org'), 'SPDX').children( check(anchored('/licenses/([\\w.-]+)\\.html')) .for('license') .verify((match, ctx) => licenseVerify(match[1], ctx)), ), check(anchored('opensource\\.org'), 'OSI').children( check(anchored('/licenses?/([\\w.-]+)')) .for('license') .verify((match, ctx) => licenseVerify(match[1], ctx)), ), check(anchored('choosealicense\\.com'), 'choosealicense.com').children( check(anchored('/licenses/([\\w.-]+)')) .for('license') .verify((match, ctx) => licenseVerify(match[1], ctx)), ), check(anchored('(?:www\\.)?gnu\\.org'), 'GNU').children( check(anchored('/licenses/[\\w.-]+')) .for('license') .verify((_match, ctx) => licenseVerify(null, ctx)), ), check(anchored('(?:www\\.)?apache\\.org'), 'Apache').children( check(anchored('/licenses/[\\w.-]+')) .for('license') .verify((_match, ctx) => licenseVerify(null, ctx)), ), check(anchored('creativecommons\\.org'), 'Creative Commons').children( check(anchored('/(?:licenses/[\\w-]+|publicdomain/zero)/[\\d.]+/?')) .for('license') .verify((_match, ctx) => licenseVerify(null, ctx)), ), ) // Google Forms for issues and Docs for Wiki checks.children( check(/^docs\.google\.com/i, 'Google').children( check(/^\/forms\//i, 'Forms').for('issues'), check(/^\/document\//i, 'Documents').for('wiki'), ), )