Skip to content

Instantly share code, notes, and snippets.

@gossi
Created October 6, 2025 15:57
Show Gist options
  • Select an option

  • Save gossi/b92a535345afb59331e8a4927cdd123d to your computer and use it in GitHub Desktop.

Select an option

Save gossi/b92a535345afb59331e8a4927cdd123d to your computer and use it in GitHub Desktop.
Ember-intl vite compat

This is files needed to build your own ember-intl API compatible vite ready clone, until it is provided by ember-intl itself.

Comes completely untested and fragile ;)

I needed only a handful of APIs from ember-intl, so I copypasta that API but no more.

Files in gist can't be in subdirectories, so the __ is a dir delim.

import type { IntlShape } from '@formatjs/intl';
export type FormatMessageParameters = Parameters<IntlShape['formatMessage']>;
export function formatMessage(
intlShape: IntlShape,
...[descriptor, parameters]: FormatMessageParameters
): string {
return intlShape.formatMessage(descriptor, parameters, {
ignoreTag: true
}) as string;
}
import Helper from '@ember/component/helper';
import { service } from '@ember/service';
import type { IntlService } from '../services/intl';
type TParameters = Parameters<IntlService['t']>;
type Key = TParameters[0];
type Options = TParameters[1];
interface TSignature {
Args: {
Named?: Options;
Positional: [Key] | [Key, Options];
};
Return: string;
}
export default class THelper extends Helper<TSignature> {
@service declare intl: IntlService;
compute(
[key, positionalOptions]: TSignature['Args']['Positional'],
namedOptions: TSignature['Args']['Named']
) {
const options = positionalOptions
? Object.assign({}, positionalOptions, namedOptions)
: namedOptions;
return this.intl.t(key, options);
}
}
export { default as t } from './helpers/t.ts';
export { IntlService } from './services/intl.ts';
export type { Translations } from './utils/translations.ts';
import { tracked } from '@glimmer/tracking';
import { next } from '@ember/runloop';
import Service from '@ember/service';
import { htmlSafe } from '@ember/template';
import { createIntl, createIntlCache, type IntlShape } from '@formatjs/intl';
import { formatMessage, type FormatMessageParameters } from '../formatters.ts';
import { escapeFormatMessageOptions } from '../utils/escaping.ts';
import {
convertToArray,
convertToString,
hasLocaleChanged,
normalizeLocale
} from '../utils/locale.ts';
import {
flattenKeys,
handleMissingTranslation,
type MissingTranslationHandler,
type Translations
} from '../utils/translations.ts';
export class IntlService extends Service {
@tracked private _intls: Record<string, IntlShape> = {};
@tracked private _locale: string[] = [];
#cache = createIntlCache();
#missingTranslationHandler: MissingTranslationHandler = handleMissingTranslation;
// private _timer?: EmberRunTimer;
get locales(): string[] {
return Object.keys(this._intls);
}
get primaryLocale(): string | undefined {
if (this._locale.length === 0) {
return;
}
return this._locale[0];
}
// constructor() {
// // eslint-disable-next-line prefer-rest-params
// super(...arguments);
// const hasNewConfiguration = Boolean(
// // @ts-expect-error: Property 'resolveRegistration' does not exist on type 'Owner'
// // eslint-disable-next-line @typescript-eslint/no-unsafe-call
// getOwner(this).resolveRegistration('ember-intl:main')
// );
// if (!hasNewConfiguration) {
// this.getDefaultFormats();
// }
// // Hydrate
// translations.forEach(([locale, translations]: [string, Translations]) => {
// this.addTranslations(locale, translations);
// });
// }
addTranslations(locale: string, translations: Translations) {
const messages = flattenKeys(translations);
this.updateIntl(locale, messages);
}
private createIntl(locale: string | string[], messages: Record<string, unknown> = {}): IntlShape {
const resolvedLocale = convertToString(locale);
// const formats = this._formats;
return createIntl(
{
// defaultFormats: formats,
defaultLocale: resolvedLocale,
// formats,
locale: resolvedLocale,
// @ts-expect-error: Type 'Record<string, unknown>' is not assignable
messages
// onError: this._onFormatjsError
},
this.#cache
);
}
exists(key: string, locale?: string | string[]): boolean {
const locales = locale ? convertToArray(locale) : this._locale;
return locales.some((l) => {
return this.getTranslation(key, l) !== undefined;
});
}
private getIntl(locale: string | string[]): IntlShape {
const resolvedLocale = normalizeLocale(convertToString(locale));
return this._intls[resolvedLocale] as IntlShape;
}
private getIntlShape(locale?: string): IntlShape {
if (locale) {
return this.createIntl(locale);
}
return this.getIntl(this._locale);
}
getTranslation(key: string, locale: string): string | undefined {
const messages = this.getIntl(locale).messages;
// if (!messages) {
// return;
// }
return messages[key] as string | undefined;
}
// setFormats(formats: Formats): void {
// this._formats = convertToFormatjsFormats(formats);
// // Call `updateIntl` to update `formats` for each locale
// for (const locale of this.locales) {
// this.updateIntl(locale, {});
// }
// }
setLocale(locale: string | string[]): void {
const proposedLocale = convertToArray(locale);
if (hasLocaleChanged(proposedLocale, this._locale)) {
this._locale = proposedLocale;
// // eslint-disable-next-line ember/no-runloop
// cancel(this._timer);
// // eslint-disable-next-line ember/no-runloop
// this._timer = next(() => {
// this.updateDocumentLanguage();
// });
// eslint-disable-next-line ember/no-runloop
next(() => {
this.updateDocumentLanguage();
});
}
this.updateIntl(proposedLocale);
}
// setOnFormatjsError(onFormatjsError: OnFormatjsError): void {
// this._onFormatjsError = onFormatjsError;
// // Call `updateIntl` to update `onError` for each locale
// for (const locale of this.locales) {
// this.updateIntl(locale, {});
// }
// }
setMissingTranslationHandler(missingTranslationHandler: MissingTranslationHandler): void {
this.#missingTranslationHandler = missingTranslationHandler;
}
t(
key: string,
options?: FormatMessageParameters[1] & {
htmlSafe?: boolean;
locale?: string;
}
): string {
const locales = options?.locale ? [options.locale] : this._locale;
let translation: string | undefined;
for (const locale of locales) {
translation = this.getTranslation(key, locale);
if (translation !== undefined) {
break;
}
}
if (translation === undefined) {
return this.#missingTranslationHandler(key, locales, options);
}
// Bypass @formatjs/intl
if (translation === '') {
return '';
}
return this.formatMessage(
{
defaultMessage: translation,
id: key
},
options
);
}
formatMessage(
value: FormatMessageParameters[0] | string | undefined | null,
options?: FormatMessageParameters[1] & {
htmlSafe?: boolean;
locale?: string;
}
): string {
if (value === undefined || value === null) {
return '';
}
const intlShape = this.getIntlShape(options?.locale);
const descriptor =
typeof value === 'object'
? value
: {
defaultMessage: value,
description: undefined,
id: value
};
if (options?.htmlSafe) {
const output = formatMessage(intlShape, descriptor, escapeFormatMessageOptions(options));
return htmlSafe(output) as unknown as string;
}
return formatMessage(intlShape, descriptor, options);
}
private updateDocumentLanguage(): void {
const html = document.documentElement;
html.setAttribute('lang', this.primaryLocale as string);
}
private updateIntl(locale: string | string[], messages?: Record<string, unknown>): void {
const resolvedLocale = normalizeLocale(convertToString(locale));
const intl = this._intls[resolvedLocale];
let newIntl;
if (!intl) {
newIntl = this.createIntl(resolvedLocale, messages);
} else if (messages) {
newIntl = this.createIntl(resolvedLocale, {
...intl.messages,
...messages
});
}
if (!newIntl) {
return;
}
this._intls = {
...this._intls,
[resolvedLocale]: newIntl
};
}
// willDestroy() {
// super.willDestroy();
// // eslint-disable-next-line ember/no-runloop
// cancel(this._timer);
// }
}
// export { type Formats } from '../-private/formatjs/index';
declare module 'virtual:ember-intl-loader' {
import type { Translations } from './utils/translations';
const translations: Record<string, Translations>;
export default translations;
}
import { isHTMLSafe, type SafeString } from '@ember/template';
const escaped: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'`': '&#x60;',
'=': '&#x3D;'
};
const needToEscape = /[&<>"'`=]/;
const badCharacters = /[&<>"'`=]/g;
// https://github.com/emberjs/ember.js/blob/v5.12.0/packages/%40ember/-internals/glimmer/lib/utils/string.ts#L103-L118
function escapeExpression(value: string): string {
if (!needToEscape.test(value)) {
return value;
}
return value.replaceAll(badCharacters, (character: string) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return escaped[character]!;
});
}
/**
* @private
* @hide
*/
export function escapeFormatMessageOptions<T extends Record<string, unknown>>(options: T) {
const escapedOptions = {} as {
[K in keyof T]: T[K] extends SafeString ? string : T[K];
};
for (const [key, value] of Object.entries(options)) {
let newValue;
if (isHTMLSafe(value)) {
/*
Cast `value`, an instance of `SafeString`, to a string
using `.toHTML()`. Since `value` is assumed to be safe,
we don't need to call `escapeExpression()`.
*/
newValue = value.toHTML();
} else if (typeof value === 'string') {
newValue = escapeExpression(value);
} else {
newValue = value;
}
// @ts-expect-error: Type not specific enough
escapedOptions[key] = newValue;
}
return escapedOptions;
}
export function convertToArray(locale: string | string[]): string[] {
if (Array.isArray(locale)) {
return locale;
}
return [locale];
}
export function convertToString(locale: string | string[]): string {
if (Array.isArray(locale)) {
return locale[0] as string;
}
return locale;
}
export function normalizeLocale(locale: string): string {
return locale.replaceAll('_', '-').toLowerCase();
}
type MaybeLocale = string[] | string | null | undefined;
export function hasLocaleChanged(locale1: string[], locale2: MaybeLocale): boolean {
if (!Array.isArray(locale2)) {
return true;
}
return locale1.toString() !== locale2.toString();
}
type IndexSignatureParameter = string | number | symbol;
type NestedStructure<T extends IndexSignatureParameter> = {
[Key in IndexSignatureParameter]?: T | NestedStructure<T>;
};
export type Translations = NestedStructure<string>;
/**
* @private
* @hide
*/
export function flattenKeys<T extends IndexSignatureParameter>(
object: NestedStructure<T>
): Record<string, T> {
const result = {} as Record<string, T>;
for (const key in object) {
if (!Object.prototype.hasOwnProperty.call(object, key)) {
continue;
}
const value = object[key];
// If `value` is not `null`
if (value && typeof value === 'object') {
const hash = flattenKeys(value);
for (const suffix in hash) {
const translation = hash[suffix];
if (translation !== undefined) {
result[`${key}.${suffix}`] = translation;
}
}
} else {
if (value !== undefined) {
result[key] = value;
}
}
}
return result;
}
export type MissingTranslationHandler = (
key: string,
locales: string[],
options?: Record<string, unknown>
) => string;
export const handleMissingTranslation: MissingTranslationHandler = (key, locales) => {
const locale = locales.join(', ');
return `Missing translation "${key}" for locale "${locale}"`;
};
/**
This is very much based on:
https://www.npmjs.com/package/@kainstar/vite-plugin-i18next-loader
With slight adjustments
*/
import fs from 'node:fs';
import path from 'node:path';
import { setProperty } from 'dot-prop';
import { globbySync } from 'globby';
// eslint-disable-next-line import-x/default
import YAML from 'js-yaml';
import { createLogger } from 'vite';
import type { Options as GlobbyOptions } from 'globby';
import type { Plugin } from 'vite';
export interface Options {
/**
* Enable debug logging
*/
debug?: boolean;
/**
* Locale top level directory paths ordered from least specialized to most specialized
* e.g. lib locale -> app locale
*
* Locales loaded later will overwrite any duplicated key via a deep merge strategy.
*/
paths: string[];
/**
* i18next namespace
*
* @default 'translation'
*/
intlNS?: string | false;
/**
* Glob patterns to match files
*
* @default ['**\/*.json', '**\/*.yml', '**\/*.yaml']
*/
include?: string[];
/**
* custom globby options
*/
globbyOptions?: GlobbyOptions;
}
export type ResBundle = Record<string, Record<string, unknown>>;
// don't export these from index so the external types are cleaner
export const IntlVirtualModuleId = 'virtual:ember-intl-loader';
export const IntlResolvedVirtualModuleId = `\0${IntlVirtualModuleId}`;
export function jsNormalizedLang(lang: string) {
return lang.replaceAll('-', '_');
}
export function enumerateLangs(dir: string) {
return fs.readdirSync(dir).filter(function (file) {
return fs.statSync(path.join(dir, file)).isDirectory();
});
}
export function resolvePaths(paths: string[], cwd: string) {
return paths.map((override) => {
return path.isAbsolute(override) ? override : path.join(cwd, override);
});
}
export function assertExistence(paths: string[]) {
for (const dir of paths) {
if (!fs.existsSync(dir)) {
throw new Error(`Directory does not exist: ${dir}`);
}
}
}
export function loadAndParse(langFile: string) {
const fileContent = fs.readFileSync(langFile, 'utf8');
const extname = path.extname(langFile);
let parsedContent: Record<string, unknown> = {};
try {
parsedContent =
extname === '.yaml' || extname === '.yml'
? (YAML.load(fileContent) as Record<string, unknown>)
: (JSON.parse(fileContent) as Record<string, unknown>);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`parsing file ${langFile}: ${error}`, {
cause: error
});
}
return parsedContent;
}
export const intl = ({
paths,
include = ['**/*.json', '**/*.yml', '**/*.yaml'],
globbyOptions,
debug
}: Options) => {
const log = createLogger('info', { prefix: '[ember-intl/vite]' });
function loadLocales() {
const localeDirs = resolvePaths(paths, process.cwd());
assertExistence(localeDirs);
const appResBundle: ResBundle = {};
const loadedFiles: string[] = [];
let allLangs = new Set<string>();
for (const nextLocaleDir of localeDirs) {
// all subdirectories match language codes
const langs = enumerateLangs(nextLocaleDir);
allLangs = new Set([...allLangs, ...langs]);
for (const lang of langs) {
const langDir = path.join(nextLocaleDir, lang); // top level lang dir
const langFiles = globbySync(include, {
...globbyOptions,
cwd: langDir,
absolute: true
}); // all lang files matching patterns in langDir
for (const langFile of langFiles) {
loadedFiles.push(langFile); // track for fast hot reload matching
const content = loadAndParse(langFile);
const namespaceFilepath = path.relative(langDir, langFile);
const extname = path.extname(langFile);
const namespaceParts = namespaceFilepath.replace(extname, '').split(path.sep);
const namespace = [lang, ...namespaceParts]
.filter(Boolean) // remove empty str
.join('.');
setProperty(appResBundle, namespace, content);
}
}
}
if (debug) {
log.info(
`Bundling locales (ordered least specific to most):\n${loadedFiles.map((f) => `\t${f}`).join('\n')}`,
{
timestamp: true
}
);
}
// one bundle - works, no issues with dashes in names
// const bundle = `export default ${JSON.stringify(appResBundle)}`
// named exports, requires manipulation of names
let namedBundle = '';
let defaultExport = 'const resources = { \n';
for (const lang of allLangs) {
const langIdentifier = jsNormalizedLang(lang);
namedBundle += `export const ${langIdentifier} = ${JSON.stringify(appResBundle[lang])}\n`;
defaultExport += `"${lang}": ${langIdentifier},\n`;
}
defaultExport += '}';
defaultExport += '\nexport default resources\n';
const bundle = namedBundle + defaultExport;
if (debug) {
log.info(`Locales module '${IntlResolvedVirtualModuleId}':\n${bundle}`, {
timestamp: true
});
}
return bundle;
}
const plugin: Plugin = {
name: 'vite-plugin-ember-intl', // required, will show up in warnings and errors
resolveId(id) {
if (id === IntlVirtualModuleId) {
return IntlResolvedVirtualModuleId;
}
return;
},
load(id) {
if (id !== IntlResolvedVirtualModuleId) {
return;
}
return loadLocales();
},
/**
* Watch translation files and trigger an update.
*/
async handleHotUpdate({ file, server }) {
const isLocaleFile =
/\.(json|yml|yaml)$/.exec(file) &&
paths.some((p) => file.startsWith(path.join(process.cwd(), p)));
if (isLocaleFile) {
log.info(`Changed locale file: ${file}`, {
timestamp: true
});
const { moduleGraph } = server;
const module = moduleGraph.getModuleById(IntlResolvedVirtualModuleId);
if (module) {
await server.reloadModule(module);
}
}
}
};
return plugin;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment