diff options
Diffstat (limited to 'src/data/things/language.js')
-rw-r--r-- | src/data/things/language.js | 206 |
1 files changed, 177 insertions, 29 deletions
diff --git a/src/data/things/language.js b/src/data/things/language.js index dbe1ff3d..f20927a4 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -127,6 +127,13 @@ export class Language extends Thing { // Expose only + onlyIfOptions: { + flags: {expose: true}, + expose: { + compute: () => Symbol.for(`language.onlyIfOptions`), + }, + }, + intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), intl_number: this.#intlHelper(Intl.NumberFormat), intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), @@ -218,18 +225,42 @@ export class Language extends Thing { throw new Error(`Invalid key ${key} accessed`); } + const constantCasify = name => + name + .replace(/[A-Z]/g, '_$&') + .toUpperCase(); + // These will be filled up as we iterate over the template, slotting in // each option (if it's present). const missingOptionNames = new Set(); + // These will also be filled. It's a bit different of an error, indicating + // a provided option was *expected,* but its value was null, undefined, or + // blank HTML content. + const valuelessOptionNames = new Set(); + + // These *might* be missing, and if they are, that's OK!! Instead of adding + // to the valueless set above, we'll just mark to return a blank for the + // whole string. + const expectedValuelessOptionNames = + new Set( + (options[this.onlyIfOptions] ?? []) + .map(constantCasify)); + + let seenExpectedValuelessOption = false; + + const isValueless = + value => + value === null || + value === undefined || + html.isBlank(value); + // And this will have entries deleted as they're encountered in the // template. Leftover entries are misplaced. const optionsMap = new Map( Object.entries(options).map(([name, value]) => [ - name - .replace(/[A-Z]/g, '_$&') - .toUpperCase(), + constantCasify(name), value, ])); @@ -239,32 +270,48 @@ export class Language extends Thing { match: languageOptionRegex, insert: ({name: optionName}, canceledForming) => { - if (optionsMap.has(optionName)) { - let optionValue; - - // We'll only need the option's value if we're going to use it as - // part of the formed output (see below). - if (!canceledForming) { - optionValue = optionsMap.get(optionName); - } - - // But we always have to delete expected options off the provided - // option map, since the leftovers are what will be used to tell - // which are misplaced. - optionsMap.delete(optionName); + if (!optionsMap.has(optionName)) { + missingOptionNames.add(optionName); - if (canceledForming) { - return undefined; - } else { - return optionValue; - } - } else { // We don't need to continue forming the output if we've hit a // missing option name, since the end result of this formatString // call will be a thrown error, and formed output won't be needed. - missingOptionNames.add(optionName); + // Return undefined to mark canceledForming for the following + // iterations (and exit early out of this iteration). + return undefined; + } + + // Even if we're not actually forming the output anymore, we'll still + // have to access this option's value to check if it is invalid. + const optionValue = optionsMap.get(optionName); + + // We always have to delete expected options off the provided option + // map, since the leftovers are what will be used to tell which are + // misplaced - information you want even (or doubly so) if we've + // already stopped forming the output thanks to missing options. + optionsMap.delete(optionName); + + // Just like if an option is missing, a valueless option cancels + // forming the rest of the output. + if (isValueless(optionValue)) { + // It's also an error, *except* if this option is one of the ones + // that we're indicated to *expect* might be valueless! In that case, + // we still need to stop forming the string (and mark a separate flag + // so that we return a blank), but it's not an error. + if (expectedValuelessOptionNames.has(optionName)) { + seenExpectedValuelessOption = true; + } else { + valuelessOptionNames.add(optionName); + } + return undefined; } + + if (canceledForming) { + return undefined; + } + + return optionValue; }, }); @@ -272,17 +319,30 @@ export class Language extends Thing { Array.from(optionsMap.keys()); withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => { + const names = set => Array.from(set).join(', '); + if (!empty(missingOptionNames)) { - const names = Array.from(missingOptionNames).join(`, `); - push(new Error(`Missing options: ${names}`)); + push(new Error( + `Missing options: ${names(missingOptionNames)}`)); + } + + if (!empty(valuelessOptionNames)) { + push(new Error( + `Valueless options: ${names(valuelessOptionNames)}`)); } if (!empty(misplacedOptionNames)) { - const names = Array.from(misplacedOptionNames).join(`, `); - push(new Error(`Unexpected options: ${names}`)); + push(new Error( + `Unexpected options: ${names(misplacedOptionNames)}`)); } }); + // If an option was valueless as marked to expect, then that indicates + // the whole string should be treated as blank content. + if (seenExpectedValuelessOption) { + return html.blank(); + } + return output; } @@ -416,11 +476,32 @@ export class Language extends Thing { } formatDate(date) { + // Null or undefined date is blank content. + if (date === null || date === undefined) { + return html.blank(); + } + this.assertIntlAvailable('intl_date'); return this.intl_date.format(date); } formatDateRange(startDate, endDate) { + // formatDateRange expects both values to be present, but if both are null + // or both are undefined, that's just blank content. + const hasStart = startDate !== null && startDate !== undefined; + const hasEnd = endDate !== null && endDate !== undefined; + if (!hasStart || !hasEnd) { + if (startDate === endDate) { + return html.blank(); + } else if (hasStart) { + throw new Error(`Expected both start and end of date range, got only start`); + } else if (hasEnd) { + throw new Error(`Expected both start and end of date range, got only end`); + } else { + throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`); + } + } + this.assertIntlAvailable('intl_date'); return this.intl_date.formatRange(startDate, endDate); } @@ -431,6 +512,17 @@ export class Language extends Thing { days: numDays = 0, approximate = false, }) { + // Give up if any of years, months, or days is null or undefined. + // These default to zero, so something's gone pretty badly wrong to + // pass in all or partial missing values. + if ( + numYears === undefined || numYears === null || + numMonths === undefined || numMonths === null || + numDays === undefined || numDays === null + ) { + throw new Error(`Expected values or default zero for years, months, and days`); + } + let basis; const years = this.countYears(numYears, {unit: true}); @@ -468,6 +560,14 @@ export class Language extends Thing { approximate = true, absolute = true, } = {}) { + // Give up if current and/or reference date is null or undefined. + if ( + currentDate === undefined || currentDate === null || + referenceDate === undefined || referenceDate === null + ) { + throw new Error(`Expected values for currentDate and referenceDate`); + } + const currentInstant = toTemporalInstant.apply(currentDate); const referenceInstant = toTemporalInstant.apply(referenceDate); @@ -528,6 +628,12 @@ export class Language extends Thing { } formatDuration(secTotal, {approximate = false, unit = false} = {}) { + // Null or undefined duration is blank content. + if (secTotal === null || secTotal === undefined) { + return html.blank(); + } + + // Zero duration is a "missing" string. if (secTotal === 0) { return this.formatString('count.duration.missing'); } @@ -565,6 +671,11 @@ export class Language extends Thing { throw new TypeError(`externalLinkSpec unavailable`); } + // Null or undefined url is blank content. + if (url === null || url === undefined) { + return html.blank(); + } + isExternalLinkContext(context); if (style === 'all') { @@ -589,16 +700,31 @@ export class Language extends Thing { } formatIndex(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + this.assertIntlAvailable('intl_pluralOrdinal'); return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); } formatNumber(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + this.assertIntlAvailable('intl_number'); return this.intl_number.format(value); } formatWordCount(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + const num = this.formatNumber( value > 1000 ? Math.floor(value / 100) / 10 : value ); @@ -612,6 +738,11 @@ export class Language extends Thing { } #formatListHelper(array, processFn) { + // Empty lists, null, and undefined are blank content. + if (empty(array) || array === null || array === undefined) { + return html.blank(); + } + // Operate on "insertion markers" instead of the actual contents of the // array, because the process function (likely an Intl operation) is taken // to only operate on strings. We'll insert the contents of the array back @@ -673,10 +804,22 @@ export class Language extends Thing { // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB formatFileSize(bytes) { - if (!bytes) return ''; + // Null or undefined bytes is blank content. + if (bytes === null || bytes === undefined) { + return html.blank(); + } + + // Zero bytes is blank content. + if (bytes === 0) { + return html.blank(); + } bytes = parseInt(bytes); - if (isNaN(bytes)) return ''; + + // Non-number bytes is blank content! Wow. + if (isNaN(bytes)) { + return html.blank(); + } const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10; @@ -704,6 +847,11 @@ export class Language extends Thing { const countHelper = (stringKey, optionName = stringKey) => function(value, {unit = false} = {}) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + return this.formatString( unit ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) |