« get me outta code hell

data, content: extract external link parsing into nicer interface - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-11-23 11:16:48 -0400
committer(quasar) nebula <qznebula@protonmail.com>2023-11-24 13:45:11 -0400
commit8f17782a5f2adbafd031b269195879eb7f79e05f (patch)
treeb15d7930f8de0c4e98e14999a49bff4e1919f4a9 /src/util
parent921f2d421d6ffb87fab1a2059a6c313b9c81f4f8 (diff)
data, content: extract external link parsing into nicer interface
Diffstat (limited to 'src/util')
-rw-r--r--src/util/external-links.js308
1 files changed, 308 insertions, 0 deletions
diff --git a/src/util/external-links.js b/src/util/external-links.js
new file mode 100644
index 00000000..8e1c3ca9
--- /dev/null
+++ b/src/util/external-links.js
@@ -0,0 +1,308 @@
+import {empty, stitchArrays} from '#sugar';
+
+import {
+  is,
+  isStringNonEmpty,
+  optional,
+  validateArrayItems,
+  validateInstanceOf,
+  validateProperties,
+} from '#validators';
+
+export const externalLinkStyles = [
+  'normal',
+  'compact',
+  'icon-id',
+];
+
+export const isExternalLinkStyle = is(...externalLinkStyles);
+
+// This might need to be adjusted for YAML importing...
+const isExternalLinkSpecRegex =
+  validateInstanceOf(RegExp);
+
+export const isExternalLinkHandleSpec =
+  validateProperties({
+    prefix: optional(isStringNonEmpty),
+
+    url: optional(isExternalLinkSpecRegex),
+
+    // TODO: Don't allow specifying both of these (they're aliases)
+    domain: optional(isExternalLinkSpecRegex),
+    hostname: optional(isExternalLinkSpecRegex),
+
+    // TODO: Don't allow specifying both of these (they're aliases)
+    path: optional(isExternalLinkSpecRegex),
+    pathname: optional(isExternalLinkSpecRegex),
+  });
+
+export const isExternalLinkSpec =
+  validateArrayItems(
+    validateProperties({
+      // TODO: Don't allow providing both of these, and require providing one
+      matchDomain: optional(isStringNonEmpty),
+      matchDomains: optional(validateArrayItems(isStringNonEmpty)),
+
+      string: isStringNonEmpty,
+
+      // TODO: Don't allow 'handle' options if handle isn't provided
+      normal: optional(is('domain', 'handle')),
+      compact: optional(is('domain', 'handle')),
+      icon: optional(isStringNonEmpty),
+
+      handle: optional(isExternalLinkHandleSpec),
+    }));
+
+export const fallbackDescriptor = {
+  string: 'external',
+
+  normal: 'domain',
+  compact: 'domain',
+  icon: 'globe',
+};
+
+// TODO: Define all this stuff in data as YAML!
+export const externalLinkSpec = [
+  {
+    matchDomain: 'bandcamp.com',
+
+    string: 'bandcamp',
+
+    compact: 'handle',
+    icon: 'bandcamp',
+
+    handle: {domain: /^[^.]*/},
+  },
+
+  {
+    matchDomains: ['bc.s3m.us', 'music.solatrux.com'],
+
+    icon: 'bandcamp',
+    string: 'bandcamp',
+
+    normal: 'domain',
+    compact: 'domain',
+  },
+
+  {
+    matchDomains: ['types.pl'],
+
+    icon: 'mastodon',
+    string: 'mastodon',
+
+    compact: 'domain',
+  },
+
+  {
+    matchDomains: ['youtube.com', 'youtu.be'],
+
+    icon: 'youtube',
+    string: 'youtube',
+
+    compact: 'handle',
+
+    handle: {
+      pathname: /^(@.*?)\/?$/,
+    },
+  },
+
+  {
+    matchDomain: 'soundcloud.com',
+
+    icon: 'soundcloud',
+    string: 'soundcloud',
+
+    compact: 'handle',
+
+    handle: /[^/]*\/?$/,
+  },
+
+  {
+    matchDomain: 'tumblr.com',
+
+    icon: 'tumblr',
+    string: 'tumblr',
+
+    compact: 'handle',
+
+    handle: {domain: /^[^.]*/},
+  },
+
+  {
+    matchDomain: 'twitter.com',
+
+    icon: 'twitter',
+    string: 'twitter',
+
+    compact: 'handle',
+
+    handle: {
+      prefix: '@',
+      pathname: /^@?.*\/?$/,
+    },
+  },
+
+  {
+    matchDomain: 'deviantart.com',
+
+    icon: 'deviantart',
+    string: 'deviantart',
+  },
+
+  {
+    matchDomain: 'instagram.com',
+
+    icon: 'instagram',
+    string: 'instagram',
+  },
+
+  {
+    matchDomain: 'newgrounds.com',
+
+    icon: 'newgrounds',
+    string: 'newgrounds',
+  },
+];
+
+export function getMatchingDescriptorsForExternalLink(url, descriptors) {
+  const {hostname: domain} = new URL(url);
+  const compare = d => domain.includes(d);
+
+  const matchingDescriptors =
+    descriptors.filter(spec => {
+      if (spec.matchDomain && compare(spec.matchDomain)) return true;
+      if (spec.matchDomains && spec.matchDomains.some(compare)) return true;
+      return false;
+    });
+
+  return [...matchingDescriptors, fallbackDescriptor];
+}
+
+export function getExternalLinkStringsFromDescriptor(url, descriptor, language) {
+  const prefix = 'misc.external';
+
+  const results = {
+    'normal': null,
+    'compact': null,
+    'icon-id': null,
+  };
+
+  const {hostname: domain, pathname} = new URL(url);
+
+  const place = language.$(prefix, descriptor.string);
+
+  if (descriptor.icon) {
+    results['icon-id'] = descriptor.icon;
+  }
+
+  if (descriptor.normal === 'domain') {
+    results['normal'] = language.$(prefix, 'withDomain', {place, domain});
+  }
+
+  if (descriptor.compact === 'domain') {
+    results['compact'] = language.sanitize(domain.replace(/^www\./, ''));
+  }
+
+  let handle = null;
+
+  if (descriptor.handle) {
+    let regexen = [];
+    let tests = [];
+
+    let handlePrefix = '';
+
+    if (descriptor.handle instanceof RegExp) {
+      regexen.push(descriptor.handle);
+      tests.push(url);
+    } else {
+      for (const [key, value] of Object.entries(descriptor.handle)) {
+        switch (key) {
+          case 'prefix':
+            handlePrefix = value;
+            continue;
+
+          case 'url':
+            tests.push(url);
+            break;
+
+          case 'domain':
+          case 'hostname':
+            tests.push(domain);
+            break;
+
+          case 'path':
+          case 'pathname':
+            tests.push(pathname.slice(1));
+            break;
+
+          default:
+            tests.push('');
+            break;
+        }
+
+        regexen.push(value);
+      }
+    }
+
+    for (const {regex, test} of stitchArrays({
+      regex: regexen,
+      test: tests,
+    })) {
+      const match = test.match(regex);
+      if (match) {
+        handle = handlePrefix + (match[1] ?? match[0]);
+        break;
+      }
+    }
+  }
+
+  if (descriptor.compact === 'handle') {
+    results.compact = language.sanitize(handle);
+  }
+
+  if (descriptor.normal === 'handle' && handle) {
+    results.normal = language.$(prefix, 'withHandle', {place, handle});
+  }
+
+  results.normal ??= language.$(prefix, descriptor.string);
+
+  return results;
+}
+
+export function getExternalLinkStringsFromDescriptors(url, descriptors, language) {
+  const results = {
+    'normal': null,
+    'compact': null,
+    'icon-id': null,
+  };
+
+  const remainingKeys =
+    new Set(Object.keys(results));
+
+  const matchingDescriptors =
+    getMatchingDescriptorsForExternalLink(url, descriptors);
+
+  for (const descriptor of matchingDescriptors) {
+    const descriptorResults =
+      getExternalLinkStringsFromDescriptor(url, descriptor, language);
+
+    const descriptorKeys =
+      new Set(
+        Object.entries(descriptorResults)
+          .filter(entry => entry[1])
+          .map(entry => entry[0]));
+
+    for (const key of remainingKeys) {
+      if (descriptorKeys.has(key)) {
+        results[key] = descriptorResults[key];
+        remainingKeys.delete(key);
+      }
+    }
+
+    if (empty(remainingKeys)) {
+      return results;
+    }
+  }
+
+  return results;
+}