« get me outta code hell

strings.js « util « src - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util/strings.js
blob: c06643519c848ea7317a09ec89dadaa05474307b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
import { logWarn } from './cli.js';
import { bindOpts } from './sugar.js';

// Localiz8tion time! Or l10n as the neeeeeeeerds call it. Which is a terri8le
// name and not one I intend on using, thank you very much. (Don't even get me
// started on """"a11y"""".)
//
// All the default strings are in strings-default.json, if you're curious what
// those actually look like. Pretty much it's "I like {ANIMAL}" for example.
// For each language, the o8ject gets turned into a single function of form
// f(key, {args}). It searches for a key in the o8ject and uses the string it
// finds (or the one in strings-default.json) as a templ8 evaluated with the
// arguments passed. (This function gets treated as an o8ject too; it gets
// the language code attached.)
//
// The function's also responsi8le for getting rid of dangerous characters
// (quotes and angle tags), though only within the templ8te (not the args),
// and it converts the keys of the arguments o8ject from camelCase to
// CONSTANT_CASE too.
//
// This function also takes an optional "bindUtilities" argument; it should
// look like a dictionary each value of which is itself a util dictionary,
// each value of which is a function in the format (value, opts) => (...).
// Each of those util dictionaries will 8e attached to the final returned
// strings() function, containing functions which automatically have that
// same strings() function provided as part of its opts argument (alongside
// any additional arguments passed).
//
// Basically, it's so that instead of doing:
//
//     count.tracks(album.tracks.length, {strings})
//
// ...you can just do:
//
//     strings.count.tracks(album.tracks.length)
//
// Definitely note bindUtilities expects an OBJECT, not an array, otherwise
// it won't 8e a8le to know what keys to attach the utilities 8y!
//
// Oh also it'll need access to the he.encode() function, and callers have to
// provide that themselves, 'cuz otherwise we can't reference this file from
// client-side code.
export function genStrings(stringsJSON, {
    he,
    defaultJSON = null,
    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).
    // So, now's a good time to valid8te the strings and let any warnings be
    // known.

    // May8e contrary to the argument name, the arguments should 8e o8jects,
    // not actual JSON-formatted strings!
    if (typeof stringsJSON !== 'object' || stringsJSON.constructor !== Object) {
        return {error: `Expected an object (parsed JSON) for stringsJSON.`};
    }
    if (typeof defaultJSON !== 'object') { // typeof null === object. I h8 JS.
        return {error: `Expected an object (parsed JSON) or null for defaultJSON.`};
    }

    // All languages require a language code.
    const code = stringsJSON['meta.languageCode'];
    if (!code) {
        return {error: `Missing language code.`};
    }
    if (typeof code !== 'string') {
        return {error: `Expected language code to be a string.`};
    }

    // Every value on the provided o8ject should be a string.
    // (This is lazy, but we only 8other checking this on stringsJSON, on the
    // assumption that defaultJSON was passed through this function too, and so
    // has already been valid8ted.)
    {
        let err = false;
        for (const [ key, value ] of Object.entries(stringsJSON)) {
            if (typeof value !== 'string') {
                logError`(${code}) The value for ${key} should be a string.`;
                err = true;
            }
        }
        if (err) {
            return {error: `Expected all values to be a string.`};
        }
    }

    // Checking is generally done against the default JSON, so we'll skip out
    // if that isn't provided (which should only 8e the case when it itself is
    // 8eing processed as the first loaded language).
    if (defaultJSON) {
        // Warn for keys that are missing or unexpected.
        const expectedKeys = Object.keys(defaultJSON);
        const presentKeys = Object.keys(stringsJSON);
        for (const key of presentKeys) {
            if (!expectedKeys.includes(key)) {
                logWarn`(${code}) Unexpected translation key: ${key} - this won't be used!`;
            }
        }
        for (const key of expectedKeys) {
            if (!presentKeys.includes(key)) {
                logWarn`(${code}) Missing translation key: ${key} - this won't be localized!`;
            }
        }
    }

    // Valid8tion is complete, 8ut We can still do a little caching to make
    // repeated actions faster.

    // We're gonna 8e mut8ting the strings dictionary o8ject from here on out.
    // We make a copy so we don't mess with the one which was given to us.
    stringsJSON = Object.assign({}, stringsJSON);

    // Preemptively pass everything through HTML encoding. This will prevent
    // strings from embedding HTML tags or accidentally including characters
    // that throw HTML parsers off.
    for (const key of Object.keys(stringsJSON)) {
        stringsJSON[key] = he.encode(stringsJSON[key], {useNamedReferences: true});
    }

    // It's time to cre8te the actual langauge function!

    // In the function, we don't actually distinguish 8etween the primary and
    // default (fall8ack) strings - any relevant warnings have already 8een
    // presented a8ove, at the time the language JSON is processed. Now we'll
    // only 8e using them for indexing strings to use as templ8tes, and we can
    // com8ine them for that.
    const stringIndex = Object.assign({}, defaultJSON, stringsJSON);

    // We do still need the list of valid keys though. That's 8ased upon the
    // default strings. (Or stringsJSON, 8ut only if the defaults aren't
    // provided - which indic8tes that the single o8ject provided *is* the
    // default.)
    const validKeys = Object.keys(defaultJSON || 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`(${code}) 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`(${code}) Args in ${key} were missing - output: ${output}`;
        }

        return output;
    };

    // And lastly, we add some utility stuff to the strings function.

    // Store the language code, for convenience of access.
    strings.code = code;

    // Store the strings dictionary itself, also for convenience.
    strings.json = stringsJSON;

    // Store Intl o8jects that can 8e reused for value formatting.
    strings.intl = {
        date: new Intl.DateTimeFormat(code, {full: true}),
        number: new Intl.NumberFormat(code),
        list: {
            conjunction: new Intl.ListFormat(code, {type: 'conjunction'}),
            disjunction: new Intl.ListFormat(code, {type: 'disjunction'}),
            unit: new Intl.ListFormat(code, {type: 'unit'})
        },
        plural: {
            cardinal: new Intl.PluralRules(code, {type: 'cardinal'}),
            ordinal: new Intl.PluralRules(code, {type: 'ordinal'})
        }
    };

    // And the provided utility dictionaries themselves, of course!
    for (const [key, utilDict] of Object.entries(bindUtilities)) {
        const boundUtilDict = {};
        for (const [key, fn] of Object.entries(utilDict)) {
            boundUtilDict[key] = bindOpts(fn, {strings});
        }
        strings[key] = boundUtilDict;
    }

    return strings;
}

const countHelper = (stringKey, argName = stringKey) => (value, {strings, unit = false}) => strings(
    (unit
        ? `count.${stringKey}.withUnit.` + strings.intl.plural.cardinal.select(value)
        : `count.${stringKey}`),
    {[argName]: strings.intl.number.format(value)});

export const count = {
    date: (date, {strings}) => {
        return strings.intl.date.format(date);
    },

    dateRange: ([startDate, endDate], {strings}) => {
        return strings.intl.date.formatRange(startDate, endDate);
    },

    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),

    words: (value, {strings, unit = false}) => {
        const num = strings.intl.number.format(value > 1000
            ? Math.floor(value / 100) / 10
            : value);

        const words = (value > 1000
            ? strings('count.words.thousand', {words: num})
            : strings('count.words', {words: num}));

        return strings('count.words.withUnit.' + strings.intl.plural.cardinal.select(value), {words});
    },

    albums: countHelper('albums'),
    commentaryEntries: countHelper('commentaryEntries', 'entries'),
    contributions: countHelper('contributions'),
    coverArts: countHelper('coverArts'),
    timesReferenced: countHelper('timesReferenced'),
    timesUsed: countHelper('timesUsed'),
    tracks: countHelper('tracks')
};

const listHelper = type => (list, {strings}) => strings.intl.list[type].format(list);

export const list = {
    unit: listHelper('unit'),
    or: listHelper('disjunction'),
    and: listHelper('conjunction')
};