« 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.js170
1 files changed, 128 insertions, 42 deletions
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 7755c505..646eb6d1 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,11 +1,17 @@
+import {Tag} from '#html';
+import {isLanguageCode} from '#validators';
+
+import {
+  externalFunction,
+  flag,
+  simpleString,
+} from '#composite/wiki-properties';
+
+import CacheableObject from './cacheable-object.js';
 import Thing from './thing.js';
 
 export class Language extends Thing {
-  static [Thing.getPropertyDescriptors] = ({
-    validators: {
-      isLanguageCode,
-    },
-  }) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     // General language code. This is used to identify the language distinctly
@@ -18,7 +24,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: Thing.common.simpleString(),
+    name: simpleString(),
 
     // Language code specific to JavaScript's Internationalization (Intl) API.
     // Usually this will be the same as the language's general code, but it
@@ -40,7 +46,7 @@ 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: Thing.common.flag(false),
+    hidden: flag(false),
 
     // Mapping of translation keys to values (strings). Generally, don't
     // access this object directly - use methods instead.
@@ -68,7 +74,7 @@ export class Language extends Thing {
 
     // Update only
 
-    escapeHTML: Thing.common.externalFunction(),
+    escapeHTML: externalFunction(),
 
     // Expose only
 
@@ -95,6 +101,7 @@ export class Language extends Thing {
       },
     },
 
+    // TODO: This currently isn't used. Is it still needed?
     strings_htmlEscaped: {
       flags: {expose: true},
       expose: {
@@ -124,8 +131,8 @@ export class Language extends Thing {
     };
   }
 
-  $(key, args = {}) {
-    return this.formatString(key, args);
+  $(...args) {
+    return this.formatString(...args);
   }
 
   assertIntlAvailable(property) {
@@ -139,20 +146,22 @@ export class Language extends Thing {
     return this.intl_pluralCardinal.select(value);
   }
 
-  formatString(key, args = {}) {
-    if (this.strings && !this.strings_htmlEscaped) {
-      throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`);
-    }
+  formatString(...args) {
+    const hasOptions =
+      typeof args.at(-1) === 'object' &&
+      args.at(-1) !== null;
 
-    return this.formatStringHelper(this.strings_htmlEscaped, key, args);
-  }
+    const key =
+      (hasOptions ? args.slice(0, -1) : args)
+        .filter(Boolean)
+        .join('.');
 
-  formatStringNoHTMLEscape(key, args = {}) {
-    return this.formatStringHelper(this.strings, key, args);
-  }
+    const options =
+      (hasOptions
+        ? args.at(-1)
+        : null);
 
-  formatStringHelper(strings, key, args = {}) {
-    if (!strings) {
+    if (!this.strings) {
       throw new Error(`Strings unavailable`);
     }
 
@@ -160,30 +169,94 @@ export class Language extends Thing {
       throw new Error(`Invalid key ${key} accessed`);
     }
 
-    const template = strings[key];
-
-    // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
-    // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
-    // like, who cares, dude?) Also, this is an array, 8ecause it's handy
-    // for the iterating we're a8out to do.
-    const processedArgs = Object.entries(args).map(([k, v]) => [
-      k.replace(/[A-Z]/g, '_$&').toUpperCase(),
-      v,
-    ]);
-
-    // Replacement time! Woot. Reduce comes in handy here!
-    const output = processedArgs.reduce(
-      (x, [k, v]) => x.replaceAll(`{${k}}`, v),
-      template
-    );
+    const template = this.strings[key];
+
+    let output;
+
+    if (hasOptions) {
+      // Convert the keys on the options dict from camelCase to CONSTANT_CASE.
+      // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
+      // like, who cares, dude?) Also, this is an array, 8ecause it's handy
+      // for the iterating we're a8out to do. Also strip HTML from arguments
+      // that are literal strings - real HTML content should always be proper
+      // HTML objects (see html.js).
+      const processedOptions =
+        Object.entries(options).map(([k, v]) => [
+          k.replace(/[A-Z]/g, '_$&').toUpperCase(),
+          this.#sanitizeStringArg(v),
+        ]);
+
+      // Replacement time! Woot. Reduce comes in handy here!
+      output =
+        processedOptions.reduce(
+          (x, [k, v]) => x.replaceAll(`{${k}}`, v),
+          template);
+    } else {
+      // Without any options provided, just use the template as-is. This will
+      // still error if the template expected arguments, and otherwise will be
+      // the right value.
+      output = template;
+    }
 
     // Post-processing: if any expected arguments *weren't* replaced, that
     // is almost definitely an error.
-    if (output.match(/\{[A-Z_]+\}/)) {
+    if (output.match(/\{[A-Z][A-Z0-9_]*\}/)) {
       throw new Error(`Args in ${key} were missing - output: ${output}`);
     }
 
-    return output;
+    // Last caveat: Wrap the output in an HTML tag so that it doesn't get
+    // treated as unsanitized HTML if *it* gets passed as an argument to
+    // *another* formatString call.
+    return this.#wrapSanitized(output);
+  }
+
+  // Escapes HTML special characters so they're displayed as-are instead of
+  // treated by the browser as a tag. This does *not* have an effect on actual
+  // html.Tag objects, which are treated as sanitized by default (so that they
+  // can be nested inside strings at all).
+  #sanitizeStringArg(arg) {
+    const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML');
+
+    if (!escapeHTML) {
+      throw new Error(`escapeHTML unavailable`);
+    }
+
+    if (typeof arg !== 'string') {
+      return arg.toString();
+    }
+
+    return escapeHTML(arg);
+  }
+
+  // Wraps the output of a formatting function in a no-name-nor-attributes
+  // HTML tag, which will indicate to other calls to formatString that this
+  // content is a string *that may contain HTML* and doesn't need to
+  // sanitized any further. It'll still .toString() to just the string
+  // contents, if needed.
+  #wrapSanitized(output) {
+    return new Tag(null, null, output);
+  }
+
+  // Similar to the above internal methods, but this one is public.
+  // It should be used when embedding content that may not have previously
+  // been sanitized directly into an HTML tag or template's contents.
+  // The templating engine usually handles this on its own, as does passing
+  // a value (sanitized or not) directly as an argument to formatString,
+  // but if you used a custom validation function ({validate: v => v.isHTML}
+  // instead of {type: 'string'} / {type: 'html'}) and are embedding the
+  // contents of a slot directly, it should be manually sanitized with this
+  // function first.
+  sanitize(arg) {
+    const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML');
+
+    if (!escapeHTML) {
+      throw new Error(`escapeHTML unavailable`);
+    }
+
+    return (
+      (typeof arg === 'string'
+        ? new Tag(null, null, escapeHTML(arg))
+        : arg));
   }
 
   formatDate(date) {
@@ -252,19 +325,32 @@ export class Language extends Thing {
   // Conjunction list: A, B, and C
   formatConjunctionList(array) {
     this.assertIntlAvailable('intl_listConjunction');
-    return this.intl_listConjunction.format(array.map(arr => arr.toString()));
+    return this.#wrapSanitized(
+      this.intl_listConjunction.format(
+        array.map(item => this.#sanitizeStringArg(item))));
   }
 
   // Disjunction lists: A, B, or C
   formatDisjunctionList(array) {
     this.assertIntlAvailable('intl_listDisjunction');
-    return this.intl_listDisjunction.format(array.map(arr => arr.toString()));
+    return this.#wrapSanitized(
+      this.intl_listDisjunction.format(
+        array.map(item => this.#sanitizeStringArg(item))));
   }
 
   // Unit lists: A, B, C
   formatUnitList(array) {
     this.assertIntlAvailable('intl_listUnit');
-    return this.intl_listUnit.format(array.map(arr => arr.toString()));
+    return this.#wrapSanitized(
+      this.intl_listUnit.format(
+        array.map(item => this.#sanitizeStringArg(item))));
+  }
+
+  // Lists without separator: A B C
+  formatListWithoutSeparator(array) {
+    return this.#wrapSanitized(
+      array.map(item => this.#sanitizeStringArg(item))
+        .join(' '));
   }
 
   // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB