« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/things/language.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/things/language.js')
-rw-r--r--src/data/things/language.js234
1 files changed, 144 insertions, 90 deletions
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 4e23cf7f..7f3f43de 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,24 +1,25 @@
-import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
+import {Temporal, toTemporalInstant} from '@js-temporal/polyfill';
 
 import {withAggregate} from '#aggregate';
-import CacheableObject from '#cacheable-object';
 import {logWarn} from '#cli';
+import {input, V} from '#composite';
 import * as html from '#html';
-import {empty} from '#sugar';
-import {isLanguageCode} from '#validators';
+import {accumulateSum, empty, withEntries} from '#sugar';
+import {isLanguageCode, isObject} from '#validators';
 import Thing from '#thing';
+import {languageOptionRegex} from '#wiki-data';
 
 import {
+  externalLinkSpec,
   getExternalLinkStringOfStyleFromDescriptors,
   getExternalLinkStringsFromDescriptors,
   isExternalLinkContext,
-  isExternalLinkSpec,
   isExternalLinkStyle,
 } from '#external-links';
 
-import {externalFunction, flag, name} from '#composite/wiki-properties';
-
-export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g;
+import {exitWithoutDependency, exposeConstant}
+  from '#composite/control-flow';
+import {flag, name} from '#composite/wiki-properties';
 
 export class Language extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
@@ -34,7 +35,7 @@ export class Language extends Thing {
 
     // Human-readable name. This should be the language's own native name, not
     // localized to any other language.
-    name: name(`Unnamed Language`),
+    name: name(V(`Unnamed Language`)),
 
     // Language code specific to JavaScript's Internationalization (Intl) API.
     // Usually this will be the same as the language's general code, but it
@@ -56,20 +57,29 @@ export class Language extends Thing {
     // with languages that are currently in development and not ready for
     // formal release, or which are just kept hidden as "experimental zones"
     // for wiki development or content testing.
-    hidden: flag(false),
+    hidden: flag(V(false)),
 
     // Mapping of translation keys to values (strings). Generally, don't
     // access this object directly - use methods instead.
-    strings: {
-      flags: {update: true, expose: true},
-      update: {validate: (t) => typeof t === 'object'},
+    strings: [
+      {
+        dependencies: [
+          input.updateValue({validate: isObject}),
+          'inheritedStrings',
+        ],
+
+        compute: (continuation, {
+          [input.updateValue()]: strings,
+          ['inheritedStrings']: inheritedStrings,
+        }) =>
+          (strings && inheritedStrings
+            ? continuation()
+            : strings ?? inheritedStrings),
+      },
 
-      expose: {
+      {
         dependencies: ['inheritedStrings', 'code'],
         transform(strings, {inheritedStrings, code}) {
-          if (!strings && !inheritedStrings) return null;
-          if (!inheritedStrings) return strings;
-
           const validStrings = {
             ...inheritedStrings,
             ...strings,
@@ -98,6 +108,7 @@ export class Language extends Thing {
                 logWarn`- Missing options: ${missingOptionNames.join(', ')}`;
               if (!empty(misplacedOptionNames))
                 logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`;
+
               validStrings[key] = inheritedStrings[key];
             }
           }
@@ -105,7 +116,7 @@ export class Language extends Thing {
           return validStrings;
         },
       },
-    },
+    ],
 
     // May be provided to specify "default" strings, generally (but not
     // necessarily) inherited from another Language object.
@@ -114,34 +125,22 @@ 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 #external-links for info.
-    externalLinkSpec: {
-      flags: {update: true, expose: true},
-      update: {validate: isExternalLinkSpec},
-    },
-
-    // Update only
-
-    escapeHTML: externalFunction(),
-
     // Expose only
 
-    onlyIfOptions: {
-      flags: {expose: true},
-      expose: {
-        compute: () => Symbol.for(`language.onlyIfOptions`),
-      },
-    },
+    isLanguage: exposeConstant(V(true)),
+
+    onlyIfOptions: exposeConstant(V(Symbol.for(`language.onlyIfOptions`))),
 
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
     intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}),
+    intl_dateMonthDay: this.#intlHelper(Intl.DateTimeFormat, {month: 'numeric', day: 'numeric'}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
     intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}),
     intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}),
     intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}),
     intl_pluralOrdinal: this.#intlHelper(Intl.PluralRules, {type: 'ordinal'}),
+    intl_wordSegmenter: this.#intlHelper(Intl.Segmenter, {granularity: 'word'}),
 
     validKeys: {
       flags: {expose: true},
@@ -159,19 +158,16 @@ export class Language extends Thing {
     },
 
     // TODO: This currently isn't used. Is it still needed?
-    strings_htmlEscaped: {
-      flags: {expose: true},
-      expose: {
-        dependencies: ['strings', 'inheritedStrings', 'escapeHTML'],
-        compute({strings, inheritedStrings, escapeHTML}) {
-          if (!(strings || inheritedStrings) || !escapeHTML) return null;
-          const allStrings = {...inheritedStrings, ...strings};
-          return Object.fromEntries(
-            Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)])
-          );
-        },
+    strings_htmlEscaped: [
+      exitWithoutDependency('strings'),
+
+      {
+        dependencies: ['strings'],
+        compute: ({strings}) =>
+          withEntries(strings, entries => entries
+            .map(([key, value]) => [key, html.escape(value)])),
       },
-    },
+    ],
   });
 
   static #intlHelper (constructor, opts) {
@@ -192,18 +188,35 @@ export class Language extends Thing {
     return this.formatString(...args);
   }
 
+  $order(...args) {
+    return this.orderStringOptions(...args);
+  }
+
   assertIntlAvailable(property) {
     if (!this[property]) {
       throw new Error(`Intl API ${property} unavailable`);
     }
   }
 
+  countWords(text) {
+    this.assertIntlAvailable('intl_wordSegmenter');
+
+    const string = html.resolve(text, {normalize: 'plain'});
+    const segments = this.intl_wordSegmenter.segment(string);
+
+    return accumulateSum(segments, segment => segment.isWordLike ? 1 : 0);
+  }
+
   getUnitForm(value) {
     this.assertIntlAvailable('intl_pluralCardinal');
     return this.intl_pluralCardinal.select(value);
   }
 
   formatString(...args) {
+    if (typeof args.at(-1) === 'function') {
+      throw new Error(`Passed function - did you mean language.encapsulate() instead?`);
+    }
+
     const hasOptions =
       typeof args.at(-1) === 'object' &&
       args.at(-1) !== null;
@@ -211,19 +224,14 @@ export class Language extends Thing {
     const key =
       this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args);
 
+    const template =
+      this.#getStringTemplateFromFormedKey(key);
+
     const options =
       (hasOptions
         ? args.at(-1)
         : {});
 
-    if (!this.strings) {
-      throw new Error(`Strings unavailable`);
-    }
-
-    if (!this.validKeys.includes(key)) {
-      throw new Error(`Invalid key ${key} accessed`);
-    }
-
     const constantCasify = name =>
       name
         .replace(/[A-Z]/g, '_$&')
@@ -264,8 +272,7 @@ export class Language extends Thing {
         ]));
 
     const output = this.#iterateOverTemplate({
-      template: this.strings[key],
-
+      template,
       match: languageOptionRegex,
 
       insert: ({name: optionName}, canceledForming) => {
@@ -310,7 +317,7 @@ export class Language extends Thing {
           return undefined;
         }
 
-        return optionValue;
+        return this.sanitize(optionValue);
       },
     });
 
@@ -345,6 +352,46 @@ export class Language extends Thing {
     return output;
   }
 
+  orderStringOptions(...args) {
+    let slice = null, at = null, parts = null;
+    if (args.length >= 2 && typeof args.at(-1) === 'number') {
+      if (args.length >= 3 && typeof args.at(-2) === 'number') {
+        slice = [args.at(-2), args.at(-1)];
+        parts = args.slice(0, -2);
+      } else {
+        at = args.at(-1);
+        parts = args.slice(0, -1);
+      }
+    } else {
+      parts = args;
+    }
+
+    const template = this.getStringTemplate(...parts);
+    const matches = Array.from(template.matchAll(languageOptionRegex));
+    const options = matches.map(({groups}) => groups.name);
+
+    if (slice !== null) return options.slice(...slice);
+    if (at !== null) return options.at(at);
+    return options;
+  }
+
+  getStringTemplate(...args) {
+    const key = this.#joinKeyParts(args);
+    return this.#getStringTemplateFromFormedKey(key);
+  }
+
+  #getStringTemplateFromFormedKey(key) {
+    if (!this.strings) {
+      throw new Error(`Strings unavailable`);
+    }
+
+    if (!this.validKeys.includes(key)) {
+      throw new Error(`Invalid key ${key} accessed`);
+    }
+
+    return this.strings[key];
+  }
+
   #iterateOverTemplate({
     template,
     match: regexp,
@@ -375,26 +422,22 @@ export class Language extends Thing {
 
       partInProgress += template.slice(lastIndex, match.index);
 
-      // Sanitize string arguments in particular. These are taken to come from
-      // (raw) data and may include special characters that aren't meant to be
-      // rendered as HTML markup.
-      const sanitizedInsertion =
-        this.#sanitizeValueForInsertion(insertion);
-
-      if (typeof sanitizedInsertion === 'string') {
-        // Join consecutive strings together.
-        partInProgress += sanitizedInsertion;
-      } else if (
-        sanitizedInsertion instanceof html.Tag &&
-        sanitizedInsertion.contentOnly
-      ) {
-        // Collapse string-only tag contents onto the current string part.
-        partInProgress += sanitizedInsertion.toString();
-      } else {
-        // Push the string part in progress, then the insertion as-is.
-        outputParts.push(partInProgress);
-        outputParts.push(sanitizedInsertion);
+      const insertionItems = html.smush(insertion).content;
+      if (insertionItems.length === 1 && typeof insertionItems[0] !== 'string') {
+        // Push the insertion exactly as it is, rather than manipulating.
+        if (partInProgress) outputParts.push(partInProgress);
+        outputParts.push(insertion);
         partInProgress = '';
+      } else for (const insertionItem of insertionItems) {
+        if (typeof insertionItem === 'string') {
+          // Join consecutive strings together.
+          partInProgress += insertionItem;
+        } else {
+          // Push the string part in progress, then the insertion as-is.
+          if (partInProgress) outputParts.push(partInProgress);
+          outputParts.push(insertionItem);
+          partInProgress = '';
+        }
       }
 
       lastIndex = match.index + match[0].length;
@@ -426,14 +469,9 @@ export class Language extends Thing {
   // html.Tag objects - gets left as-is, preserving the value exactly as it's
   // provided.
   #sanitizeValueForInsertion(value) {
-    const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML');
-    if (!escapeHTML) {
-      throw new Error(`escapeHTML unavailable`);
-    }
-
     switch (typeof value) {
       case 'string':
-        return escapeHTML(value);
+        return html.escape(value);
 
       case 'number':
       case 'boolean':
@@ -510,6 +548,15 @@ export class Language extends Thing {
     return this.intl_dateYear.format(date);
   }
 
+  formatMonthDay(date) {
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_dateMonthDay');
+    return this.intl_dateMonthDay.format(date);
+  }
+
   formatYearRange(startDate, endDate) {
     // formatYearRange expects both values to be present, but if both are null
     // or both are undefined, that's just blank content.
@@ -688,10 +735,6 @@ export class Language extends Thing {
     style = 'platform',
     context = 'generic',
   } = {}) {
-    if (!this.externalLinkSpec) {
-      throw new TypeError(`externalLinkSpec unavailable`);
-    }
-
     // Null or undefined url is blank content.
     if (url === null || url === undefined) {
       return html.blank();
@@ -700,7 +743,7 @@ export class Language extends Thing {
     isExternalLinkContext(context);
 
     if (style === 'all') {
-      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+      return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, {
         language: this,
         context,
       });
@@ -709,7 +752,7 @@ export class Language extends Thing {
     isExternalLinkStyle(style);
 
     const result =
-      getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+      getExternalLinkStringOfStyleFromDescriptors(url, style, externalLinkSpec, {
         language: this,
         context,
       });
@@ -865,6 +908,18 @@ export class Language extends Thing {
     }
   }
 
+  typicallyLowerCase(string) {
+    // Utter nonsense implementation, so this only works on strings,
+    // not actual HTML content, and may rudely disrespect *intentful*
+    // capitalization of whatever goes into it.
+
+    if (typeof string !== 'string') return string;
+    if (string.length <= 1) return string;
+    if (/^\S+?[A-Z]/.test(string)) return string;
+
+    return string[0].toLowerCase() + string.slice(1);
+  }
+
   // Utility function to quickly provide a useful string key
   // (generally a prefix) to stuff nested beneath it.
   encapsulate(...args) {
@@ -923,7 +978,6 @@ Object.assign(Language.prototype, {
   countArtworks: countHelper('artworks'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
-  countCoverArts: countHelper('coverArts'),
   countDays: countHelper('days'),
   countFlashes: countHelper('flashes'),
   countMonths: countHelper('months'),