poe-i18n
Version:
i18n utility for Path of Exile
232 lines (205 loc) • 5.99 kB
text/typescript
import translate, { NO_DESCRIPTION, Stat } from '../translate';
import { ICUMessageSyntax } from '../types/intl';
import {
Description,
Descriptions,
StatLocaleData,
StatLocaleDatas,
Translation
} from '../types/StatDescription';
import { isZero } from '../types/StatValue';
// arg types
export { Stat } from '../translate';
export const DEFAULT_RANGE_MESSAGE = '({min}–{max})';
export type Options = {
datas: StatLocaleDatas;
fallback: Fallback | FallbackCallback;
start_file: string;
getFormatters: (
t: Translation,
stat: Stat,
n: number
) => Translation['formatters'];
/**
* if a stat value is rollable (i.e. has a min and max value)
* default: {DEFAULT_RANGE_MESSAGE}
*/
range_message: ICUMessageSyntax;
};
// return type
export type TranslatedStats = string[];
export type FallbackCallback = (id: string, stat: Stat) => string | null;
export class NoDescriptionFound extends Error {
constructor(stats: Stat[]) {
super('no descriptions found for ' + stats.map(({ id }) => id).join(','));
}
}
export enum Fallback {
throw, // throw if no stat was found
id,
skip
}
const initial_options: Options = {
datas: {},
fallback: Fallback.throw,
start_file: 'stat_descriptions',
getFormatters: t => t.formatters,
range_message: DEFAULT_RANGE_MESSAGE
};
const formatStats = (
stats: Stat[],
options: Partial<Options> = {}
): TranslatedStats => {
const {
datas,
fallback,
start_file,
getFormatters,
range_message
} = Object.assign({}, initial_options, options);
// translated lines
const lines: string[] = [];
// array of stat_ids for which hash lookup failed
const untranslated: Map<string, Stat> = new Map(
stats.map((stat: Stat) => [stat.id, stat] as [string, Stat])
);
let description_file: StatLocaleData | undefined = datas[start_file];
while (description_file !== undefined) {
const data: Descriptions = description_file.data;
for (const descriptionFinder of createDescriptionFindStrategies(data)) {
lines.push(
...formatWithFinder(untranslated, descriptionFinder, {
getFormatters,
range_message
})
);
}
description_file = description_file.meta.include
? datas[description_file.meta.include]
: undefined;
}
lines.push(...formatWithFallback(untranslated, fallback));
return lines;
};
export default formatStats;
/**
* creates an array of methods that can be used to find a description for a
* given stat.
*
* return value is to be interpreted as a priority queue
* @param descriptions
*/
export function createDescriptionFindStrategies(
descriptions: Descriptions
): Array<(stat: Stat) => Description | undefined> {
return [
({ id }) => descriptions[id],
({ id }) =>
Object.values(descriptions).find(({ stats }) => stats.includes(id))
];
}
// stats will get mutated
interface FormatWithFinderOptions {
getFormatters: (
t: Translation,
stat: Stat,
n: number
) => Translation['formatters'];
range_message: ICUMessageSyntax;
}
function formatWithFinder(
stats: Map<string, Stat>,
find: (stat: Stat) => Description | undefined,
options: Partial<FormatWithFinderOptions> = {}
): string[] {
const {
getFormatters = (t: Translation) => t.formatters,
range_message = DEFAULT_RANGE_MESSAGE
} = options;
const lines: string[] = [];
const translated: Set<string> = new Set();
for (const [stat_id, stat] of Array.from(stats.entries())) {
if (translated.has(stat_id)) {
continue;
}
const description = find(stat);
if (description !== undefined) {
const translation = translate(
description,
stats,
(t: Translation, n) => getFormatters(t, stat, n),
range_message
);
if (translation === undefined) {
const requiredStatsAreZero = requiredStats(description, stats).every(
({ value }) => isZero(value)
);
if (!requiredStatsAreZero) {
throw new Error(`matching translation not found for '${stat.id}'`);
}
} else {
// mark as translated
description.stats.forEach(translated_id => {
stats.delete(translated_id);
translated.add(translated_id);
});
if (translation === NO_DESCRIPTION) {
lines.push(`${stat_id} (hidden)`);
} else {
lines.push(translation);
}
}
}
}
return lines;
}
function requiredStats(
description: Description,
provided: Map<string, Stat>
): Stat[] {
// intersect the required stat_ids from the desc with the provided
return description.stats
.map(stat_id => {
const stat = provided.get(stat_id);
// default the value to 0
if (stat === undefined) {
return {
id: stat_id,
value: 0
};
} else {
return stat;
}
})
.filter((stat: Stat | null): stat is Stat => stat !== null);
}
function formatWithFallback(
stats: Map<string, Stat>,
fallback: Fallback | FallbackCallback
): string[] {
const non_zero_stats = Array.from(stats.entries()).filter(
([, stat]) => !isZero(stat.value)
);
if (non_zero_stats.length === 0) {
return [];
}
if (fallback === Fallback.throw) {
if (stats.size > 0) {
throw new NoDescriptionFound(non_zero_stats.map(([, stat]) => stat));
} else {
return [];
}
} else if (fallback === Fallback.id) {
return non_zero_stats.map(([key]) => key);
} else if (fallback === Fallback.skip) {
return [];
} else if (typeof fallback === 'function') {
return non_zero_stats
.map(([id, stat]) => fallback(id, stat))
.filter((line): line is string => typeof line === 'string');
} else {
// should ts recognize that this is unreachable code? enums can prob
// be extended at runtime an therfore somebody could mess with them
throw new Error(`unrecognized fallback type '${fallback}'`);
}
}