diff --git a/apps/frontend/src/components/LinkCheckMessage.vue b/apps/frontend/src/components/LinkCheckMessage.vue
new file mode 100644
index 0000000000..d273bef595
--- /dev/null
+++ b/apps/frontend/src/components/LinkCheckMessage.vue
@@ -0,0 +1,29 @@
+
+
+
+ {{ message }}
+
+
+
+
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/license.vue b/apps/frontend/src/pages/[type]/[project]/settings/license.vue
index b4de71aee8..7fa9d972d0 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/license.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/license.vue
@@ -72,7 +72,7 @@
-
@@ -145,7 +146,8 @@
!(
current.license.friendly === 'Custom' &&
(current.license.short === '' || current.licenseUrl === '')
- )
+ ) &&
+ effectiveLicenseCheck?.severity !== 'error'
"
@reset="reset"
@save="save"
@@ -154,6 +156,7 @@
diff --git a/packages/moderation/src/data/nags/index.ts b/packages/moderation/src/data/nags/index.ts
index a66c6c0dbe..d285c11ffe 100644
--- a/packages/moderation/src/data/nags/index.ts
+++ b/packages/moderation/src/data/nags/index.ts
@@ -1,5 +1,6 @@
export * from './core'
export * from './description'
+export * from './link-checks'
export * from './links'
export * from './server-projects'
export * from './tags'
diff --git a/packages/moderation/src/data/nags/link-checks.ts b/packages/moderation/src/data/nags/link-checks.ts
new file mode 100644
index 0000000000..9250dd9aee
--- /dev/null
+++ b/packages/moderation/src/data/nags/link-checks.ts
@@ -0,0 +1,749 @@
+import {defineMessage, defineMessages, type MessageDescriptor} from "@modrinth/ui"
+import {computed, reactive, type Ref, watch} from "vue"
+
+export interface LinkCheckContext {
+ url: string | undefined
+ field: string
+
+ [key: string]: unknown
+}
+
+export interface LinkCheckResult {
+ severity: "valid" | "warn" | "error"
+ message?: MessageDescriptor
+ values?: Record
+}
+
+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}
+}
+
+type FieldMatcher = string | string[] | ((field: string, context: LinkCheckContext) => boolean)
+
+function matchesField(matcher: FieldMatcher, context: LinkCheckContext): boolean {
+ if (typeof matcher === "function") return matcher(context.field, context)
+ if (Array.isArray(matcher)) return matcher.includes(context.field)
+ return matcher === context.field
+}
+
+type LinkCheckVerify = (match: RegExpMatchArray, context: LinkCheckContext) => Promise
+type LinkCheckMatcher = RegExp | ((remaining: string) => number | null | Promise)
+
+function isAsyncMatcher(when: LinkCheckMatcher): boolean {
+ return when instanceof RegExp ? false : when.constructor.name === "AsyncFunction"
+}
+
+interface LinkCheckNode {
+ when: LinkCheckMatcher
+ label?: string
+ unrecognizedSeverity?: "error" | "warn"
+ unrecognizedMessage?: MessageDescriptor
+ forMatchers?: FieldMatcher[]
+ verify?: LinkCheckVerify
+ childNodes?: LinkCheckNode[]
+ isTransparent?: boolean
+ isFallback?: boolean
+}
+
+interface LinkCheckBuilder {
+ when: LinkCheckMatcher
+ label?: string
+
+ for(fields: FieldMatcher): LinkCheckBuilder
+
+ verify(fn: LinkCheckVerify): LinkCheckBuilder
+
+ children(...shapes: LinkCheckChildShape[]): LinkCheckBuilder
+
+ severity(value: "error" | "warn"): LinkCheckBuilder
+
+ message(descriptor: MessageDescriptor): LinkCheckBuilder
+
+ transparent(): LinkCheckBuilder
+
+ fallback(): LinkCheckBuilder
+
+ warn(message: MessageDescriptor, values?: Record): LinkCheckBuilder
+
+ error(message: MessageDescriptor, values?: Record): LinkCheckBuilder
+}
+
+type LinkCheckChildShape =
+ | LinkCheckNode
+ | LinkCheckBuilder
+ | RegExp
+ | string
+ | ((remaining: string) => number | null | Promise)
+
+function anchored(source: string): RegExp {
+ return new RegExp(`^${source}`, "i")
+}
+
+function blacklist(label: string, ...domains: string[]): LinkCheckBuilder {
+ const pattern = domains.map((domain) => domain.replace(/\./g, "\\.")).join("|")
+ return check(new RegExp(`^(?:${pattern})`, "i"), label)
+}
+
+function buildNode(when: LinkCheckMatcher, label?: string): LinkCheckBuilder {
+ const childNodes: LinkCheckNode[] = []
+ const forMatchers: FieldMatcher[] = []
+ const node: Record = {when, label, childNodes, forMatchers}
+
+ node.for = (fields: FieldMatcher) => {
+ forMatchers.push(fields)
+ return node
+ }
+ node.verify = (fn: LinkCheckVerify) => {
+ node.verify = fn
+ return node
+ }
+ node.severity = (value: "error" | "warn") => {
+ node.unrecognizedSeverity = value
+ return node
+ }
+ node.message = (descriptor: MessageDescriptor) => {
+ node.unrecognizedMessage = descriptor
+ return node
+ }
+ node.transparent = () => {
+ node.isTransparent = true
+ return node
+ }
+ node.fallback = () => {
+ node.isFallback = true
+ return node
+ }
+ node.warn = (message: MessageDescriptor, values?: Record) => {
+ node.verify = async () => warn(message, values)
+ return node
+ }
+ node.error = (message: MessageDescriptor, values?: Record) => {
+ node.verify = async () => error(message, values)
+ return node
+ }
+ node.children = (...shapes: LinkCheckChildShape[]) => {
+ const parentLabel = node.label as string | undefined
+ const parentForMatchers = node.forMatchers as FieldMatcher[] | undefined
+ for (const shape of shapes) {
+ const child = normalizeChild(shape)
+ const label = child.label ? [parentLabel, child.label].filter(Boolean).join(" ") : parentLabel
+ const inheritedFor = [...(parentForMatchers ?? []), ...(child.forMatchers ?? [])]
+ childNodes.push({...child, label, forMatchers: inheritedFor})
+ }
+ return node
+ }
+
+ return node as unknown as LinkCheckBuilder
+}
+
+function check(when: RegExp | string | ((remaining: string) => number | null | Promise), label?: string): LinkCheckBuilder {
+ const matcher = typeof when === "function" ? when : typeof when === "string" ? new RegExp(when) : when
+ return buildNode(matcher, label)
+}
+
+function fallback(label?: string): LinkCheckBuilder {
+ return buildNode(() => 0, label).fallback()
+}
+
+function normalizeChild(shape: LinkCheckChildShape): LinkCheckNode {
+ if (shape instanceof RegExp || typeof shape === "function") return {when: shape}
+ if (typeof shape === "string") return {when: new RegExp(shape)}
+ return shape as unknown as LinkCheckNode
+}
+
+function named(label: string, shapes: LinkCheckChildShape[]): LinkCheckNode[] {
+ return shapes.map((shape) => ({...normalizeChild(shape), label}))
+}
+
+interface MatchResult {
+ node: LinkCheckNode
+ match: RegExpMatchArray
+ expectedChild?: LinkCheckNode
+}
+
+async function matchNode(
+ node: LinkCheckNode,
+ remaining: string,
+ context: LinkCheckContext,
+ isRoot = false,
+): Promise {
+ let match: RegExpMatchArray | null
+ if (node.when instanceof RegExp) {
+ match = node.when.exec(remaining)
+ } else {
+ const consumed = await node.when(remaining)
+ match =
+ consumed === null
+ ? null
+ : (Object.assign([remaining.slice(0, consumed)], {input: remaining, index: 0}) as RegExpMatchArray)
+ }
+
+ if (!match) {
+ if (!isRoot || !node.unrecognizedMessage) return null
+ return {
+ node: {
+ when: node.when,
+ label: node.label,
+ unrecognizedMessage: node.unrecognizedMessage,
+ unrecognizedSeverity: node.unrecognizedSeverity,
+ },
+ match: Object.assign([remaining], {input: remaining, index: 0}) as RegExpMatchArray,
+ }
+ }
+
+ if (node.childNodes?.length) {
+ const rest = remaining.slice(match[0].length)
+ const syncChildren = node.childNodes.filter((child) => !isAsyncMatcher(child.when) && !child.isFallback)
+ const asyncChildren = node.childNodes.filter((child) => isAsyncMatcher(child.when) && !child.isFallback)
+ const fallbackChildren = node.childNodes.filter((child) => child.isFallback)
+ let expectedChild: LinkCheckNode | undefined
+ for (const child of [...syncChildren, ...asyncChildren]) {
+ const found = await matchNode(child, rest, context)
+ if (found) return found
+ if (!expectedChild && child.forMatchers?.some((matcher) => matchesField(matcher, context))) expectedChild = child
+ }
+
+ const matchingFallback = fallbackChildren.find((child) =>
+ child.forMatchers?.some((matcher) => matchesField(matcher, context)),
+ )
+ if (matchingFallback) {
+ return {node: matchingFallback, match: Object.assign([rest], {input: rest, index: 0}) as RegExpMatchArray}
+ }
+
+ if (node.isTransparent) return null
+ return {node, match, expectedChild}
+ }
+
+ return {node, match}
+}
+
+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 is not a valid URL.",
+})
+
+function validUrlPrefix(remaining: string): number | null {
+ let url: URL
+ try {
+ url = new URL(remaining)
+ const hostname = url.hostname
+
+ // https pls
+ if (url.protocol !== "https:") return null;
+
+ // ensure there's a domain and TLD
+ if (!/[^.]\.[^.]/.test(hostname)) return null;
+
+ // reserved TLDs
+ if (/(^|\.)(local|localhost|test|example|invalid|onion|arpa|home)$/i.test(hostname)) return null;
+
+ // example.com/net/org is reserved (and probably quite likely to be set by AI)
+ if (/^example\.(com|net|org)$/i.test(hostname)) return null
+
+ // No IP addresses
+ const strippedHost = hostname.replace(/^\[|]$/g, "")
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHost) || strippedHost.includes(":")) return null;
+
+ return remaining.indexOf(hostname)
+ } catch {
+ return null
+ }
+}
+
+const checks = check(validUrlPrefix).message(invalidUrlMessage).transparent()
+
+const rootNode = checks as unknown as LinkCheckNode
+
+const cache = reactive(new Map())
+
+function cacheKey(context: LinkCheckContext): string {
+ return JSON.stringify(context)
+}
+
+async function checkLink(context: LinkCheckContext) {
+ const url = context.url
+ if (!url) return
+ const key = cacheKey(context)
+ if (cache.has(key)) return
+
+ const normalizedUrl = url.replace(/^(https:\/\/)www\./i, "$1")
+
+ cache.set(key, "pending")
+
+ const found = await matchNode(rootNode, normalizedUrl, context, true)
+ if (!found) {
+ cache.delete(key)
+ return
+ }
+ const {node: matched, match, expectedChild} = found
+
+ const isLeaf = !matched.childNodes?.length
+ const applies = isLeaf && matched.forMatchers?.some((matcher) => matchesField(matcher, context))
+
+ if (!applies) {
+ const build = matched.unrecognizedSeverity === "warn" ? warn : error
+
+ if (matched.unrecognizedMessage && isLeaf) {
+ cache.set(key, build(matched.unrecognizedMessage, {label: matched.label}))
+ return
+ }
+
+ if (expectedChild) {
+ if (matched.unrecognizedMessage) {
+ cache.set(key, build(matched.unrecognizedMessage, {label: matched.label}))
+ return
+ }
+
+ cache.set(key, build(coreMessages.expectedType, {label: expectedChild.label}))
+ return
+ }
+
+ const validElsewhere = matched.forMatchers && matched.forMatchers.length > 0
+ const message = validElsewhere ? coreMessages.wrongField : coreMessages.neverValid
+ cache.set(key, build(message, {label: matched.label}))
+ return
+ }
+
+ if (!matched.verify) {
+ cache.set(key, valid)
+ return
+ }
+
+ cache.set(key, "pending")
+ matched.verify(match, context).then(
+ (result) => cache.set(key, result),
+ () => cache.delete(key),
+ )
+}
+
+function getLinkCheckState(context: LinkCheckContext): LinkCheckResult | undefined {
+ if (!context.url) return undefined
+ const state = cache.get(cacheKey(context))
+ return typeof state === "object" ? state : undefined
+}
+
+function isLinkCheckPending(context: LinkCheckContext): boolean {
+ if (!context.url) return false
+ return cache.get(cacheKey(context)) === "pending"
+}
+
+function useLinkCheck(context: Ref) {
+ let timeout: ReturnType
+ watch(
+ context,
+ (value) => {
+ clearTimeout(timeout)
+ timeout = setTimeout(() => checkLink(value), 500)
+ },
+ {deep: true, immediate: true},
+ )
+
+ return computed(() => getLinkCheckState(context.value) ?? null)
+}
+
+async function discordInviteVerify(match: RegExpMatchArray): Promise {
+ const res = await fetch(`https://discord.com/api/v10/invites/${match[1]}?with_expiration=true`)
+
+ if (!res.ok)
+ return error(
+ defineMessage({
+ id: "nags.link.discord.invite.invalid",
+ defaultMessage: "This Discord invite is invalid or has expired.",
+ }),
+ )
+
+ const invite = await res.json()
+
+ if (!invite.guild)
+ return error(
+ defineMessage({
+ id: "nags.link.discord.invite.not-guild",
+ defaultMessage: "This Discord invite does not lead to a server.",
+ }),
+ )
+
+ if (invite.expires_at)
+ return warn(
+ defineMessage({
+ id: "nags.link.discord.invite.expires",
+ defaultMessage: "This Discord invite is set to expire",
+ }),
+ )
+
+ //TODO Ideally we could also check if the invite has a max uses and if its temporary but
+ // we can't without auth which we can't really do in frontend
+
+ return valid
+}
+
+checks.children(
+ ...named("Discord", [
+ check(/^discord\.gg/i).children(check(/^\/([\w-]+)/i).for("discord").verify(discordInviteVerify)),
+ check(/^(?:discord\.com|discordapp\.com)/i).children(
+ check(/^\/invite\/([\w-]+)/i).for("discord").verify(discordInviteVerify),
+ 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 | undefined>,
+ path: string,
+ evaluate: (facts: Record) => LinkCheckResult,
+): Promise {
+ const facts = await fetchRepo(path)
+ if (!facts) return error(gitRepoMessages.notFound)
+
+ return evaluate(facts)
+}
+
+async function giteaFetchRepo(host: string, path: string): Promise | undefined> {
+ const res = await fetch(`https://${host}/api/v1/repos/${path}`)
+ if (!res.ok) return undefined
+ const data = await res.json()
+ return {empty: data.size === 0, archived: data.archived, issues: data.has_issues, wiki: data.has_wiki}
+}
+
+function gitHost(
+ name: string,
+ domain: string,
+ fetchRepo: (path: string) => Promise | undefined>,
+ 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").verify(async (match) =>
+ checkRepo(fetchRepo, match[1], (facts) => (facts.empty ? error(gitRepoMessages.empty) : valid)),
+ ),
+
+ check(anchored(`/(${path})${sep}/issues`), "issues").for("issues").verify(async (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").verify(async (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", async (path) => {
+ const res = await fetch(`https://api.github.com/repos/${path}`)
+ if (!res.ok) return undefined
+ const data = await res.json()
+ return {
+ empty: data.size === 0,
+ archived: data.archived,
+ issues: data.has_issues,
+ wiki: data.has_wiki,
+ }
+ })
+ // 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) => giteaFetchRepo("codeberg.org", path)),
+
+ gitHost(
+ "GitLab",
+ "gitlab\\.com",
+ async (path) => {
+ const res = await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(path)}`)
+ if (!res.ok) return undefined
+ //TODO unauthed gitlab doesn't give us like any info, so... yeah that sucks I guess
+ return {}
+ },
+ {
+ pathPattern: /[^/]+(?:\/[^/]+)+/.source,
+ subPageSeparator: "/-",
+ wikiPath: "wikis",
+ },
+ ),
+
+ gitHost("Bitbucket", "bitbucket\\.org", async (path) => {
+ const res = await fetch(`https://api.bitbucket.org/2.0/repositories/${path}`)
+ if (!res.ok) return undefined
+ const data = await res.json()
+ return {
+ empty: data.size === 0,
+ issues: data.has_issues,
+ wiki: data.has_wiki,
+ }
+ }),
+
+ gitHost("Gitee", "gitee\\.com", async (path) => {
+ const res = await fetch(`https://gitee.com/api/v5/repos/${path}`)
+ if (!res.ok) return undefined
+ const data = await res.json()
+ return {
+ issues: data.has_issues,
+ wiki: data.has_wiki,
+ }
+ }),
+)
+
+const giteaHostCache = new Map()
+
+async function probeGiteaHost(hostname: string): Promise {
+ if (giteaHostCache.has(hostname)) return giteaHostCache.get(hostname)!
+ try {
+ const res = await fetch(`https://${hostname}/api/v1/version`)
+ giteaHostCache.set(hostname, res.ok)
+ return res.ok
+ } catch {
+ giteaHostCache.set(hostname, false)
+ return false
+ }
+}
+
+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")
+ .verify(async (match) =>
+ checkRepo((path) => giteaFetchRepo(match[1], path), match[2], (facts) =>
+ facts.empty ? error(gitRepoMessages.empty) : valid,
+ ),
+ ),
+
+ check(/^([^/]+)\/([^/]+\/[^/]+)\/issues/i, "issues")
+ .for("issues")
+ .verify(async (match) =>
+ checkRepo((path) => giteaFetchRepo(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")
+ .verify(async (match) =>
+ checkRepo((path) => giteaFetchRepo(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(async (_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(async (match, ctx) => licenseVerify(match[1], ctx)),
+ ),
+ check(anchored("opensource\\.org"), "OSI").children(
+ check(anchored("/licenses?/([\\w.-]+)"))
+ .for("license")
+ .verify(async (match, ctx) => licenseVerify(match[1], ctx)),
+ ),
+ check(anchored("choosealicense\\.com"), "choosealicense.com").children(
+ check(anchored("/licenses/([\\w.-]+)"))
+ .for("license")
+ .verify(async (match, ctx) => licenseVerify(match[1], ctx)),
+ ),
+ check(anchored("(?:www\\.)?gnu\\.org"), "GNU").children(
+ check(anchored("/licenses/[\\w.-]+"))
+ .for("license")
+ .verify(async (_match, ctx) => licenseVerify(null, ctx)),
+ ),
+ check(anchored("(?:www\\.)?apache\\.org"), "Apache").children(
+ check(anchored("/licenses/[\\w.-]+"))
+ .for("license")
+ .verify(async (_match, ctx) => licenseVerify(null, ctx)),
+ ),
+ check(anchored("creativecommons\\.org"), "Creative Commons").children(
+ check(anchored("/(?:licenses/[\\w-]+|publicdomain/zero)/[\\d.]+/?"))
+ .for("license")
+ .verify(async (_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"),
+ ),
+);
+
+checks.children(
+ blacklist("URL Shortener", "bit.ly", "adf.ly", "tinyurl.com", "short.io", "is.gd"),
+
+ // Social Media
+ blacklist("Twitter", "twitter.com", "x.com"),
+ blacklist("Instagram", "instagram.com"),
+ blacklist("Facebook", "facebook.com"),
+ blacklist("TikTok", "tiktok.com"),
+ blacklist("Telegram", "telegram.org", "t.me"),
+ blacklist("Bilibili", "bilibili.com"),
+ blacklist("Bluesky", "bsky.app"),
+ blacklist("Twitch", "twitch.tv"),
+ blacklist("Reddit", "reddit.com", "redd.it"),
+
+ // Minecraft
+ blacklist("Modrinth", "modrinth.com"),
+ blacklist("Minecraft", "minecraft.net"),
+ //TODO we should probably setup curseforge/planetminecraft issues for issues but im too lazy to do that rn
+ blacklist("Mod Distribution Platform", "curseforge.com", "planetminecraft.com", "9minecraft.net", "mcmod.cn"),
+
+ blacklist("AI Mod Generation Platform", "creativemode.net", "orcaclient.com", "autoforged.cn")
+)
+
+export {checkLink, getLinkCheckState, isLinkCheckPending, useLinkCheck}
diff --git a/packages/ui/src/components/base/UnsavedChangesPopup.vue b/packages/ui/src/components/base/UnsavedChangesPopup.vue
index c997b965a5..46014ad123 100644
--- a/packages/ui/src/components/base/UnsavedChangesPopup.vue
+++ b/packages/ui/src/components/base/UnsavedChangesPopup.vue
@@ -18,6 +18,7 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
canReset?: boolean
+ canSave?: boolean
original: T
modified: Partial
saving?: boolean
@@ -29,6 +30,7 @@ const props = withDefaults(
}>(),
{
canReset: true,
+ canSave: true,
saving: false,
text: () =>
defineMessage({
@@ -66,7 +68,7 @@ defineExpose({ nudge })
-