« 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
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
parent921f2d421d6ffb87fab1a2059a6c313b9c81f4f8 (diff)
data, content: extract external link parsing into nicer interface
-rw-r--r--package.json1
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js223
-rw-r--r--src/data/language.js14
-rw-r--r--src/data/things/language.js34
-rw-r--r--src/util/external-links.js308
5 files changed, 355 insertions, 225 deletions
diff --git a/package.json b/package.json
index 194c406..abf9e1d 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
         "#content-dependencies": "./src/content/dependencies/index.js",
         "#content-function": "./src/content-function.js",
         "#cli": "./src/util/cli.js",
+        "#external-links": "./src/util/external-links.js",
         "#find": "./src/find.js",
         "#html": "./src/util/html.js",
         "#language": "./src/data/language.js",
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
index d3ed912..58bd896 100644
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -1,237 +1,28 @@
-import {stitchArrays} from '#sugar';
-
-const fallbackDescriptor = {
-  icon: 'globe',
-  string: 'external',
-
-  normal: 'domain',
-  compact: 'domain',
-};
-
-// TODO: Define all this stuff in data!
-const externalSpec = [
-  {
-    matchDomain: 'bandcamp.com',
-
-    icon: 'bandcamp',
-    string: 'bandcamp',
-
-    compact: 'handle',
-
-    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',
-  },
-];
-
-function determineLinkText(url, descriptor, {language}) {
-  const prefix = 'misc.external';
-
-  const {
-    hostname: domain,
-    pathname,
-  } = new URL(url);
-
-  let normal = null;
-  let compact = null;
-
-  const place = language.$(prefix, descriptor.string);
-
-  if (descriptor.normal === 'domain') {
-    normal = language.$(prefix, 'withDomain', {place, domain});
-  }
-
-  if (descriptor.compact === 'domain') {
-    compact = 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') {
-    compact = handle;
-  }
-
-  if (normal === 'handle' && handle) {
-    normal = language.$(prefix, 'withHandle', {place, handle});
-  }
-
-  normal ??= language.$(prefix, descriptor.string);
-
-  return {normal, compact};
-}
-
 export default {
   extraDependencies: ['html', 'language', 'to'],
 
-  data(url) {
-    return {url};
-  },
+  data: (url) => ({url}),
 
   slots: {
     withText: {type: 'boolean'},
   },
 
   generate(data, slots, {html, language, to}) {
-    const {hostname: domain} = new URL(data.url);
-
-    const descriptor =
-      externalSpec.find(({matchDomain, matchDomains}) => {
-        const compare = d => domain.includes(d);
-        if (matchDomain && compare(matchDomain)) return true;
-        if (matchDomains && matchDomains.some(compare)) return true;
-        return false;
-      }) ?? fallbackDescriptor;
+    const {url} = data;
 
-    const {normal: normalText, compact: compactText} =
-      determineLinkText(data.url, descriptor, {language});
+    const normalText = language.formatExternalLink(url, {style: 'normal'});
+    const compactText = language.formatExternalLink(url, {style: 'compact'});
+    const iconId = language.formatExternalLink(url, {style: 'icon-id'});
 
     return html.tag('a',
-      {href: data.url, class: ['icon', slots.withText && 'has-text']},
+      {href: url, class: ['icon', slots.withText && 'has-text']},
       [
         html.tag('svg', [
           !slots.withText &&
             html.tag('title', normalText),
 
           html.tag('use', {
-            href: to('shared.staticIcon', descriptor.icon),
+            href: to('shared.staticIcon', iconId),
           }),
         ]),
 
diff --git a/src/data/language.js b/src/data/language.js
index 3fc14da..6f774f2 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -7,15 +7,11 @@ import chokidar from 'chokidar';
 import he from 'he'; // It stands for "HTML Entities", apparently. Cursed.
 import yaml from 'js-yaml';
 
-import T from '#things';
+import {externalLinkSpec} from '#external-links';
 import {colors, logWarn} from '#cli';
-
-import {
-  annotateError,
-  annotateErrorWithFile,
-  showAggregate,
-  withAggregate,
-} from '#sugar';
+import {annotateError, annotateErrorWithFile, showAggregate, withAggregate}
+  from '#sugar';
+import T from '#things';
 
 const {Language} = T;
 
@@ -114,6 +110,8 @@ export function initializeLanguageObject() {
   language.escapeHTML = string =>
     he.encode(string, {useNamedReferences: true});
 
+  language.externalLinkSpec = externalLinkSpec;
+
   return language;
 }
 
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 646eb6d..185488e 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,5 +1,11 @@
-import {Tag} from '#html';
 import {isLanguageCode} from '#validators';
+import {Tag} from '#html';
+
+import {
+  getExternalLinkStringsFromDescriptors,
+  isExternalLinkSpec,
+  isExternalLinkStyle,
+} from '#external-links';
 
 import {
   externalFunction,
@@ -72,6 +78,13 @@ export class Language extends Thing {
       update: {validate: (t) => typeof t === 'object'},
     },
 
+    // List of descriptors for providing to external link utilities when using
+    // language.formatExternalLink - refer to util/external-links.js for info.
+    externalLinkSpec: {
+      flags: {update: true, expose: true},
+      update: {validate: isExternalLinkSpec},
+    },
+
     // Update only
 
     escapeHTML: externalFunction(),
@@ -299,6 +312,25 @@ export class Language extends Thing {
       : duration;
   }
 
+  formatExternalLink(url, {style = 'normal'} = {}) {
+    if (!this.externalLinkSpec) {
+      throw new TypeError(`externalLinkSpec unavailable`);
+    }
+
+    if (style !== 'all') {
+      isExternalLinkStyle(style);
+    }
+
+    const results =
+      getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, this);
+
+    if (style === 'all') {
+      return results;
+    } else {
+      return results[style];
+    }
+  }
+
   formatIndex(value) {
     this.assertIntlAvailable('intl_pluralOrdinal');
     return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
diff --git a/src/util/external-links.js b/src/util/external-links.js
new file mode 100644
index 0000000..8e1c3ca
--- /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;
+}