diff options
Diffstat (limited to 'src/util/external-links.js')
-rw-r--r-- | src/util/external-links.js | 669 |
1 files changed, 438 insertions, 231 deletions
diff --git a/src/util/external-links.js b/src/util/external-links.js index 8ab8dec..3b779af 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -1,8 +1,10 @@ -import {empty, stitchArrays} from '#sugar'; +import {empty, stitchArrays, withEntries} from '#sugar'; import { anyOf, is, + isBoolean, + isObject, isStringNonEmpty, looseArrayOf, optional, @@ -13,9 +15,8 @@ import { } from '#validators'; export const externalLinkStyles = [ - 'normal', - 'compact', 'platform', + 'handle', 'icon-id', ]; @@ -86,25 +87,26 @@ export const isExternalLinkSpec = }), platform: isStringNonEmpty, - substring: optional(isStringNonEmpty), - - // TODO: Don't allow 'handle' or 'custom' options if the corresponding - // properties aren't provided - normal: optional(is('domain', 'handle', 'custom')), - compact: optional(is('domain', 'handle', 'custom')), - icon: optional(isStringNonEmpty), handle: optional(isExternalLinkExtractSpec), - // TODO: This should validate each value with isExternalLinkExtractSpec. - custom: optional(validateAllPropertyValues(isExternalLinkExtractSpec)), + detail: + optional(anyOf( + isStringNonEmpty, + validateProperties({ + [validateProperties.validateOtherKeys]: + isExternalLinkExtractSpec, + + substring: isStringNonEmpty, + }))), + + unusualDomain: optional(isBoolean), + + icon: optional(isStringNonEmpty), })); export const fallbackDescriptor = { platform: 'external', - - normal: 'domain', - compact: 'domain', icon: 'globe', }; @@ -120,7 +122,7 @@ export const externalLinkSpec = [ }, platform: 'youtube', - substring: 'playlist', + detail: 'playlist', icon: 'youtube', }, @@ -133,7 +135,7 @@ export const externalLinkSpec = [ }, platform: 'youtube', - substring: 'fullAlbum', + detail: 'fullAlbum', icon: 'youtube', }, @@ -145,45 +147,11 @@ export const externalLinkSpec = [ }, platform: 'youtube', - substring: 'fullAlbum', + detail: 'fullAlbum', icon: 'youtube', }, - // Special handling for artist links - - { - match: { - domain: 'patreon.com', - context: 'artist', - }, - - platform: 'patreon', - - normal: 'handle', - compact: 'handle', - icon: 'globe', - - handle: /([^/]*)\/?$/, - }, - - { - match: { - context: 'artist', - domain: 'youtube.com', - }, - - platform: 'youtube', - - normal: 'handle', - compact: 'handle', - icon: 'youtube', - - handle: { - pathname: /^(@.*?)\/?$/, - }, - }, - // Special handling for flash links { @@ -193,7 +161,7 @@ export const externalLinkSpec = [ }, platform: 'bgreco', - substring: 'flash', + detail: 'flash', icon: 'globe', }, @@ -203,20 +171,16 @@ export const externalLinkSpec = [ match: { context: 'flash', domain: 'homestuck.com', - pathname: /^story\/[0-9]+\/?$/, }, platform: 'homestuck', - substring: 'page', - - normal: 'custom', - icon: 'globe', - custom: { - page: { - pathname: /[0-9]+/, - }, + detail: { + substring: 'page', + page: {pathname: /^story\/([0-9]+)\/?$/,}, }, + + icon: 'globe', }, { @@ -227,7 +191,7 @@ export const externalLinkSpec = [ }, platform: 'homestuck', - substring: 'secretPage', + detail: 'secretPage', icon: 'globe', }, @@ -239,7 +203,7 @@ export const externalLinkSpec = [ }, platform: 'youtube', - substring: 'flash', + detail: 'flash', icon: 'youtube', }, @@ -247,12 +211,36 @@ export const externalLinkSpec = [ // Generic domains, sorted alphabetically (by string) { + match: {domain: 'music.apple.com'}, + platform: 'appleMusic', + icon: 'appleMusic', + }, + + { + match: {domain: 'artstation.com'}, + + platform: 'artstation', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'artstation', + }, + + { + match: {domain: '.artstation.com'}, + + platform: 'artstation', + handle: {domain: /^[^.]+/}, + + icon: 'artstation', + }, + + { match: {domains: ['bc.s3m.us', 'music.solatrus.com']}, platform: 'bandcamp', + handle: {domain: /.+/}, + unusualDomain: true, - normal: 'domain', - compact: 'domain', icon: 'bandcamp', }, @@ -260,11 +248,36 @@ export const externalLinkSpec = [ match: {domain: '.bandcamp.com'}, platform: 'bandcamp', + handle: {domain: /^[^.]+/}, - compact: 'handle', icon: 'bandcamp', + }, + + { + match: {domain: 'bsky.app'}, + + platform: 'bluesky', + handle: {pathname: /^profile\/([^/]+?)(?:\.bsky\.social)?\/?$/}, - handle: {domain: /^[^.]*/}, + icon: 'bluesky', + }, + + { + match: {domain: '.carrd.co'}, + + platform: 'carrd', + handle: {domain: /^[^.]+/}, + + icon: 'carrd', + }, + + { + match: {domain: 'cohost.org'}, + + platform: 'cohost', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'cohost', }, { @@ -280,24 +293,60 @@ export const externalLinkSpec = [ }, { + match: {domain: '.deviantart.com'}, + + platform: 'deviantart', + handle: {domain: /^[^.]+/}, + + icon: 'deviantart', + }, + + { match: {domain: 'deviantart.com'}, + platform: 'deviantart', + handle: {pathname: /^([^/]+)\/?$/}, + icon: 'deviantart', }, { - match: { - domain: 'mspaintadventures.fandom.com', - pathname: /^wiki\/(.+)\/?$/, - }, + match: {domain: 'deviantart.com'}, + platform: 'deviantart', + icon: 'deviantart', + }, - platform: 'fandom', - substring: 'mspaintadventures.page', + { + match: {domain: 'facebook.com'}, - normal: 'custom', - icon: 'globe', + platform: 'facebook', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'facebook', + }, + + { + match: {domain: 'facebook.com'}, + + platform: 'facebook', + handle: {pathname: /^(?:pages|people)\/([^/]+)\/[0-9]+\/?$/}, + + icon: 'facebook', + }, - custom: { + { + match: {domain: 'facebook.com'}, + platform: 'facebook', + icon: 'facebook', + }, + + { + match: {domain: 'mspaintadventures.fandom.com'}, + + platform: 'fandom.mspaintadventures', + + detail: { + substring: 'page', page: { pathname: /^wiki\/(.+)\/?$/, transform: [ @@ -306,52 +355,150 @@ export const externalLinkSpec = [ ], }, }, + + icon: 'globe', }, { match: {domain: 'mspaintadventures.fandom.com'}, - platform: 'fandom', - substring: 'mspaintadventures', + platform: 'fandom.mspaintadventures', icon: 'globe', }, { - match: {domain: 'fandom.com'}, + match: {domains: ['fandom.com', '.fandom.com']}, platform: 'fandom', icon: 'globe', }, { + match: {domain: 'gamebanana.com'}, + platform: 'gamebanana', + icon: 'globe', + }, + + { match: {domain: 'homestuck.com'}, platform: 'homestuck', icon: 'globe', }, { + match: { + domain: 'hsmusic.wiki', + pathname: /^media\/misc\/archive/, + }, + + platform: 'hsmusic.archive', + + icon: 'globe', + }, + + { match: {domain: 'hsmusic.wiki'}, - platform: 'local', + platform: 'hsmusic', icon: 'globe', }, { match: {domain: 'instagram.com'}, + + platform: 'instagram', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'instagram', + }, + + { + match: {domain: 'instagram.com'}, platform: 'instagram', icon: 'instagram', }, + // The Wayback Machine is a separate entry. + { + match: {domain: 'archive.org'}, + platform: 'internetArchive', + icon: 'internetArchive', + }, + + { + match: {domain: '.itch.io'}, + + platform: 'itch', + handle: {domain: /^[^.]+/}, + + icon: 'itch', + }, + + { + match: {domain: 'itch.io'}, + + platform: 'itch', + handle: {pathname: /^profile\/([^/]+)\/?$/}, + + icon: 'itch', + }, + + { + match: {domain: 'ko-fi.com'}, + + platform: 'kofi', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'kofi', + }, + + { + match: {domain: 'linktr.ee'}, + + platform: 'linktree', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'linktree', + }, + { - match: {domains: ['types.pl']}, + match: {domains: [ + 'mastodon.social', + 'shrike.club', + 'types.pl', + ]}, platform: 'mastodon', + handle: {domain: /.+/}, + unusualDomain: true, - normal: 'domain', - compact: 'domain', icon: 'mastodon', }, { + match: {domains: ['mspfa.com', '.mspfa.com']}, + platform: 'mspfa', + icon: 'globe', + }, + + { + match: {domain: '.neocities.org'}, + + platform: 'neocities', + handle: {domain: /.+/}, + + icon: 'globe', + }, + + { + match: {domain: '.newgrounds.com'}, + + platform: 'newgrounds', + handle: {domain: /^[^.]+/}, + + icon: 'newgrounds', + }, + + { match: {domain: 'newgrounds.com'}, platform: 'newgrounds', icon: 'newgrounds', @@ -359,8 +506,17 @@ export const externalLinkSpec = [ { match: {domain: 'patreon.com'}, + platform: 'patreon', - icon: 'globe', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'patreon', + }, + + { + match: {domain: 'patreon.com'}, + platform: 'patreon', + icon: 'patreon', }, { @@ -373,51 +529,111 @@ export const externalLinkSpec = [ match: {domain: 'soundcloud.com'}, platform: 'soundcloud', + handle: {pathname: /^([^/]+)\/?$/}, - compact: 'handle', icon: 'soundcloud', + }, - handle: /([^/]*)\/?$/, + { + match: {domain: 'soundcloud.com'}, + platform: 'soundcloud', + icon: 'soundcloud', }, { - match: {domain: 'spotify.com'}, + match: {domains: ['spotify.com', 'open.spotify.com']}, platform: 'spotify', - icon: 'globe', + icon: 'spotify', + }, + + { + match: {domain: 'tiktok.com'}, + + platform: 'tiktok', + handle: {pathname: /^@?([^/]+)\/?$/}, + + icon: 'tiktok', + }, + + { + match: {domain: 'toyhou.se'}, + + platform: 'toyhouse', + handle: {pathname: /^([^/]+)\/?$/}, + + icon: 'toyhouse', }, { match: {domain: '.tumblr.com'}, platform: 'tumblr', + handle: {domain: /^[^.]+/}, + + icon: 'tumblr', + }, + + { + match: {domain: 'tumblr.com'}, + + platform: 'tumblr', + handle: {pathname: /^([^/]+)\/?$/}, - compact: 'handle', icon: 'tumblr', + }, - handle: {domain: /^[^.]*/}, + { + match: {domain: 'tumblr.com'}, + platform: 'tumblr', + icon: 'tumblr', + }, + + { + match: {domain: 'twitch.tv'}, + + platform: 'twitch', + handle: {pathname: /^(.+)\/?/}, + + icon: 'twitch', }, { match: {domain: 'twitter.com'}, platform: 'twitter', + handle: {pathname: /^@?([^/]+)\/?$/}, - compact: 'handle', icon: 'twitter', + }, - handle: { - prefix: '@', - pathname: /^@?([a-zA-Z0-9_]*)\/?$/, - }, + { + match: {domain: 'twitter.com'}, + platform: 'twitter', + icon: 'twitter', + }, + + { + match: {domain: 'web.archive.org'}, + platform: 'waybackMachine', + icon: 'internetArchive', }, { - match: {domain: 'wikipedia.org'}, + match: {domains: ['wikipedia.org', '.wikipedia.org']}, platform: 'wikipedia', icon: 'misc', }, { + match: {domain: 'youtube.com'}, + + platform: 'youtube', + handle: {pathname: /^@([^/]+)\/?$/}, + + icon: 'youtube', + }, + + { match: {domains: ['youtube.com', 'youtu.be']}, platform: 'youtube', icon: 'youtube', @@ -443,10 +659,30 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, { } = {}) { const {domain, pathname, query} = urlParts(url); - const compareDomain = string => domain.includes(string); + const compareDomain = string => { + // A dot at the start of the descriptor's domain indicates + // we're looking to match a subdomain. + if (string.startsWith('.')) matchSubdomain: { + // "www" is never an acceptable subdomain for this purpose. + // Sorry to people whose usernames are www!! + if (domain.startsWith('www.')) { + return false; + } + + return domain.endsWith(string); + } + + // No dot means we're looking for an exact/full domain match. + // But let "www" pass here too, implicitly. + return domain === string || domain === 'www.' + string; + }; + const comparePathname = regex => regex.test(pathname.slice(1)); const compareQuery = regex => regex.test(query.slice(1)); + const compareExtractSpec = extract => + extractPartFromExternalLink(url, extract, {mode: 'test'}); + const contextArray = (Array.isArray(context) ? context @@ -454,33 +690,55 @@ export function getMatchingDescriptorsForExternalLink(url, descriptors, { const matchingDescriptors = descriptors - .filter(({match}) => { - if (match.domain) return compareDomain(match.domain); - if (match.domains) return match.domains.some(compareDomain); - return false; - }) - .filter(({match}) => { - if (Array.isArray(match.context)) - return match.context.some(c => contextArray.includes(c)); - if (match.context) - return contextArray.includes(match.context); - return true; - }) - .filter(({match}) => { - if (match.pathname) return comparePathname(match.pathname); - if (match.pathnames) return match.pathnames.some(comparePathname); - return true; - }) - .filter(({match}) => { - if (match.query) return compareQuery(match.query); - if (match.queries) return match.quieries.some(compareQuery); - return true; - }); + .filter(({match}) => + (match.domain + ? compareDomain(match.domain) + : match.domains + ? match.domains.some(compareDomain) + : false)) + + .filter(({match}) => + (Array.isArray(match.context) + ? match.context.some(c => contextArray.includes(c)) + : match.context + ? contextArray.includes(match.context) + : true)) + + .filter(({match}) => + (match.pathname + ? comparePathname(match.pathname) + : match.pathnames + ? match.pathnames.some(comparePathname) + : true)) + + .filter(({match}) => + (match.query + ? compareQuery(match.query) + : match.queries + ? match.quieries.some(compareQuery) + : true)) + + .filter(({handle}) => + (handle + ? compareExtractSpec(handle) + : true)) + + .filter(({detail}) => + (typeof detail === 'object' + ? Object.entries(detail) + .filter(([key]) => key !== 'substring') + .map(([_key, value]) => value) + .every(compareExtractSpec) + : true)); return [...matchingDescriptors, fallbackDescriptor]; } -export function extractPartFromExternalLink(url, extract) { +export function extractPartFromExternalLink(url, extract, { + // Set to 'test' to just see if this would extract anything. + // This disables running custom transformations. + mode = 'extract', +} = {}) { const {domain, pathname, query} = urlParts(url); let regexen = []; @@ -556,15 +814,23 @@ export function extractPartFromExternalLink(url, extract) { })) { const match = test.match(regex); if (match) { - value = prefix + (match[1] ?? match[0]); + value = match[1] ?? match[0]; break; } } + if (mode === 'test') { + return !!value; + } + if (!value) { return null; } + if (prefix) { + value = prefix + value; + } + for (const fn of transform) { value = fn(value); } @@ -587,134 +853,77 @@ export function extractAllCustomPartsFromExternalLink(url, custom) { export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) { const prefix = 'misc.external'; - function getPlatform() { - return language.$(prefix, descriptor.platform); - } - - function getDomain() { - return urlParts(url).domain; - } - - function getCustom() { - if (!descriptor.custom) { + function getDetail() { + if (!descriptor.detail) { return null; } - const customParts = - extractAllCustomPartsFromExternalLink(url, descriptor.custom); - - if (!customParts) { - return null; - } + if (typeof descriptor.detail === 'string') { + return language.$(prefix, descriptor.platform, descriptor.detail); + } else { + const {substring, ...rest} = descriptor.detail; - return language.$(prefix, descriptor.platform, descriptor.substring, customParts); - } + const opts = + withEntries(rest, entries => entries + .map(([key, value]) => [ + key, + extractPartFromExternalLink(url, value), + ])); - function getHandle() { - if (!descriptor.handle) { - return null; + return language.$(prefix, descriptor.platform, substring, opts); } - - return extractPartFromExternalLink(url, descriptor.handle); } - function getNormal() { - if (descriptor.custom) { - if (descriptor.normal === 'custom') { - return getCustom(); + switch (style) { + case 'platform': { + const platform = language.$(prefix, descriptor.platform); + const domain = urlParts(url).domain; + + if (descriptor === fallbackDescriptor) { + // The fallback descriptor has a "platform" which is just + // the word "External". This isn't really useful when you're + // looking for platform info! + if (domain) { + return language.sanitize(domain.replace(/^www\./, '')); + } else { + return platform; + } + } else if (descriptor.detail) { + return getDetail(); + } else if (descriptor.unusualDomain && domain) { + return language.$(prefix, 'withDomain', {platform, domain}); } else { - return null; - } - } - - if (descriptor.normal === 'domain') { - const platform = getPlatform(); - const domain = getDomain(); - - if (!platform || !domain) { - return null; + return platform; } - - return language.$(prefix, 'withDomain', {platform, domain}); } - if (descriptor.normal === 'handle') { - const platform = getPlatform(); - const handle = getHandle(); - - if (!platform || !handle) { - return null; - } - - return language.$(prefix, 'withHandle', {platform, handle}); - } - - return language.$(prefix, descriptor.platform, descriptor.substring); - } - - function getCompact() { - if (descriptor.custom) { - if (descriptor.compact === 'custom') { - return getCustom(); + case 'handle': { + if (descriptor.handle) { + return extractPartFromExternalLink(url, descriptor.handle); } else { return null; } } - if (descriptor.compact === 'domain') { - const domain = getDomain(); - - if (!domain) { + case 'icon-id': { + if (descriptor.icon) { + return descriptor.icon; + } else { return null; } - - return language.sanitize(domain.replace(/^www\./, '')); } - - if (descriptor.compact === 'handle') { - const handle = getHandle(); - - if (!handle) { - return null; - } - - return language.sanitize(handle); - } - } - - function getIconId() { - return descriptor.icon ?? null; - } - - switch (style) { - case 'normal': return getNormal(); - case 'compact': return getCompact(); - case 'platform': return getPlatform(); - case 'icon-id': return getIconId(); } } export function couldDescriptorSupportStyle(descriptor, style) { - if (style === 'normal') { - if (descriptor.custom) { - return descriptor.normal === 'custom'; - } else { - return true; - } - } - - if (style === 'compact') { - if (descriptor.custom) { - return descriptor.compact === 'custom'; - } else { - return !!descriptor.compact; - } - } - if (style === 'platform') { return true; } + if (style === 'handle') { + return !!descriptor.handle; + } + if (style === 'icon-id') { return !!descriptor.icon; } @@ -744,15 +953,13 @@ export function getExternalLinkStringOfStyleFromDescriptors(url, style, descript } export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) { - const getStyle = style => - getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}); - - return { - 'normal': getStyle('normal'), - 'compact': getStyle('compact'), - 'platform': getStyle('platform'), - 'icon-id': getStyle('icon-id'), - }; + return ( + Object.fromEntries( + externalLinkStyles.map(style => + getExternalLinkStringOfStyleFromDescriptor( + url, + style, + descriptor, {language})))); } export function getExternalLinkStringsFromDescriptors(url, descriptors, { |