diff options
Diffstat (limited to 'src/data/things/language.js')
-rw-r--r-- | src/data/things/language.js | 520 |
1 files changed, 465 insertions, 55 deletions
diff --git a/src/data/things/language.js b/src/data/things/language.js index 3086ad2..dbe1ff3 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,11 +1,27 @@ -import Thing from './thing.js'; +import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; + +import {withAggregate} from '#aggregate'; +import CacheableObject from '#cacheable-object'; +import {logWarn} from '#cli'; +import * as html from '#html'; +import {empty} from '#sugar'; +import {isLanguageCode} from '#validators'; +import Thing from '#thing'; + +import { + getExternalLinkStringOfStyleFromDescriptors, + getExternalLinkStringsFromDescriptors, + isExternalLinkContext, + isExternalLinkSpec, + isExternalLinkStyle, +} from '#external-links'; + +import {externalFunction, flag, name} from '#composite/wiki-properties'; + +export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g; 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 +34,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: name(`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 @@ -40,21 +56,53 @@ 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. strings: { flags: {update: true, expose: true}, update: {validate: (t) => typeof t === 'object'}, + expose: { - dependencies: ['inheritedStrings'], - transform(strings, {inheritedStrings}) { - if (strings || inheritedStrings) { - return {...(inheritedStrings ?? {}), ...(strings ?? {})}; - } else { - return null; + dependencies: ['inheritedStrings', 'code'], + transform(strings, {inheritedStrings, code}) { + if (!strings && !inheritedStrings) return null; + if (!inheritedStrings) return strings; + + const validStrings = { + ...inheritedStrings, + ...strings, + }; + + const optionsFromTemplate = template => + Array.from(template.matchAll(languageOptionRegex)) + .map(({groups}) => groups.name); + + for (const [key, providedTemplate] of Object.entries(strings)) { + const inheritedTemplate = inheritedStrings[key]; + if (!inheritedTemplate) continue; + + const providedOptions = optionsFromTemplate(providedTemplate); + const inheritedOptions = optionsFromTemplate(inheritedTemplate); + + const missingOptionNames = + inheritedOptions.filter(name => !providedOptions.includes(name)); + + const misplacedOptionNames = + providedOptions.filter(name => !inheritedOptions.includes(name)); + + if (!empty(missingOptionNames) || !empty(misplacedOptionNames)) { + logWarn`Not using ${code ?? '(no code)'} string ${key}:`; + if (!empty(missingOptionNames)) + logWarn`- Missing options: ${missingOptionNames.join(', ')}`; + if (!empty(misplacedOptionNames)) + logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`; + validStrings[key] = inheritedStrings[key]; + } } + + return validStrings; }, }, }, @@ -66,9 +114,16 @@ 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 util/external-links.js for info. + externalLinkSpec: { + flags: {update: true, expose: true}, + update: {validate: isExternalLinkSpec}, + }, + // Update only - escapeHTML: Thing.common.externalFunction(), + escapeHTML: externalFunction(), // Expose only @@ -95,13 +150,14 @@ 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 ?? {})}; + const allStrings = {...inheritedStrings, ...strings}; return Object.fromEntries( Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)]) ); @@ -124,8 +180,8 @@ export class Language extends Thing { }; } - $(key, args = {}) { - return this.formatString(key, args); + $(...args) { + return this.formatString(...args); } assertIntlAvailable(property) { @@ -139,20 +195,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) + : {}); - formatStringHelper(strings, key, args = {}) { - if (!strings) { + if (!this.strings) { throw new Error(`Strings unavailable`); } @@ -160,32 +218,203 @@ 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 - ); + // These will be filled up as we iterate over the template, slotting in + // each option (if it's present). + const missingOptionNames = new Set(); + + // 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(), + value, + ])); + + const output = this.#iterateOverTemplate({ + template: this.strings[key], + + 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); + } - // 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}`); - } + // 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 (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; + } + }, + }); + + const misplacedOptionNames = + Array.from(optionsMap.keys()); + + withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => { + if (!empty(missingOptionNames)) { + const names = Array.from(missingOptionNames).join(`, `); + push(new Error(`Missing options: ${names}`)); + } + + if (!empty(misplacedOptionNames)) { + const names = Array.from(misplacedOptionNames).join(`, `); + push(new Error(`Unexpected options: ${names}`)); + } + }); return output; } + #iterateOverTemplate({ + template, + match: regexp, + insert: insertFn, + }) { + const outputParts = []; + + let canceledForming = false; + + let lastIndex = 0; + let partInProgress = ''; + + for (const match of template.matchAll(regexp)) { + const insertion = + insertFn(match.groups, canceledForming); + + if (insertion === undefined) { + canceledForming = true; + } + + // Don't proceed with forming logic if the insertion function has + // indicated that's not needed anymore - but continue iterating over + // the rest of the template's matches, so other iteration logic (with + // side effects) gets to process everything. + if (canceledForming) { + continue; + } + + 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); + partInProgress = ''; + } + + lastIndex = match.index + match[0].length; + } + + if (canceledForming) { + return undefined; + } + + // Tack onto the final partInProgress, which may still have a value by this + // point, if the final inserted value was a string. (Otherwise, it'll just + // be equal to the remaining template text.) + if (lastIndex < template.length) { + partInProgress += template.slice(lastIndex); + } + + if (partInProgress) { + outputParts.push(partInProgress); + } + + return this.#wrapSanitized(outputParts); + } + + // Processes a value so that it's suitable to be inserted into a template. + // For strings, this escapes HTML special characters, displaying them as-are + // instead of representing HTML markup. For numbers and booleans, this turns + // them into string values, so they never accidentally get caught as falsy + // by #html stringification. Everything else - most importantly including + // 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); + + case 'number': + case 'boolean': + return value.toString(); + + default: + return value; + } + } + + // 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(content) { + return html.tags(content, { + [html.blessAttributes]: true, + [html.joinChildren]: '', + [html.noEdgeWhitespace]: true, + }); + } + + // 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 for inserting into formatting + // functions, but if you used a custom slot validation function (for example, + // {validate: v => v.isHTML} instead of {type: 'string'} / {type: 'html'}) + // and are embedding the contents of the slot as a direct child of another + // tag, you should manually sanitize those contents with this function. + sanitize(value) { + if (typeof value === 'string') { + return this.#wrapSanitized(this.#sanitizeValueForInsertion(value)); + } else { + return value; + } + } + formatDate(date) { this.assertIntlAvailable('intl_date'); return this.intl_date.format(date); @@ -196,6 +425,108 @@ export class Language extends Thing { return this.intl_date.formatRange(startDate, endDate); } + formatDateDuration({ + years: numYears = 0, + months: numMonths = 0, + days: numDays = 0, + approximate = false, + }) { + let basis; + + const years = this.countYears(numYears, {unit: true}); + const months = this.countMonths(numMonths, {unit: true}); + const days = this.countDays(numDays, {unit: true}); + + if (numYears && numMonths && numDays) + basis = this.formatString('count.dateDuration.yearsMonthsDays', {years, months, days}); + else if (numYears && numMonths) + basis = this.formatString('count.dateDuration.yearsMonths', {years, months}); + else if (numYears && numDays) + basis = this.formatString('count.dateDuration.yearsDays', {years, days}); + else if (numYears) + basis = this.formatString('count.dateDuration.years', {years}); + else if (numMonths && numDays) + basis = this.formatString('count.dateDuration.monthsDays', {months, days}); + else if (numMonths) + basis = this.formatString('count.dateDuration.months', {months}); + else if (numDays) + basis = this.formatString('count.dateDuration.days', {days}); + else + return this.formatString('count.dateDuration.zero'); + + if (approximate) { + return this.formatString('count.dateDuration.approximate', { + duration: basis, + }); + } else { + return basis; + } + } + + formatRelativeDate(currentDate, referenceDate, { + considerRoundingDays = false, + approximate = true, + absolute = true, + } = {}) { + const currentInstant = toTemporalInstant.apply(currentDate); + const referenceInstant = toTemporalInstant.apply(referenceDate); + + const comparison = + Temporal.Instant.compare(currentInstant, referenceInstant); + + if (comparison === 0) { + return this.formatString('count.dateDuration.same'); + } + + const currentTDZ = currentInstant.toZonedDateTimeISO('Etc/UTC'); + const referenceTDZ = referenceInstant.toZonedDateTimeISO('Etc/UTC'); + + const earlierTDZ = (comparison === -1 ? currentTDZ : referenceTDZ); + const laterTDZ = (comparison === 1 ? currentTDZ : referenceTDZ); + + const {years, months, days} = + laterTDZ.since(earlierTDZ, { + largestUnit: 'year', + smallestUnit: + (considerRoundingDays + ? (laterTDZ.since(earlierTDZ, { + largestUnit: 'year', + smallestUnit: 'day', + }).years + ? 'month' + : 'day') + : 'day'), + roundingMode: 'halfCeil', + }); + + const duration = + this.formatDateDuration({ + years, months, days, + approximate: false, + }); + + const relative = + this.formatString( + 'count.dateDuration', + (approximate && (years || months || days) + ? (comparison === -1 + ? 'approximateEarlier' + : 'approximateLater') + : (comparison === -1 + ? 'earlier' + : 'later')), + {duration}); + + if (absolute) { + return this.formatString('count.dateDuration.relativeAbsolute', { + relative, + absolute: this.formatDate(currentDate), + }); + } else { + return relative; + } + } + formatDuration(secTotal, {approximate = false, unit = false} = {}) { if (secTotal === 0) { return this.formatString('count.duration.missing'); @@ -226,6 +557,37 @@ export class Language extends Thing { : duration; } + formatExternalLink(url, { + style = 'platform', + context = 'generic', + } = {}) { + if (!this.externalLinkSpec) { + throw new TypeError(`externalLinkSpec unavailable`); + } + + isExternalLinkContext(context); + + if (style === 'all') { + return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + language: this, + context, + }); + } + + isExternalLinkStyle(style); + + const result = + getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { + language: this, + context, + }); + + // It's possible for there to not actually be any string available for the + // given URL, style, and context, and we want this to be detectable via + // html.blank(). + return result ?? html.blank(); + } + formatIndex(value) { this.assertIntlAvailable('intl_pluralOrdinal'); return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); @@ -249,22 +611,64 @@ export class Language extends Thing { return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words}); } + #formatListHelper(array, processFn) { + // 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 + // at these points afterwards. + + const insertionMarkers = + Array.from( + {length: array.length}, + (_item, index) => `<::insertion_${index}>`); + + // Basically the same insertion logic as in formatString. Like there, we + // can't assume that insertion markers were kept in the same order as they + // were provided, so we'll refer to the marked index. But we don't need to + // worry about some of the indices *not* corresponding to a provided source + // item, like we do in formatString, so that cuts out a lot of the + // validation logic. + + return this.#iterateOverTemplate({ + template: processFn(insertionMarkers), + + match: /<::insertion_(?<index>[0-9]+)>/g, + + insert: ({index: markerIndex}) => { + return array[markerIndex]; + }, + }); + } + // Conjunction list: A, B, and C formatConjunctionList(array) { this.assertIntlAvailable('intl_listConjunction'); - return this.intl_listConjunction.format(array); + return this.#formatListHelper( + array, + array => this.intl_listConjunction.format(array)); } // Disjunction lists: A, B, or C formatDisjunctionList(array) { this.assertIntlAvailable('intl_listDisjunction'); - return this.intl_listDisjunction.format(array); + return this.#formatListHelper( + array, + array => this.intl_listDisjunction.format(array)); } // Unit lists: A, B, C formatUnitList(array) { this.assertIntlAvailable('intl_listUnit'); - return this.intl_listUnit.format(array); + return this.#formatListHelper( + array, + array => this.intl_listUnit.format(array)); + } + + // Lists without separator: A B C + formatListWithoutSeparator(array) { + return this.#formatListHelper( + array, + array => array.join(' ')); } // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB @@ -298,23 +702,29 @@ export class Language extends Thing { } } -const countHelper = (stringKey, argName = stringKey) => +const countHelper = (stringKey, optionName = stringKey) => function(value, {unit = false} = {}) { return this.formatString( unit ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) : `count.${stringKey}`, - {[argName]: this.formatNumber(value)}); + {[optionName]: this.formatNumber(value)}); }; // TODO: These are hard-coded. Is there a better way? Object.assign(Language.prototype, { countAdditionalFiles: countHelper('additionalFiles', 'files'), countAlbums: countHelper('albums'), + countArtworks: countHelper('artworks'), countCommentaryEntries: countHelper('commentaryEntries', 'entries'), countContributions: countHelper('contributions'), countCoverArts: countHelper('coverArts'), + countDays: countHelper('days'), + countFlashes: countHelper('flashes'), + countMonths: countHelper('months'), countTimesReferenced: countHelper('timesReferenced'), countTimesUsed: countHelper('timesUsed'), countTracks: countHelper('tracks'), + countWeeks: countHelper('weeks'), + countYears: countHelper('years'), }); |