diff options
Diffstat (limited to 'src/data/things/language.js')
| -rw-r--r-- | src/data/things/language.js | 234 |
1 files changed, 144 insertions, 90 deletions
diff --git a/src/data/things/language.js b/src/data/things/language.js index 4e23cf7f..7f3f43de 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,24 +1,25 @@ -import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; +import {Temporal, toTemporalInstant} from '@js-temporal/polyfill'; import {withAggregate} from '#aggregate'; -import CacheableObject from '#cacheable-object'; import {logWarn} from '#cli'; +import {input, V} from '#composite'; import * as html from '#html'; -import {empty} from '#sugar'; -import {isLanguageCode} from '#validators'; +import {accumulateSum, empty, withEntries} from '#sugar'; +import {isLanguageCode, isObject} from '#validators'; import Thing from '#thing'; +import {languageOptionRegex} from '#wiki-data'; import { + externalLinkSpec, getExternalLinkStringOfStyleFromDescriptors, getExternalLinkStringsFromDescriptors, isExternalLinkContext, - isExternalLinkSpec, isExternalLinkStyle, } from '#external-links'; -import {externalFunction, flag, name} from '#composite/wiki-properties'; - -export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g; +import {exitWithoutDependency, exposeConstant} + from '#composite/control-flow'; +import {flag, name} from '#composite/wiki-properties'; export class Language extends Thing { static [Thing.getPropertyDescriptors] = () => ({ @@ -34,7 +35,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: name(`Unnamed Language`), + name: name(V(`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 @@ -56,20 +57,29 @@ 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: flag(false), + hidden: flag(V(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'}, + strings: [ + { + dependencies: [ + input.updateValue({validate: isObject}), + 'inheritedStrings', + ], + + compute: (continuation, { + [input.updateValue()]: strings, + ['inheritedStrings']: inheritedStrings, + }) => + (strings && inheritedStrings + ? continuation() + : strings ?? inheritedStrings), + }, - expose: { + { dependencies: ['inheritedStrings', 'code'], transform(strings, {inheritedStrings, code}) { - if (!strings && !inheritedStrings) return null; - if (!inheritedStrings) return strings; - const validStrings = { ...inheritedStrings, ...strings, @@ -98,6 +108,7 @@ export class Language extends Thing { logWarn`- Missing options: ${missingOptionNames.join(', ')}`; if (!empty(misplacedOptionNames)) logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`; + validStrings[key] = inheritedStrings[key]; } } @@ -105,7 +116,7 @@ export class Language extends Thing { return validStrings; }, }, - }, + ], // May be provided to specify "default" strings, generally (but not // necessarily) inherited from another Language object. @@ -114,34 +125,22 @@ 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 #external-links for info. - externalLinkSpec: { - flags: {update: true, expose: true}, - update: {validate: isExternalLinkSpec}, - }, - - // Update only - - escapeHTML: externalFunction(), - // Expose only - onlyIfOptions: { - flags: {expose: true}, - expose: { - compute: () => Symbol.for(`language.onlyIfOptions`), - }, - }, + isLanguage: exposeConstant(V(true)), + + onlyIfOptions: exposeConstant(V(Symbol.for(`language.onlyIfOptions`))), intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}), + intl_dateMonthDay: this.#intlHelper(Intl.DateTimeFormat, {month: 'numeric', day: 'numeric'}), intl_number: this.#intlHelper(Intl.NumberFormat), intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}), intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}), intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}), intl_pluralOrdinal: this.#intlHelper(Intl.PluralRules, {type: 'ordinal'}), + intl_wordSegmenter: this.#intlHelper(Intl.Segmenter, {granularity: 'word'}), validKeys: { flags: {expose: true}, @@ -159,19 +158,16 @@ 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}; - return Object.fromEntries( - Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)]) - ); - }, + strings_htmlEscaped: [ + exitWithoutDependency('strings'), + + { + dependencies: ['strings'], + compute: ({strings}) => + withEntries(strings, entries => entries + .map(([key, value]) => [key, html.escape(value)])), }, - }, + ], }); static #intlHelper (constructor, opts) { @@ -192,18 +188,35 @@ export class Language extends Thing { return this.formatString(...args); } + $order(...args) { + return this.orderStringOptions(...args); + } + assertIntlAvailable(property) { if (!this[property]) { throw new Error(`Intl API ${property} unavailable`); } } + countWords(text) { + this.assertIntlAvailable('intl_wordSegmenter'); + + const string = html.resolve(text, {normalize: 'plain'}); + const segments = this.intl_wordSegmenter.segment(string); + + return accumulateSum(segments, segment => segment.isWordLike ? 1 : 0); + } + getUnitForm(value) { this.assertIntlAvailable('intl_pluralCardinal'); return this.intl_pluralCardinal.select(value); } formatString(...args) { + if (typeof args.at(-1) === 'function') { + throw new Error(`Passed function - did you mean language.encapsulate() instead?`); + } + const hasOptions = typeof args.at(-1) === 'object' && args.at(-1) !== null; @@ -211,19 +224,14 @@ export class Language extends Thing { const key = this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args); + const template = + this.#getStringTemplateFromFormedKey(key); + const options = (hasOptions ? args.at(-1) : {}); - if (!this.strings) { - throw new Error(`Strings unavailable`); - } - - if (!this.validKeys.includes(key)) { - throw new Error(`Invalid key ${key} accessed`); - } - const constantCasify = name => name .replace(/[A-Z]/g, '_$&') @@ -264,8 +272,7 @@ export class Language extends Thing { ])); const output = this.#iterateOverTemplate({ - template: this.strings[key], - + template, match: languageOptionRegex, insert: ({name: optionName}, canceledForming) => { @@ -310,7 +317,7 @@ export class Language extends Thing { return undefined; } - return optionValue; + return this.sanitize(optionValue); }, }); @@ -345,6 +352,46 @@ export class Language extends Thing { return output; } + orderStringOptions(...args) { + let slice = null, at = null, parts = null; + if (args.length >= 2 && typeof args.at(-1) === 'number') { + if (args.length >= 3 && typeof args.at(-2) === 'number') { + slice = [args.at(-2), args.at(-1)]; + parts = args.slice(0, -2); + } else { + at = args.at(-1); + parts = args.slice(0, -1); + } + } else { + parts = args; + } + + const template = this.getStringTemplate(...parts); + const matches = Array.from(template.matchAll(languageOptionRegex)); + const options = matches.map(({groups}) => groups.name); + + if (slice !== null) return options.slice(...slice); + if (at !== null) return options.at(at); + return options; + } + + getStringTemplate(...args) { + const key = this.#joinKeyParts(args); + return this.#getStringTemplateFromFormedKey(key); + } + + #getStringTemplateFromFormedKey(key) { + if (!this.strings) { + throw new Error(`Strings unavailable`); + } + + if (!this.validKeys.includes(key)) { + throw new Error(`Invalid key ${key} accessed`); + } + + return this.strings[key]; + } + #iterateOverTemplate({ template, match: regexp, @@ -375,26 +422,22 @@ export class Language extends Thing { 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); + const insertionItems = html.smush(insertion).content; + if (insertionItems.length === 1 && typeof insertionItems[0] !== 'string') { + // Push the insertion exactly as it is, rather than manipulating. + if (partInProgress) outputParts.push(partInProgress); + outputParts.push(insertion); partInProgress = ''; + } else for (const insertionItem of insertionItems) { + if (typeof insertionItem === 'string') { + // Join consecutive strings together. + partInProgress += insertionItem; + } else { + // Push the string part in progress, then the insertion as-is. + if (partInProgress) outputParts.push(partInProgress); + outputParts.push(insertionItem); + partInProgress = ''; + } } lastIndex = match.index + match[0].length; @@ -426,14 +469,9 @@ export class Language extends Thing { // 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); + return html.escape(value); case 'number': case 'boolean': @@ -510,6 +548,15 @@ export class Language extends Thing { return this.intl_dateYear.format(date); } + formatMonthDay(date) { + if (date === null || date === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_dateMonthDay'); + return this.intl_dateMonthDay.format(date); + } + formatYearRange(startDate, endDate) { // formatYearRange expects both values to be present, but if both are null // or both are undefined, that's just blank content. @@ -688,10 +735,6 @@ export class Language extends Thing { style = 'platform', context = 'generic', } = {}) { - if (!this.externalLinkSpec) { - throw new TypeError(`externalLinkSpec unavailable`); - } - // Null or undefined url is blank content. if (url === null || url === undefined) { return html.blank(); @@ -700,7 +743,7 @@ export class Language extends Thing { isExternalLinkContext(context); if (style === 'all') { - return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, { language: this, context, }); @@ -709,7 +752,7 @@ export class Language extends Thing { isExternalLinkStyle(style); const result = - getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { + getExternalLinkStringOfStyleFromDescriptors(url, style, externalLinkSpec, { language: this, context, }); @@ -865,6 +908,18 @@ export class Language extends Thing { } } + typicallyLowerCase(string) { + // Utter nonsense implementation, so this only works on strings, + // not actual HTML content, and may rudely disrespect *intentful* + // capitalization of whatever goes into it. + + if (typeof string !== 'string') return string; + if (string.length <= 1) return string; + if (/^\S+?[A-Z]/.test(string)) return string; + + return string[0].toLowerCase() + string.slice(1); + } + // Utility function to quickly provide a useful string key // (generally a prefix) to stuff nested beneath it. encapsulate(...args) { @@ -923,7 +978,6 @@ Object.assign(Language.prototype, { countArtworks: countHelper('artworks'), countCommentaryEntries: countHelper('commentaryEntries', 'entries'), countContributions: countHelper('contributions'), - countCoverArts: countHelper('coverArts'), countDays: countHelper('days'), countFlashes: countHelper('flashes'), countMonths: countHelper('months'), |