UNPKG

tiny-essentials

Version:

Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.

874 lines (872 loc) 35.2 kB
import { readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join as pathJoin } from 'path'; /** * Supported operating modes for TinyI18. * - `"local"` → All translations are managed entirely in memory (browser + Node.js). * - `"file"` → Translations are loaded from JSON files on disk (Node.js only). * @typedef {"local" | "file"} ModeTypes */ /** * Dictionary of translation keys mapped to values. * * @typedef {Record<string, any>} Dict */ /** * Represents a translation entry as it appears in JSON files. * * @typedef {{ * $pattern: string, * $fn: string, * [key: string]: string * }} FileValue * * `$pattern` - Regex string (will be compiled into RegExp). * * `$fn` - Helper function name associated with this entry. * `[key]` - Additional key-value pairs for interpolation or extra metadata. */ /** * Represents a valid locale code string. * Example: "en", "pt-BR", "fr". * * @typedef {string} LocaleCode */ /** * Value types stored per locale: * - string: a direct translation with optional {named} interpolation * - Function: (params, helpers) => any (advanced rendering; HTML-safe if you control it) * - { $pattern: RegExp, value: string | { $fn: string, args?: any } | Function } * - { $fn: string, args?: any } // in file mode; resolved to a registered function */ /** * Represents a single regex-based translation entry. * * @typedef {Object} PatternEntry * @property {RegExp} $pattern - Compiled regular expression used for matching. * @property {any} value - Translation value or resolver function associated with the pattern. * @property {any} elseValue - Translation value or resolver function associated with the pattern. */ /** * @typedef {Object} FileModeEntryJSON * @property {string} [$pattern] - regex as string e.g. "^user\\.(\\d+)$" * @property {string|Object} value - string or { $fn: string, args?: any } * @property {string|Object} elseValue - string or { $fn: string, args?: any } */ /** * @typedef {Object} TinyI18Options * @property {ModeTypes} mode * @property {LocaleCode} defaultLocale * @property {string} [basePath] - Required in file mode. Directory with <locale>.json * @property {Dict} [localResources] - Optional initial map { locale: dict } for local mode * @property {boolean} [strict=true] - If true, throws on missing keys; else returns key */ /** * @typedef {Object} ResolveOptions * @property {LocaleCode} [locale] - force resolve using a specific locale first */ /** * Represents statistics for a specific locale. * * @typedef {Object} StatLocale * @property {string} locale - The locale code (e.g., "en", "pt-BR"). * @property {number} strings - Total number of static string entries in this locale. * @property {number} patterns - Total number of regex-based pattern entries in this locale. * @property {boolean} isDefault - Whether this locale is the default fallback. * @property {boolean} isCurrent - Whether this locale is currently loaded/active. */ /** * Read-only view of registered helpers, exposed to function-based entries. * * Provides safe access to: * - check if a helper exists by name * - call a helper by name, passing arguments * * @template {any} T * @template {any} R * @typedef {Object} HelpersReadonly * @property {(name: string) => boolean} [has] - Check if a helper with given name is registered. * @property {(name: string, arg: T, extras?: HelpersReadonly<T, R>) => R} [call] - * Invoke a helper by name with an argument and optional extras. */ /** * A helper callback function used in translations. * * - Receives user-supplied parameters and a read-only facade for calling other helpers. * - Must return a string (e.g. processed, interpolated, or formatted output). * * @callback HelperCallback * @param {Dict} params - Key-value parameters passed from the translation entry. * @param {HelpersReadonly<any, any>} helpers - Read-only access to other registered helpers. * @returns {string} Processed string result. */ /** * TinyI18 — Professional and flexible i18n manager with dual mode (local/file), * regex-based keys, and function-based entries for advanced rendering (incl. HTML). * * - Mode "local": in-memory resources (Node + Browser). * - Mode "file": JSON files on disk via fs/path (Node only). * - Keeps only default + selected locale in memory. * - Selected locale overrides default; fallback resolves to default. * - Supports string entries, regex pattern entries, and function-backed entries. * - Safe: no dynamic code eval from files; functions in file mode are referenced by name ("$fn"). */ class TinyI18 { /** * Merges multiple JSON locale files into a single file for TinyI18 usage. * @param {Object} options * @param {string[]} options.files - List of JSON file paths to merge. * @param {string} options.output - Path where the merged JSON file will be written. * @param {number} [options.spaces=0] - Number of spaces to use for indentation in the output file (0 for compact JSON). * @throws {TypeError} If arguments are invalid. * @throws {Error} If file reading or writing fails. */ static async mergeLocaleFiles({ files, output, spaces = 0 }) { if (typeof spaces !== 'number' || Number.isNaN(spaces) || spaces < 0) throw new TypeError('mergeLocaleFiles: "spaces" must be a non-negative number'); if (!Array.isArray(files) || files.length === 0) throw new TypeError('mergeLocaleFiles: "files" must be a non-empty array of file paths'); if (typeof output !== 'string' || !output) throw new TypeError('mergeLocaleFiles: "output" must be a non-empty string path'); const merged = {}; for (const filePath of files) { if (typeof filePath !== 'string' || !filePath) throw new TypeError('mergeLocaleFiles: each file path must be a non-empty string'); if (!existsSync(filePath)) throw new Error(`mergeLocaleFiles: file "${filePath}" does not exist`); const raw = await readFile(filePath, 'utf-8'); let json; try { json = JSON.parse(raw); } catch (err) { throw new Error(`mergeLocaleFiles: file "${filePath}" is not valid JSON`); } // Deep merge (locale-based) Object.assign(merged, json); } await writeFile(output, JSON.stringify(merged, null, spaces), 'utf-8'); } /** @type {ModeTypes} */ #mode; /** @type {LocaleCode} */ #defaultLocale; /** @type {LocaleCode|null} */ #currentLocale = null; /** @type {boolean} */ #strict; /** @type {string|null} */ #basePath = null; /** @type {Map<LocaleCode, Dict>} */ #stringTables = new Map(); // plain key => string | { $fn } | Function /** @type {Map<LocaleCode, PatternEntry[]>} */ #patternTables = new Map(); // array of { $pattern: RegExp, value } // Helpers registry for function-based entries in both modes. /** @type {Map<string, HelperCallback>} */ #helpers = new Map(); /** @type {Map<string, RegExp>} */ #regexCache = new Map(); /** * Gets the currently selected locale, or null if only default is active. * @returns {LocaleCode|null} */ get currentLocale() { return this.#currentLocale; } /** * The default locale code chosen at construction time. * This locale is always kept in memory as a fallback. * @type {LocaleCode} */ get defaultLocale() { return this.#defaultLocale; } /** * The current operating mode of this instance. * Determines whether translations are managed in memory ("local") * or loaded from JSON files ("file"). * @type {ModeTypes} */ get mode() { return this.#mode; } /** * Whether strict mode is enabled. * - `true` → Missing keys, invalid regex, or helper errors throw exceptions. * - `false` → Failures are ignored silently, returning fallback values. * @type {boolean} */ get strict() { return this.#strict; } /** * Base directory path used in `"file"` mode to locate locale JSON files. * - In `"local"` mode this will always be `null`. * - In `"file"` mode this is the root folder passed to the constructor. * @type {string|null} */ get basePath() { return this.#basePath; } /** * Returns basic stats for debugging/memory insights. * @returns {StatLocale[]} */ get stats() { const locales = []; for (const loc of this.#stringTables.keys()) locales.push(this.getStatsForLocale(loc)); return locales; } /** * Deep-cloned view of string tables (Map → Object). * Preserves strings, $fn objects, and functions. * @returns {Record<string, Dict>} */ get stringTables() { /** @type {Record<string, Dict>} */ const obj = {}; for (const [locale, dict] of this.#stringTables.entries()) { obj[locale] = this.#deepClone(dict); } return obj; } /** * Deep-cloned view of pattern tables (Map → Object). * Recreates RegExp objects to avoid mutation. * @returns {Record<string, PatternEntry[]>} */ get patternTables() { /** @type {Record<string, PatternEntry[]>} */ const obj = {}; for (const [locale, arr] of this.#patternTables.entries()) { obj[locale] = arr.map((e) => ({ $pattern: new RegExp(e.$pattern.source, e.$pattern.flags), value: this.#deepClone(e.value), elseValue: this.#deepClone(e.elseValue), })); } return obj; } /** * Deep-cloned view of helpers (Map → Object). * Functions are referenced (cannot deep clone functions). * @returns {Record<string, HelperCallback>} */ get helpers() { /** @type {Record<string, HelperCallback>} */ const obj = {}; for (const [name, fn] of this.#helpers.entries()) { obj[name] = fn; } return obj; } /** * Deep-cloned view of regex cache (Map → Object). * Recreates RegExp objects to avoid mutation. * @returns {Record<string, RegExp>} */ get regexCache() { /** @type {Record<string, RegExp>} */ const obj = {}; for (const [key, re] of this.#regexCache.entries()) { obj[key] = new RegExp(re.source, re.flags); } return obj; } // -------------------- Internal: resolution & materialization -------------------- /** * Utility for deep cloning values inside Maps. * - Strings are returned as-is * - Objects/arrays are recursively cloned * - Functions are returned as-is * - RegExp are cloned * @param {any} value * @returns {any} */ #deepClone(value) { if (value == null || typeof value === 'string' || typeof value === 'function' || typeof value === 'number') return value; if (value instanceof RegExp) return new RegExp(value.source, value.flags); if (Array.isArray(value)) return value.map((v) => this.#deepClone(v)); if (typeof value === 'object') { /** @type {Record<string, any>} */ const clone = {}; for (const [k, v] of Object.entries(value)) { clone[k] = this.#deepClone(v); } return clone; } return value; } /** * Determines the resolution order of locales. * * @param {string} [forceLocale] - Optional locale to prioritize first. * @returns {string[]} Ordered list of locale codes to try. */ #resolveOrder(forceLocale) { if (forceLocale !== undefined && typeof forceLocale !== 'string') throw new TypeError('#resolveOrder: "forceLocale" must be a string if provided'); /** @type {LocaleCode[]} */ const order = []; if (forceLocale && typeof forceLocale === 'string' && this.#stringTables.has(forceLocale)) order.push(forceLocale); else if (this.#currentLocale) order.push(this.#currentLocale); // default is always last if not skipped externally order.push(this.#defaultLocale); return order; } /** * Resolves a key exactly from a set of locales. * * @param {string[]} order - Array of locale codes in resolution order. * @param {string} key - Key to look up. * @returns {any} The value for the key if found, otherwise undefined. */ #resolveExact(order, key) { if (!Array.isArray(order) || !order.every((value) => typeof value === 'string')) throw new TypeError('#resolveExact: "order" must be an array of strings'); if (typeof key !== 'string' || !key) throw new TypeError('#resolveExact: "key" must be a non-empty string'); for (const loc of order) { const table = /** @type {Dict} */ (this.#stringTables.get(loc)); if (table && Object.prototype.hasOwnProperty.call(table, key)) { return table[key]; } } return undefined; } /** * Resolves a key by matching it against regex patterns in the locale tables. * * @param {string[]} order - Array of locale codes in resolution order. * @param {string} key - Key to match against patterns. * @returns {any} The value associated with the matching pattern, or undefined. */ #resolveByPattern(order, key) { if (!Array.isArray(order) || !order.every((value) => typeof value === 'string')) throw new TypeError('#resolveByPattern: "order" must be an array of strings'); if (typeof key !== 'string' || !key) throw new TypeError('#resolveByPattern: "key" must be a non-empty string'); for (const loc of order) { const patterns = this.#patternTables.get(loc) || []; for (const entry of patterns) { if (entry.$pattern.test(key)) return entry.value; else return entry.elseValue ?? undefined; } } return undefined; } /** * Value can be: * - string * - function (params, helpers) => any * - { $fn: string, args?: any } // file mode placeholder resolved via helpers * * @param {string | HelperCallback | { $fn: string; args?: any }} value * @param {Dict} [params] * @returns {string} */ #materialize(value, params) { if (value === null || value === undefined) { if (this.#strict) throw new TypeError('#materialize: "value" cannot be null or undefined'); return ''; } if (typeof value === 'string') return this.#interpolate(value, params); if (typeof value === 'function') return value(params ?? {}, this.#helpersReadonly()); if (value && typeof value === 'object' && typeof value.$fn === 'string') { const fn = this.#helpers.get(value.$fn); if (typeof fn !== 'function') { if (this.#strict) throw new Error(`TinyI18: helper "${value.$fn}" is not registered`); return ''; } const args = value.args !== undefined ? value.args : undefined; return fn({ ...(params ?? {}), args }, this.#helpersReadonly()); } // Unknown entry type if (this.#strict) throw new Error('TinyI18: unsupported entry type'); return ''; } /** * Interpolates values into a template string using {named} placeholders. * * @param {string} template - Template string containing placeholders. * @param {Dict} [params] - Object containing values to interpolate. * @returns {string} The interpolated string. */ #interpolate(template, params) { if (typeof template !== 'string') throw new TypeError('#interpolate: "template" must be a string'); if (params !== undefined && (params === null || typeof params !== 'object')) throw new TypeError('#interpolate: "params" must be an object if provided'); if (!params || typeof params !== 'object') return template; // Simple {name} interpolation, no ICU. Escapes are not added; caller controls HTML safety. return template.replace(/\{([a-zA-Z0-9_.$-]+)\}/g, (_, name) => { const val = this.#dotGet(params, name); return val === undefined || val === null ? '' : String(val); }); } /** * Safely retrieves a nested property from an object using dot notation. * * @param {Dict} obj - Object to retrieve from. * @param {string} path - Dot-separated path string (e.g., "a.b.c"). * @returns {Dict|undefined} Value at the given path, or undefined if any part is missing. */ #dotGet(obj, path) { if (!obj || typeof obj !== 'object') throw new TypeError('#dotGet: "obj" must be an object'); if (typeof path !== 'string' || !path) throw new TypeError('#dotGet: "path" must be a non-empty string'); const parts = path.split('.'); let cur = obj; for (const p of parts) { if (cur == null) return undefined; cur = cur[p]; } return cur; } /** * Provides a read-only facade for calling registered helpers safely. * * @returns {HelpersReadonly<any, any>} Read-only helper access. */ #helpersReadonly() { // Provide a minimal read-only facade for helpers to call other helpers safely if needed. return { has: (name) => this.#helpers.has(name), call: (name, arg, extras) => { const fn = this.#helpers.get(name); if (typeof fn !== 'function') throw new Error(`Helper "${name}" not found`); return fn(arg, extras ?? {}); }, }; } // -------------------- Internal: ingesting locale data -------------------- /** * Ingests and flattens locale data into the internal string and pattern tables. * * @param {string} locale - Locale code to ingest. * @param {Dict} raw - Raw locale data object. */ #ingestLocale(locale, raw) { if (typeof locale !== 'string' || !locale) throw new TypeError('#ingestLocale: "locale" must be a non-empty string'); if (!raw || typeof raw !== 'object') throw new TypeError('#ingestLocale: "raw" must be an object'); if (typeof locale !== 'string' || !locale) throw new TypeError('#ingestLocale: invalid locale'); if (!raw || typeof raw !== 'object') throw new TypeError('#ingestLocale: "raw" must be an object'); /** @type {Dict} */ const flat = { ...(this.#stringTables.get(locale) ?? {}) }; /** @type {PatternEntry[]} */ const patterns = [...(this.#patternTables.get(locale) ?? [])]; /** * @param {string} prefix * @param {Dict} node */ const walk = (prefix, node) => { if (node == null) return; if (typeof node === 'string' || typeof node === 'function' || (node && typeof node === 'object' && typeof node.$fn === 'string')) { if (!prefix) throw new TypeError('Leaf value requires a key path'); flat[prefix] = node; return; } if (node && typeof node === 'object') { // Pattern form in local mode can be { $pattern: RegExp, value: ... } if (node.$pattern instanceof RegExp && Object.prototype.hasOwnProperty.call(node, 'value')) { patterns.push({ $pattern: node.$pattern, value: node.value, elseValue: node.elseValue }); return; } for (const [k, v] of Object.entries(node)) { const path = prefix ? `${prefix}.${k}` : k; walk(path, v); } } }; walk('', raw); this.#stringTables.set(locale, flat); this.#patternTables.set(locale, patterns); // Ensure default exists if (!this.#stringTables.has(this.#defaultLocale)) { this.#stringTables.set(this.#defaultLocale, {}); this.#patternTables.set(this.#defaultLocale, []); } } /** * Unloads a previously loaded locale, except the default locale. * * @param {string} locale - Locale code to unload. */ #unloadLocale(locale) { if (typeof locale !== 'string' || !locale) throw new TypeError('#unloadLocale: "locale" must be a non-empty string'); if (locale === this.#defaultLocale) return; // never unload default this.#stringTables.delete(locale); this.#patternTables.delete(locale); } // -------------------- File mode loading -------------------- /** * Loads and flattens a locale JSON file into internal maps (file mode only). * * - Keys are dot-flattened. * - `$pattern` entries are compiled to RegExp and stored in the pattern table. * - `$fn` references are preserved for later resolution via helpers. * * @param {LocaleCode} locale - Locale identifier (e.g. "en", "pt-BR"). * @returns {Promise<void>} */ async #loadLocaleFromFile(locale) { if (typeof locale !== 'string' || !locale) throw new TypeError('#loadLocaleFromFile: "locale" must be a non-empty string'); if (typeof this.#basePath !== 'string' || !this.#basePath) throw new TypeError('#loadLocaleFromFile: "this.#basePath" must be a non-empty string'); const file = pathJoin(this.#basePath, `${locale}.json`); let json; try { const raw = await readFile(file, 'utf8'); json = JSON.parse(raw); } catch (err) { if (!(err instanceof Error)) return; if (this.#strict) throw new Error(`TinyI18: failed to load or parse ${file}: ${err.message}`); // register empty to avoid repeated I/O this.#stringTables.set(locale, {}); this.#patternTables.set(locale, []); return; } /** * Convert JSON to internal form: flatten + compile patterns + keep $fn placeholders * @type {Dict} */ const flat = { ...(this.#stringTables.get(locale) ?? {}) }; /** @type {PatternEntry[]} */ const patterns = [...(this.#patternTables.get(locale) ?? [])]; /** * @param {string} prefix * @param {FileValue|string|null} node */ const walk = (prefix, node) => { if (node == null) return; // { "$pattern": "regex", "value": ... } if (node && typeof node === 'object' && typeof node.$pattern === 'string' && Object.prototype.hasOwnProperty.call(node, 'value')) { const regex = this.#safeRegExp(node.$pattern); patterns.push({ $pattern: regex, value: this.#coerceFileValue(node.value), elseValue: this.#coerceFileValue(node.elseValue), }); return; } // Leaf as string or $fn reference if (typeof node === 'string' || (node && typeof node === 'object' && typeof node.$fn === 'string')) { if (!prefix) throw new TypeError('Leaf value requires a key path'); flat[prefix] = this.#coerceFileValue(node); return; } // Nested object if (node && typeof node === 'object') { for (const [k, v] of Object.entries(node)) { const path = prefix ? `${prefix}.${k}` : k; walk(path, v); } } }; walk('', json); this.#stringTables.set(locale, flat); this.#patternTables.set(locale, patterns); } /** * Returns a cached RegExp for the given source, compiling if needed. * In strict mode, throws on invalid regex; otherwise returns a never-matching regex. * * @param {string} src - Regex source pattern (no flags allowed). * @returns {RegExp} */ #safeRegExp(src) { if (typeof src !== 'string' || !src) throw new TypeError('#safeRegExp: "src" must be a non-empty string'); const tinyReg = this.#regexCache.get(src); if (tinyReg) return tinyReg; // Basic safety wrapper; no flags support in JSON to keep it simple and deterministic. try { const re = new RegExp(src); this.#regexCache.set(src, re); return re; } catch { if (this.#strict) throw new Error(`TinyI18: invalid regex "${src}" in file`); return /^$/; // never matches } } /** * Normalizes a file-based JSON value into an internal representation. * * Supported: * - string → returned as-is * - { $fn: string, args?: any } → preserved for helper resolution * * @param {string|FileValue} v * @returns {string|{ $fn: string, args?: any }} */ #coerceFileValue(v) { // Strings pass through; objects with $fn kept as-is; everything else ignored gracefully if (typeof v === 'string') return v; if (v && typeof v === 'object' && typeof v.$fn === 'string') { // ensure only serializable args pass through return { $fn: String(v.$fn), args: v.args }; } if (this.#strict) throw new Error('TinyI18: unsupported value in file JSON'); return ''; } // -------------------- External: constructor stuff -------------------- /** * Creates a new TinyI18 instance for managing localized strings and patterns. * * Supports two modes: * - "local": loads translations directly from provided objects. * - "file": loads translations from JSON files on demand. * * Ensures the default locale is always initialized. In "file" mode, `basePath` is required. * * @param {TinyI18Options} options - Configuration options for the instance. */ constructor(options) { if (!options || typeof options !== 'object') { throw new TypeError('TinyI18: options must be an object'); } const { mode, defaultLocale, basePath, localResources, strict = true } = options; if (mode !== 'local' && mode !== 'file') throw new TypeError('TinyI18: "mode" must be "local" or "file"'); if (typeof defaultLocale !== 'string' || !defaultLocale) throw new TypeError('TinyI18: "defaultLocale" must be a non-empty string'); if (typeof strict !== 'boolean') throw new TypeError('TinyI18: "strict" must be a boolean'); if (mode === 'file') { if (typeof basePath !== 'string' || !basePath) throw new TypeError('TinyI18: "basePath" is required in file mode'); this.#basePath = basePath; } this.#mode = mode; this.#defaultLocale = defaultLocale; this.#strict = strict; if (mode === 'local' && localResources && typeof localResources === 'object') { for (const [loc, data] of Object.entries(localResources)) { this.#ingestLocale(loc, data); } } // Ensure default locale is present (empty if not provided yet). if (!this.#stringTables.has(this.#defaultLocale)) { this.#stringTables.set(this.#defaultLocale, {}); this.#patternTables.set(this.#defaultLocale, []); } // Selected locale starts null; user may call setLocale() after constructor. } /** * Clears the internal regex cache. * * The regex cache stores compiled {@link RegExp} objects to avoid * recompiling frequently used patterns. This wrapper ensures cache * management is always controlled via the API instead of direct access. */ clearRegexCache() { this.#regexCache.clear(); } /** * Registers a helper function available to function-based entries and $fn references. * @param {string} name * @param {HelperCallback} fn */ registerHelper(name, fn) { if (typeof name !== 'string' || !name) throw new TypeError('registerHelper: "name" must be non-empty string'); if (typeof fn !== 'function') throw new TypeError('registerHelper: "fn" must be a function'); this.#helpers.set(name, fn); } /** * Unregisters a previously registered helper function. * * If the helper does not exist, this method silently does nothing. * * @param {string} name - The name of the helper to remove. * @returns {boolean} `true` if the helper was removed, `false` if it was not found. */ unregisterHelper(name) { if (typeof name !== 'string' || !name) throw new TypeError('unregisterHelper: "name" must be non-empty string'); return this.#helpers.delete(name); } /** * Loads or updates a locale data in-memory (local mode only). * @param {LocaleCode} locale * @param {Dict} data */ loadLocaleLocal(locale, data) { if (this.#mode !== 'local') throw new TypeError('loadLocaleLocal is only available in "local" mode'); this.#ingestLocale(locale, data); } /** * Sets the current selected locale. In file mode, loads it from disk. * Keeps only the default and the selected locale in memory (unloads previous selected). * @param {LocaleCode|null} locale - null -> keep only default */ async setLocale(locale) { if (locale !== null && (typeof locale !== 'string' || !locale)) throw new TypeError('setLocale: "locale" must be string or null'); const prev = this.#currentLocale; // Load or ensure presence if (locale !== null) { if (this.#mode === 'file') await this.#loadLocaleFromFile(locale); else if (!this.#stringTables.has(locale)) { // local mode: if not previously provided, create empty containers this.#stringTables.set(locale, {}); this.#patternTables.set(locale, []); } } // Unload previous selected if different and not default if (this.#mode === 'file' && prev && prev !== this.#defaultLocale && prev !== locale) this.#unloadLocale(prev); this.#currentLocale = locale; } /** * Resolves a translation by exact key. * * Resolution order: * 1. Current locale (if set) * 2. Default locale (fallback) * * @param {string} key - Translation key (dot.notation). * @param {Dict} [params] - Parameters for string interpolation or helper functions. * @param {ResolveOptions} [options] - Override resolution options (e.g., force locale). * @returns {any} - Usually string, but may be HTMLElement, DocumentFragment, or any return type from a helper. */ t(key, params = undefined, options = undefined) { return this.get(key, params, options); } /** * Alias of t() * @param {string} key * @param {Dict} [params] * @param {ResolveOptions} [options] * @returns {any} */ get(key, params = undefined, options = undefined) { if (typeof key !== 'string' || !key) throw new TypeError('get: "key" must be a non-empty string'); const { locale: forceLocale } = options || {}; const order = this.#resolveOrder(forceLocale); let resolved = this.#resolveExact(order, key); if (resolved === undefined) { if (this.#strict) throw new Error(`TinyI18: missing translation for key "${key}"`); return key; // graceful fallback to key } return this.#materialize(resolved, params); } /** * Resolves a translation by regex pattern match. * * If multiple patterns exist, returns the first matching entry. * * @param {string} key - Input string to test against regex patterns. * @param {ResolveOptions} [options] - Override resolution options (e.g., force locale). * @returns {any} - Translation value (string or custom return type). */ p(key, options) { return this.resolveByPattern(key, options); } /** * Alias of p() * @param {string} key * @param {ResolveOptions} [options] * @returns {any} */ resolveByPattern(key, options) { if (typeof key !== 'string' || !key) throw new TypeError('get: "key" must be a non-empty string'); const { locale: forceLocale } = options || {}; const order = this.#resolveOrder(forceLocale); let resolved = this.#resolveByPattern(order, key); if (resolved === undefined) { if (this.#strict) throw new Error(`TinyI18: missing translation for key "${key}"`); return key; // graceful fallback to key } return resolved; } /** * Clears everything except the default locale (keeps its data). * Selected locale becomes null. */ resetToDefaultOnly() { if (this.#mode === 'file') { for (const loc of Array.from(this.#stringTables.keys())) { if (loc !== this.#defaultLocale) { this.#unloadLocale(loc); } } } this.#currentLocale = null; } /** * Returns stats for a specific locale. * @param {LocaleCode} locale * @returns {StatLocale} * @throws {Error} If the locale is not registered. */ getStatsForLocale(locale) { if (typeof locale !== 'string' || !locale) throw new TypeError('getStatsForLocale: "locale" must be a non-empty string'); if (!this.#stringTables.has(locale)) throw new Error(`getStatsForLocale: locale "${locale}" is not registered`); const strings = Object.keys(this.#stringTables.get(locale) ?? {}).length; const patterns = this.#patternTables.get(locale)?.length ?? 0; return { locale, strings, patterns, isDefault: locale === this.#defaultLocale, isCurrent: locale === this.#currentLocale, }; } } export default TinyI18;