« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/data/things.js199
-rw-r--r--src/data/yaml.js10
-rw-r--r--src/util/strings.js63
3 files changed, 210 insertions, 62 deletions
diff --git a/src/data/things.js b/src/data/things.js
index c02e07e5..a37ede7b 100644
--- a/src/data/things.js
+++ b/src/data/things.js
@@ -87,6 +87,12 @@ export class FlashAct extends CacheableObject {}
 // -> WikiInfo
 export class WikiInfo extends CacheableObject {}
 
+// -> Language
+export class Language extends CacheableObject {}
+
+// -> BuildDirective
+export class BuildDirective extends CacheableObject {}
+
 // Before initializing property descriptors, set additional independent
 // constants on the classes (which are referenced later).
 
@@ -1363,3 +1369,196 @@ WikiInfo.propertyDescriptors = {
     enableArtTagUI: Thing.common.flag(false),
     enableGroupUI: Thing.common.flag(false),
 };
+
+// -> Language
+
+const intlHelper = (constructor, opts) => ({
+    flags: {expose: true},
+    expose: {
+        dependencies: ['code', 'intlCode'],
+        compute: ({ code, intlCode }) => {
+            const constructCode = intlCode ?? code;
+            if (!constructCode) return null;
+            return Reflect.construct(constructor, [constructCode, opts]);
+        }
+    }
+});
+
+Language.propertyDescriptors = {
+    // Update & expose
+
+    // General language code. This is used to identify the language distinctly
+    // from other languages (similar to how "Directory" operates in many data
+    // objects).
+    code: {
+        flags: {update: true, expose: true},
+        update: {validate: isLanguageCode}
+    },
+
+    // Language code specific to JavaScript's Internationalization (Intl) API.
+    // Usually this will be the same as the language's general code, but it
+    // may be overridden to provide Intl constructors an alternative value.
+    intlCode: {
+        flags: {update: true, expose: true},
+        update: {validate: isLanguageCode},
+        expose: {
+            dependencies: ['code'],
+            transform: (intlCode, { code }) => intlCode ?? code
+        }
+    },
+
+    // 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'}
+    },
+
+    // Expose only
+
+    intl_date: intlHelper(Intl.DateTimeFormat, {full: true}),
+    intl_number: intlHelper(Intl.NumberFormat),
+    intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}),
+    intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}),
+    intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}),
+    intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}),
+    intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}),
+
+    validKeys: {
+        flags: {expose: true},
+
+        expose: {
+            dependencies: ['strings'],
+            compute: ({ strings }) => strings ? Object.keys(strings) : []
+        }
+    },
+};
+
+const countHelper = (stringKey, argName = stringKey) => function(value, {unit = false} = {}) {
+    return this.$(
+        (unit
+            ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
+            : `count.${stringKey}`),
+        {[argName]: this.formatNumber(value)});
+};
+
+Object.assign(Language.prototype, {
+    $(key, args = {}) {
+        if (!this.validKeys.includes(key)) {
+            throw new Error(`Invalid key ${key} accessed`);
+        }
+
+        const template = this.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);
+
+        // Post-processing: if any expected arguments *weren't* replaced, that
+        // is almost definitely an error.
+        if (output.match(/\{[A-Z_]+\}/)) {
+            throw new Error(`Args in ${key} were missing - output: ${output}`);
+        }
+
+        return output;
+    },
+
+    assertIntlAvailable(property) {
+        if (!this[property]) {
+            throw new Error(`Intl API ${property} unavailable`);
+        }
+    },
+
+    getUnitForm(value) {
+        this.assertIntlAvailable('intl_pluralCardinal');
+        return this.intl_pluralCardinal.select(value);
+    },
+
+    formatDate(date) {
+        this.assertIntlAvailable('intl_date');
+        return this.intl_date.format(date);
+    },
+
+    formatDateRange(startDate, endDate) {
+        this.assertIntlAvailable('intl_date');
+        return this.intl_date.formatRange(startDate, endDate);
+    },
+
+    formatDuration(secTotal, {approximate = false, unit = false}) {
+        if (secTotal === 0) {
+            return strings('count.duration.missing');
+        }
+
+        const hour = Math.floor(secTotal / 3600);
+        const min = Math.floor((secTotal - hour * 3600) / 60);
+        const sec = Math.floor(secTotal - hour * 3600 - min * 60);
+
+        const pad = val => val.toString().padStart(2, '0');
+
+        const stringSubkey = unit ? '.withUnit' : '';
+
+        const duration = (hour > 0
+            ? this.$('count.duration.hours' + stringSubkey, {
+                hours: hour,
+                minutes: pad(min),
+                seconds: pad(sec)
+            })
+            : this.$('count.duration.minutes' + stringSubkey, {
+                minutes: min,
+                seconds: pad(sec)
+            }));
+
+        return (approximate
+            ? this.$('count.duration.approximate', {duration})
+            : duration);
+    },
+
+    formatIndex(value) {
+        this.assertIntlAvailable('intl_pluralOrdinal');
+        return this.$('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
+    },
+
+    formatNumber(value) {
+        this.assertIntlAvailable('intl_number');
+        return this.intl_number.format(value);
+    },
+
+    formatWordCount(value) {
+        const num = this.formatNumber(value > 1000
+            ? Math.floor(value / 100) / 10
+            : value);
+
+        const words = (value > 1000
+            ? strings('count.words.thousand', {words: num})
+            : strings('count.words', {words: num}));
+
+        return this.$('count.words.withUnit.' + this.getUnitForm(value), {words});
+    },
+
+    // TODO: These are hard-coded. Is there a better way?
+    countAlbums: countHelper('albums'),
+    countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
+    countContributions: countHelper('contributions'),
+    countCoverArts: countHelper('coverArts'),
+    countTimesReferenced: countHelper('timesReferenced'),
+    countTimesUsed: countHelper('timesUsed'),
+    countTracks: countHelper('tracks'),
+});
+
+// -> BuildDirective
+
+BuildDirective.propertyDescriptors = {
+    // Update & expose
+
+    directive: Thing.common.directory(),
+    baseDirectory: Thing.common.directory(),
+    language: Thing.common.simpleString(),
+};
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 4897d573..7e2d824a 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -11,6 +11,7 @@ import {
     Album,
     Artist,
     ArtTag,
+    BuildDirective,
     Flash,
     FlashAct,
     Group,
@@ -55,6 +56,7 @@ function inspect(value) {
 // --> YAML data repository structure constants
 
 export const WIKI_INFO_FILE = 'wiki-info.yaml';
+export const BUILD_DIRECTIVE_DATA_FILE = 'build-directives.yaml';
 export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml';
 export const ARTIST_DATA_FILE = 'artists.yaml';
 export const FLASH_DATA_FILE = 'flashes.yaml';
@@ -398,6 +400,14 @@ export const processWikiInfoDocument = makeProcessDocument(WikiInfo, {
     }
 });
 
+export const processBuildDirectiveDocument = makeProcessDocument(BuildDirective, {
+    propertyFieldMapping: {
+        directive: 'Directive',
+        baseDirectory: 'Base Directory',
+        language: 'Language',
+    }
+});
+
 export const processHomepageLayoutDocument = makeProcessDocument(HomepageLayout, {
     propertyFieldMapping: {
         sidebarContent: 'Sidebar Content'
diff --git a/src/util/strings.js b/src/util/strings.js
index 88dd5718..ffd257b1 100644
--- a/src/util/strings.js
+++ b/src/util/strings.js
@@ -43,7 +43,7 @@ import { bindOpts } from './sugar.js';
 export function genStrings(stringsJSON, {
     he,
     defaultJSON = null,
-    bindUtilities = []
+    bindUtilities = {}
 }) {
     // genStrings will only 8e called once for each language, and it happens
     // right at the start of the program (or at least 8efore 8uilding pages).
@@ -153,40 +153,6 @@ export function genStrings(stringsJSON, {
     const invalidKeysFound = [];
 
     const strings = (key, args = {}) => {
-        // Ok, with the warning out of the way, it's time to get to work.
-        // First make sure we're even accessing a valid key. (If not, return
-        // an error string as su8stitute.)
-        if (!validKeys.includes(key)) {
-            // We only want to warn a8out a given key once. More than that is
-            // just redundant!
-            if (!invalidKeysFound.includes(key)) {
-                invalidKeysFound.push(key);
-                logError`(${baseDirectory}) Accessing invalid key ${key}. Fix a typo or provide this in strings-default.json!`;
-            }
-            return `MISSING: ${key}`;
-        }
-
-        const template = stringIndex[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);
-
-        // Post-processing: if any expected arguments *weren't* replaced, that
-        // is almost definitely an error.
-        if (output.match(/\{[A-Z_]+\}/)) {
-            logError`(${baseDirectory}) Args in ${key} were missing - output: ${output}`;
-        }
-
-        return output;
     };
 
     // And lastly, we add some utility stuff to the strings function.
@@ -243,36 +209,9 @@ export const count = {
     },
 
     duration: (secTotal, {strings, approximate = false, unit = false}) => {
-        if (secTotal === 0) {
-            return strings('count.duration.missing');
-        }
-
-        const hour = Math.floor(secTotal / 3600);
-        const min = Math.floor((secTotal - hour * 3600) / 60);
-        const sec = Math.floor(secTotal - hour * 3600 - min * 60);
-
-        const pad = val => val.toString().padStart(2, '0');
-
-        const stringSubkey = unit ? '.withUnit' : '';
-
-        const duration = (hour > 0
-            ? strings('count.duration.hours' + stringSubkey, {
-                hours: hour,
-                minutes: pad(min),
-                seconds: pad(sec)
-            })
-            : strings('count.duration.minutes' + stringSubkey, {
-                minutes: min,
-                seconds: pad(sec)
-            }));
-
-        return (approximate
-            ? strings('count.duration.approximate', {duration})
-            : duration);
     },
 
     index: (value, {strings}) => {
-        return strings('count.index.' + strings.intl.plural.ordinal.select(value), {index: value});
     },
 
     number: value => strings.intl.number.format(value),