« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util/external-links.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/util/external-links.js')
-rw-r--r--src/util/external-links.js669
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, {