Skip to main content
Glama
syncJSON.ts10.6 kB
import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { formatDictionaryOutput } from '@intlayer/chokidar'; import { getProjectRequire } from '@intlayer/config'; import type { Dictionary, DictionaryFormat, IntlayerConfig, LocalDictionaryId, Locale, LocalesValues, Plugin, } from '@intlayer/types'; import fg from 'fast-glob'; type JSONContent = Record<string, any>; type Builder = ({ key, locale, }: { key: string; locale: LocalesValues | (string & {}); }) => string; type MessagesRecord = Record<Locale, Record<Dictionary['key'], FilePath>>; const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export const extractKeyAndLocaleFromPath = ( filePath: string, maskPattern: string, locales: Locale[], defaultLocale: Locale ) => { const keyPlaceholder = '{{__KEY__}}'; const localePlaceholder = '{{__LOCALE__}}'; const escapedMask = escapeRegex(maskPattern); const localesAlternation = locales.join('|'); // Build a regex from the mask to capture locale (and key if present) let regexStr = `^${escapedMask}$`; regexStr = regexStr.replace( escapeRegex(localePlaceholder), `(?<locale>${localesAlternation})` ); if (maskPattern.includes(keyPlaceholder)) { regexStr = regexStr.replace(escapeRegex(keyPlaceholder), '(?<key>[^/]+)'); } const maskRegex = new RegExp(regexStr); const match = maskRegex.exec(filePath); let locale: Locale | undefined; let key: string | undefined; if (match?.groups) { locale = match.groups.locale as Locale | undefined; key = (match.groups.key as string | undefined) ?? 'index'; } if (typeof key === 'undefined') { key = 'index'; } if (typeof locale === 'undefined') { locale = defaultLocale; } return { key, locale, }; }; const listMessages = ( builder: Builder, configuration: IntlayerConfig ): MessagesRecord => { const { content, internationalization } = configuration; const baseDir = content.baseDir; const locales = internationalization.locales; const defaultLocale = internationalization.defaultLocale; const localePattern = `{${locales.map((locale) => locale).join(',')}}`; const globPattern = builder({ key: '*', locale: localePattern }); const maskPattern = builder({ key: '{{__KEY__}}', locale: '{{__LOCALE__}}' }); const files = fg.sync(globPattern, { cwd: baseDir, }); const result: MessagesRecord = {} as MessagesRecord; for (const file of files) { const { key, locale } = extractKeyAndLocaleFromPath( file, maskPattern, locales, defaultLocale ); const absolutePath = isAbsolute(file) ? file : resolve(baseDir, file); if (!result[locale as Locale]) { result[locale as Locale] = {}; } result[locale as Locale][key as Dictionary['key']] = absolutePath; } // Ensure all declared locales are present even if the file doesn't exist yet // Derive the list of keys from discovered files; if no key placeholder in mask, default to 'index' const hasKeyInMask = maskPattern.includes('{{__KEY__}}'); const discoveredKeys = new Set<string>(); for (const locale of Object.keys(result)) { for (const key of Object.keys(result[locale as Locale] ?? {})) { discoveredKeys.add(key); } } if (!hasKeyInMask) { discoveredKeys.add('index'); } // If no keys were discovered and mask expects a key, we cannot infer keys. // In that case, do not fabricate unknown keys. const keysToEnsure = discoveredKeys.size > 0 ? Array.from(discoveredKeys) : []; for (const locale of locales) { if (!result[locale]) { result[locale] = {} as Record<Dictionary['key'], FilePath>; } for (const key of keysToEnsure) { if (!result[locale][key as Dictionary['key']]) { const builtPath = builder({ key, locale }); const absoluteBuiltPath = isAbsolute(builtPath) ? builtPath : resolve(baseDir, builtPath); result[locale][key as Dictionary['key']] = absoluteBuiltPath; } } } return result; }; type FilePath = string; type DictionariesMap = { path: string; locale: Locale; key: string }[]; const loadMessagePathMap = ( source: MessagesRecord | Builder, configuration: IntlayerConfig ) => { const messages: MessagesRecord = listMessages( source as Builder, configuration ); const dictionariesPathMap: DictionariesMap = Object.entries(messages).flatMap( ([locale, keysRecord]) => Object.entries(keysRecord).map(([key, path]) => { const absolutePath = isAbsolute(path) ? path : resolve(configuration.content.baseDir, path); return { path: absolutePath, locale, key, } as DictionariesMap[number]; }) ); return dictionariesPathMap; }; type SyncJSONPluginOptions = { /** * The source of the plugin. * Is a function to build the source from the key and locale. * * ```ts * syncJSON({ * source: ({ key, locale }) => `./messages/${locale}/${key}.json` * }) * ``` */ source: Builder; /** * Because Intlayer transform the JSON files into Dictionary, we need to identify the plugin in the dictionary. * Used to identify the plugin in the dictionary. * * In the case you have multiple plugins, you can use this to identify the plugin in the dictionary. * * ```ts * const config ={ * plugins: [ * syncJSON({ * source: ({ key, locale }) => `./resources/${locale}/${key}.json` * location: 'plugin-i18next', * }), * syncJSON({ * source: ({ key, locale }) => `./messages/${locale}/${key}.json` * location: 'plugin-next-intl', * }), * ] * } * ``` */ location?: string; /** * The priority of the dictionaries created by the plugin. * * In the case of conflicts with remote dictionaries, or .content files, the dictionary with the highest priority will override the other dictionaries. * * Default is -1. (.content file priority is 0) * */ priority?: number; /** * The format of the dictionaries created by the plugin. * * Default: 'intlayer' * * The format of the dictionaries created by the plugin. */ format?: DictionaryFormat; }; export const syncJSON = (options: SyncJSONPluginOptions): Plugin => { const { location, priority, format } = { location: 'plugin', priority: 0, ...options, }; return { name: 'sync-json', loadDictionaries: async ({ configuration }) => { const dictionariesMap: DictionariesMap = loadMessagePathMap( options.source, configuration ); let fill: string = options.source({ key: '{{key}}', locale: '{{locale}}', }); if (fill && !isAbsolute(fill)) { fill = join(configuration.content.baseDir, fill); } const dictionaries: Dictionary[] = []; for (const { locale, path, key } of dictionariesMap) { const requireFunction = configuration.build?.require ?? getProjectRequire(); let json: JSONContent = {}; try { json = requireFunction(path as string); } catch { // File does not exist yet; default to empty content so it can be filled later json = {}; } const filePath = relative(configuration.content.baseDir, path); const dictionary: Dictionary = { key, locale, fill, format, localId: `${key}::${location}::${filePath}` as LocalDictionaryId, location: location as Dictionary['location'], filled: locale !== configuration.internationalization.defaultLocale ? true : undefined, content: json, filePath, priority, }; dictionaries.push(dictionary); } return dictionaries; }, formatOutput: ({ dictionary }) => { if (!dictionary.filePath || !dictionary.locale) return dictionary; const builderPath = options.source({ key: dictionary.key, locale: dictionary.locale, }); // It's not one of the JSON that we synchronize, don't modify it if (resolve(builderPath) !== resolve(dictionary.filePath)) { return dictionary; } return dictionary.content; }, afterBuild: async ({ dictionaries, configuration }) => { // Dynamic import to avoid circular dependency as core package import config, that load esbuild, that load the config file, that load the plugin const { getPerLocaleDictionary } = await import('@intlayer/core'); const { parallelize } = await import('@intlayer/chokidar'); const locales = configuration.internationalization.locales; type RecordList = { key: string; dictionary: Dictionary; locale: Locale; }; const recordList: RecordList[] = Object.entries( dictionaries.mergedDictionaries ).flatMap(([key, dictionary]) => locales.map((locale) => ({ key, dictionary: dictionary.dictionary as Dictionary, locale, })) ); await parallelize(recordList, async ({ key, dictionary, locale }) => { const builderPath = options.source({ key, locale, }); const localizedDictionary = getPerLocaleDictionary(dictionary, locale); // Restore the original format from plugin options for output formatting const dictionaryWithFormat = { ...localizedDictionary, format, }; const formattedOutput = formatDictionaryOutput(dictionaryWithFormat); // Remove function, Symbol, etc. as it can be written as JSON const content = JSON.parse(JSON.stringify(formattedOutput.content)); if ( typeof content === 'undefined' || (typeof content === 'object' && Object.keys(content as Record<string, unknown>).length === 0) ) { return; } // Ensure directory exists before writing the file await mkdir(dirname(builderPath), { recursive: true }); const stringContent = JSON.stringify(content, null, 2); await writeFile( builderPath, `${stringContent}\n`, // Add a new line at the end of the file to avoid formatting issues with VSCode 'utf-8' ); }); }, }; };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server