next-intl
Version:
Internationalization (i18n) for Next.js
1,446 lines (1,380 loc) • 56.3 kB
JavaScript
'use strict';
var fs$1 = require('fs/promises');
var path = require('path');
var watcher = require('@parcel/watcher');
var fs = require('fs');
var module$1 = require('module');
var core = require('@swc/core');
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
var fs__default$1 = /*#__PURE__*/_interopDefaultCompat(fs$1);
var path__default = /*#__PURE__*/_interopDefaultCompat(path);
var fs__default = /*#__PURE__*/_interopDefaultCompat(fs);
function formatMessage(message) {
return `\n[next-intl] ${message}\n`;
}
function throwError(message) {
throw new Error(formatMessage(message));
}
function warn(message) {
console.warn(formatMessage(message));
}
/**
* Returns a function that runs the provided callback only once per process.
* Next.js can call the config multiple times - this ensures we only run once.
* Uses an environment variable to track execution across config loads.
*/
function once(namespace) {
return function runOnce(fn) {
if (process.env[namespace] === '1') {
return;
}
process.env[namespace] = '1';
fn();
};
}
function stripTrailingSlash(dirPath) {
if (dirPath.endsWith('/')) {
return dirPath.slice(0, -1);
} else {
return dirPath;
}
}
function normalizeMessagesCatalogPaths(messagesPath) {
const rawPaths = Array.isArray(messagesPath) ? messagesPath : [messagesPath];
return rawPaths.map(dirPath => stripTrailingSlash(String(dirPath).trim())).filter(dirPath => dirPath.length > 0);
}
function normalizeExtractorConfig(input) {
if (input.messages == null) {
throwError('`messages` is required when extracting messages.');
}
const extract = input.extract;
let extractPath;
let sourceLocale;
if (extract !== undefined && extract !== true) {
if (extract.sourceLocale) {
warn('`extract.sourceLocale` is deprecated in favor of `messages.sourceLocale`.');
sourceLocale = extract.sourceLocale;
}
if (extract.path) {
extractPath = stripTrailingSlash(extract.path);
}
}
const locales = input.messages.locales;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!locales) {
throwError('`messages.locales` is required when extracting messages.');
}
if (input.messages.sourceLocale) {
sourceLocale = input.messages.sourceLocale;
}
if (!sourceLocale) {
throwError('`messages.sourceLocale` is required when extracting messages.');
}
const srcPath = input.srcPath;
if (srcPath == null) {
throwError('`srcPath` is required when extracting messages.');
}
const pathIsArray = Array.isArray(input.messages.path);
const messagesPath = normalizeMessagesCatalogPaths(input.messages.path);
if (messagesPath.length === 0) {
throwError('`messages.path` must not be empty.');
}
if (extractPath == null) {
if (pathIsArray) {
throwError('When `messages.path` is an array, `extract.path` is required to select the writable catalog directory.');
}
extractPath = messagesPath[0];
}
return {
extract: {
locales,
path: extractPath,
sourceLocale,
srcPath
},
messages: {
format: input.messages.format,
path: messagesPath
}
};
}
/**
* Wrapper around `fs.watch` that provides a workaround
* for https://github.com/nodejs/node/issues/5039.
*/
function watchFile(filepath, callback) {
const directory = path__default.default.dirname(filepath);
const filename = path__default.default.basename(filepath);
return fs__default.default.watch(directory, {
persistent: false,
recursive: false
}, (event, changedFilename) => {
if (changedFilename === filename) {
callback();
}
});
}
const runOnce$1 = once('_NEXT_INTL_COMPILE_MESSAGES');
function createMessagesDeclaration(messagesPaths) {
// Instead of running _only_ in certain cases, it's
// safer to _avoid_ running for certain known cases.
// https://github.com/amannn/next-intl/issues/2006
const shouldBailOut = ['info', 'start'
// Note: These commands don't consult the
// Next.js config, so we can't detect them here.
// - telemetry (however, the `detached-flush` DOES - see `createNextIntlPlugin`)
// - lint
//
// What remains are:
// - dev
// - build
// - typegen
].some(arg => process.argv.includes(arg));
if (shouldBailOut) {
return;
}
runOnce$1(() => {
for (const messagesPath of messagesPaths) {
const fullPath = path__default.default.resolve(messagesPath);
if (!fs__default.default.existsSync(fullPath)) {
throwError(`\`createMessagesDeclaration\` points to a non-existent file: ${fullPath}`);
}
if (!fullPath.endsWith('.json')) {
throwError(`\`createMessagesDeclaration\` needs to point to a JSON file. Received: ${fullPath}`);
}
// Keep this as a runtime check and don't replace
// this with a constant during the build process
const env = process.env['NODE_ENV'.trim()];
compileDeclaration(messagesPath);
if (env === 'development') {
startWatching(messagesPath);
}
}
});
}
function startWatching(messagesPath) {
const watcher = watchFile(messagesPath, () => {
compileDeclaration(messagesPath, true);
});
process.on('exit', () => {
watcher.close();
});
}
function compileDeclaration(messagesPath, async = false) {
const declarationPath = messagesPath.replace(/\.json$/, '.d.json.ts');
function createDeclaration(content) {
return `// This file is auto-generated by next-intl, do not edit directly.
// See: https://next-intl.dev/docs/workflows/typescript#messages-arguments
declare const messages: ${content.trim()};
export default messages;`;
}
if (async) {
return fs__default.default.promises.readFile(messagesPath, 'utf-8').then(content => fs__default.default.promises.writeFile(declarationPath, createDeclaration(content)));
}
const content = fs__default.default.readFileSync(messagesPath, 'utf-8');
fs__default.default.writeFileSync(declarationPath, createDeclaration(content));
}
const formats = {
json: {
codec: () => Promise.resolve().then(function () { return require('./JSONCodec-CzA8ubPy.cjs'); }),
extension: '.json'
},
po: {
codec: () => Promise.resolve().then(function () { return require('./POCodec-CWGHK-Gp.cjs'); }),
extension: '.po'
}
};
function isBuiltInFormat(format) {
return typeof format === 'string' && format in formats;
}
function getFormatExtension(format) {
if (isBuiltInFormat(format)) {
return formats[format].extension;
} else {
return format.extension;
}
}
async function resolveCodec(format, projectRoot) {
if (isBuiltInFormat(format)) {
const factory = (await formats[format].codec()).default;
return factory();
} else {
const resolvedPath = path__default.default.isAbsolute(format.codec) ? format.codec : path__default.default.resolve(projectRoot, format.codec);
let module;
try {
module = await import(resolvedPath);
} catch (error) {
throwError(`Could not load codec from "${resolvedPath}".\n${error}`);
}
const factory = module.default;
if (!factory || typeof factory !== 'function') {
throwError(`Codec at "${resolvedPath}" must have a default export returned from \`defineCodec\`.`);
}
return factory();
}
}
class SourceFileFilter {
static EXTENSIONS = ['ts', 'tsx', 'js', 'jsx'];
// Will not be entered, except if explicitly asked for
// TODO: At some point we should infer these from .gitignore
static IGNORED_DIRECTORIES = ['node_modules', '.next', '.git'];
static isSourceFile(filePath) {
const ext = path__default.default.extname(filePath);
return SourceFileFilter.EXTENSIONS.map(cur => '.' + cur).includes(ext);
}
static shouldEnterDirectory(dirPath, srcPaths) {
const dirName = path__default.default.basename(dirPath);
if (SourceFileFilter.IGNORED_DIRECTORIES.includes(dirName)) {
return SourceFileFilter.isIgnoredDirectoryExplicitlyIncluded(dirPath, srcPaths);
}
return true;
}
static isIgnoredDirectoryExplicitlyIncluded(ignoredDirPath, srcPaths) {
return srcPaths.some(srcPath => SourceFileFilter.isWithinPath(srcPath, ignoredDirPath));
}
static isWithinPath(targetPath, basePath) {
const relativePath = path__default.default.relative(basePath, targetPath);
return relativePath === '' || !relativePath.startsWith('..');
}
}
class SourceFileScanner {
static async walkSourceFiles(dir, srcPaths, acc = []) {
const entries = await fs__default$1.default.readdir(dir, {
withFileTypes: true
});
for (const entry of entries) {
const entryPath = path__default.default.join(dir, entry.name);
if (entry.isDirectory()) {
if (!SourceFileFilter.shouldEnterDirectory(entryPath, srcPaths)) {
continue;
}
await SourceFileScanner.walkSourceFiles(entryPath, srcPaths, acc);
} else {
if (SourceFileFilter.isSourceFile(entry.name)) {
acc.push(entryPath);
}
}
}
return acc;
}
static async getSourceFiles(srcPaths) {
const files = (await Promise.all(srcPaths.map(srcPath => SourceFileScanner.walkSourceFiles(srcPath, srcPaths)))).flat();
return new Set(files);
}
}
class SourceFileWatcher {
subscriptions = [];
constructor(roots, onChange) {
this.roots = roots;
this.onChange = onChange;
}
async start() {
if (this.subscriptions.length > 0) {
return;
}
const ignore = SourceFileFilter.IGNORED_DIRECTORIES.map(dir => `**/${dir}/**`);
for (const root of this.roots) {
const sub = await watcher.subscribe(root, async (err, events) => {
if (err) {
console.error(err);
return;
}
const filtered = await this.normalizeEvents(events);
if (filtered.length > 0) {
await this.onChange(filtered);
}
}, {
ignore
});
this.subscriptions.push(sub);
}
}
async normalizeEvents(events) {
const directoryCreatePaths = [];
const otherEvents = [];
// We need to expand directory creates because during rename operations,
// @parcel/watcher emits a directory create event but may not emit individual
// file events for the moved files
await Promise.all(events.map(async event => {
if (event.type === 'create') {
try {
const stats = await fs__default$1.default.stat(event.path);
if (stats.isDirectory()) {
directoryCreatePaths.push(event.path);
return;
}
} catch {
// Path doesn't exist or is inaccessible, treat as file
}
}
otherEvents.push(event);
}));
// Expand directory create events to find source files inside
let expandedCreateEvents = [];
if (directoryCreatePaths.length > 0) {
try {
const sourceFiles = await SourceFileScanner.getSourceFiles(directoryCreatePaths);
expandedCreateEvents = Array.from(sourceFiles).map(filePath => ({
type: 'create',
path: filePath
}));
} catch {
// Directories might have been deleted or are inaccessible
}
}
// Combine original events with expanded directory creates.
// Deduplicate by path to avoid processing the same file twice
// in case @parcel/watcher also emitted individual file events.
const allEvents = [...otherEvents, ...expandedCreateEvents];
const seenPaths = new Set();
const deduplicated = [];
for (const event of allEvents) {
const key = `${event.type}:${event.path}`;
if (!seenPaths.has(key)) {
seenPaths.add(key);
deduplicated.push(event);
}
}
return deduplicated.filter(event => {
// Keep all delete events (might be deleted directories that no longer exist)
if (event.type === 'delete') {
return true;
}
// Keep source files
return SourceFileFilter.isSourceFile(event.path);
});
}
async expandDirectoryDeleteEvents(events, prevKnownFiles) {
const expanded = [];
for (const event of events) {
if (event.type === 'delete' && !SourceFileFilter.isSourceFile(event.path)) {
const dirPath = path__default.default.resolve(event.path);
const filesInDirectory = [];
for (const filePath of prevKnownFiles) {
if (SourceFileFilter.isWithinPath(filePath, dirPath)) {
filesInDirectory.push(filePath);
}
}
// If we found files within this path, it was a directory
if (filesInDirectory.length > 0) {
for (const filePath of filesInDirectory) {
expanded.push({
type: 'delete',
path: filePath
});
}
} else {
// Not a directory or no files in it, pass through as-is
expanded.push(event);
}
} else {
// Pass through as-is
expanded.push(event);
}
}
return expanded;
}
async stop() {
await Promise.all(this.subscriptions.map(sub => sub.unsubscribe()));
this.subscriptions = [];
}
[Symbol.dispose]() {
void this.stop();
}
}
function normalizePathToPosix(filePath) {
// `path.relative` uses OS-specific separators. For stable `.po` references we
// always use POSIX separators, regardless of the OS that ran extraction.
return path__default.default.posix.normalize(filePath.split(path__default.default.win32.sep).join(path__default.default.posix.sep));
}
const FORBIDDEN_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function isForbiddenObjectKey(key) {
return FORBIDDEN_OBJECT_KEYS.has(key);
}
function hasLocalesToExtract(config) {
const {
locales
} = config.extract;
return locales === 'infer' || locales.length > 0;
}
// Essentialls lodash/set, but we avoid this dependency
function setNestedProperty(obj, keyPath, value) {
const keys = keyPath.split('.');
for (const key of keys) {
if (isForbiddenObjectKey(key)) {
throw new Error(`Invalid message id segment: ${key}`);
}
}
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!Object.prototype.hasOwnProperty.call(current, key) || typeof current[key] !== 'object' || current[key] === null) {
current[key] = Object.create(null);
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
function getSortedMessages(messages) {
const warnedMissingReferenceIds = new Set();
return messages.toSorted((messageA, messageB) => {
const refA = messageA.references[0];
const refB = messageB.references[0];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (refA == null) {
warnAboutMissingReference(messageA.id, warnedMissingReferenceIds);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (refB == null) {
warnAboutMissingReference(messageB.id, warnedMissingReferenceIds);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (refA == null || refB == null) {
return 0;
}
// Sort by path, then line. Same path+line: preserve original order
return compareReferences(refA, refB);
});
}
function warnAboutMissingReference(id, warnedMissingReferenceIds) {
if (warnedMissingReferenceIds.has(id)) return;
warnedMissingReferenceIds.add(id);
warn(`Missing file reference for extracted message: ${id}`);
}
function localeCompare(a, b) {
return a.localeCompare(b, 'en');
}
function compareReferences(refA, refB) {
const pathCompare = localeCompare(refA.path, refB.path);
if (pathCompare !== 0) return pathCompare;
return (refA.line ?? 0) - (refB.line ?? 0);
}
function getDefaultProjectRoot() {
return process.cwd();
}
class CatalogLocales {
onChangeCallbacks = new Set();
constructor(params) {
this.messagesDir = params.messagesDir;
this.sourceLocale = params.sourceLocale;
this.extension = params.extension;
this.locales = params.locales;
}
async getTargetLocales() {
if (this.targetLocales) {
return this.targetLocales;
}
if (this.locales === 'infer') {
this.targetLocales = await this.readTargetLocales();
} else {
this.targetLocales = this.locales.filter(locale => locale !== this.sourceLocale);
}
return this.targetLocales;
}
async readTargetLocales() {
try {
const files = await fs__default$1.default.readdir(this.messagesDir);
return files.filter(file => file.endsWith(this.extension)).map(file => path__default.default.basename(file, this.extension)).filter(locale => locale !== this.sourceLocale);
} catch {
return [];
}
}
subscribeLocalesChange(callback) {
this.onChangeCallbacks.add(callback);
if (this.locales === 'infer' && !this.watcher) {
void this.startWatcher();
}
}
unsubscribeLocalesChange(callback) {
this.onChangeCallbacks.delete(callback);
if (this.onChangeCallbacks.size === 0) {
this.stopWatcher();
}
}
async startWatcher() {
if (this.watcher) {
return;
}
await fs__default$1.default.mkdir(this.messagesDir, {
recursive: true
});
this.watcher = fs__default.default.watch(this.messagesDir, {
persistent: false,
recursive: false
}, (event, filename) => {
const isCatalogFile = filename != null && filename.endsWith(this.extension) && !filename.includes(path__default.default.sep);
if (isCatalogFile) {
void this.onChange();
}
});
}
stopWatcher() {
if (this.watcher) {
this.watcher.close();
this.watcher = undefined;
}
}
async onChange() {
const oldLocales = new Set(this.targetLocales || []);
this.targetLocales = await this.readTargetLocales();
const newLocalesSet = new Set(this.targetLocales);
const added = this.targetLocales.filter(locale => !oldLocales.has(locale));
const removed = Array.from(oldLocales).filter(locale => !newLocalesSet.has(locale));
if (added.length > 0 || removed.length > 0) {
for (const callback of this.onChangeCallbacks) {
callback({
added,
removed
});
}
}
}
}
class CatalogPersister {
constructor(params) {
this.messagesPath = params.messagesPath;
this.codec = params.codec;
this.extension = params.extension;
}
getFileName(locale) {
return locale + this.extension;
}
getFilePath(locale) {
return path__default.default.join(this.messagesPath, this.getFileName(locale));
}
async read(locale) {
const filePath = this.getFilePath(locale);
let content;
try {
content = await fs__default$1.default.readFile(filePath, 'utf8');
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return [];
}
throw new Error(`Error while reading ${this.getFileName(locale)}:\n> ${error}`, {
cause: error
});
}
try {
return this.codec.decode(content, {
locale
});
} catch (error) {
throw new Error(`Error while decoding ${this.getFileName(locale)}:\n> ${error}`, {
cause: error
});
}
}
async write(messages, context) {
const filePath = this.getFilePath(context.locale);
const content = this.codec.encode(messages, context);
try {
const outputDir = path__default.default.dirname(filePath);
await fs__default$1.default.mkdir(outputDir, {
recursive: true
});
await fs__default$1.default.writeFile(filePath, content);
} catch (error) {
console.error(`❌ Failed to write catalog: ${error}`);
}
}
async getLastModified(locale) {
const filePath = this.getFilePath(locale);
try {
const stats = await fs__default$1.default.stat(filePath);
return stats.mtime;
} catch {
return undefined;
}
}
}
/**
* De-duplicates excessive save invocations,
* while keeping a single one instant.
*/
class SaveScheduler {
isSaving = false;
pendingResolvers = [];
constructor(delayMs = 50) {
this.delayMs = delayMs;
}
async schedule(saveTask) {
return new Promise((resolve, reject) => {
this.pendingResolvers.push({
resolve,
reject
});
this.nextSaveTask = saveTask;
if (!this.isSaving && !this.saveTimeout) {
// Not currently saving and no scheduled save, save immediately
this.executeSave();
} else if (this.saveTimeout) {
// A save is already scheduled, reschedule to debounce
this.scheduleSave();
}
// If isSaving is true and no timeout is scheduled, the current save
// will check for pending resolvers when it completes and schedule
// another save if needed (see finally block in executeSave)
});
}
scheduleSave() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => {
this.saveTimeout = undefined;
this.executeSave();
}, this.delayMs);
}
async executeSave() {
if (this.isSaving) {
return;
}
const saveTask = this.nextSaveTask;
if (!saveTask) {
return;
}
// Capture current pending resolvers for this save
const resolversForThisSave = this.pendingResolvers;
this.pendingResolvers = [];
this.nextSaveTask = undefined;
this.isSaving = true;
try {
const result = await saveTask();
// Resolve only the promises that were pending when this save started
resolversForThisSave.forEach(({
resolve
}) => resolve(result));
} catch (error) {
// Reject only the promises that were pending when this save started
resolversForThisSave.forEach(({
reject
}) => reject(error));
} finally {
this.isSaving = false;
// If new saves were requested during this save, schedule another
if (this.pendingResolvers.length > 0) {
this.scheduleSave();
}
}
}
[Symbol.dispose]() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = undefined;
}
this.pendingResolvers = [];
this.nextSaveTask = undefined;
this.isSaving = false;
}
}
class CatalogManager {
/**
* Extraction-derived fields aggregated into `ExtractorMessage`.
* Source code is the source of truth for these fields, only ancillary
* codec fields may merge from disk (e.g. flags).
*/
static extractorOwnedAggregatorKeys = new Set(['description', 'id', 'message', 'references']);
/**
* Source of truth for statically extracted source messages,
* grouped by file and message ID.
*/
sourceMessagesByFile = new Map();
/**
* Reverse index for rebuilding aggregated messages without scanning all files.
* Contains the same `SourceMessage` arrays as `sourceMessagesByFile` and is
* kept in sync with it.
*/
sourceMessagesById = new Map();
/**
* Fast lookup for messages by ID, aggregated across all files. This combines
* metadata from `sourceMessagesById`, e.g. references and descriptions.
*/
messagesById = new Map();
/**
* This potentially also includes outdated ones that were initially available,
* but are not used anymore. This allows to restore them if they are used again.
**/
translationsByTargetLocale = new Map();
lastWriteByLocale = new Map();
// Cached instances
// Resolves when all catalogs are loaded
// Resolves when the initial project scan and processing is complete
constructor(config, opts) {
this.config = config;
this.saveScheduler = new SaveScheduler(opts.saveDebounceMs ?? 50);
this.projectRoot = opts.projectRoot ?? getDefaultProjectRoot();
this.isDevelopment = opts.isDevelopment ?? false;
this.extractor = opts.extractor;
if (this.isDevelopment) {
// We kick this off as early as possible, so we get notified about changes
// that happen during the initial project scan (while awaiting it to
// complete though)
this.sourceWatcher = new SourceFileWatcher(this.getSrcPaths(), this.handleFileEvents.bind(this));
void this.sourceWatcher.start();
}
}
async getCodec() {
if (!this.codec) {
this.codec = await resolveCodec(this.config.messages.format, this.projectRoot);
}
return this.codec;
}
async getPersister() {
if (this.persister) {
return this.persister;
} else {
this.persister = new CatalogPersister({
messagesPath: this.config.extract.path,
codec: await this.getCodec(),
extension: getFormatExtension(this.config.messages.format)
});
return this.persister;
}
}
getCatalogLocales() {
if (this.catalogLocales) {
return this.catalogLocales;
} else {
const messagesDir = path__default.default.join(this.projectRoot, this.config.extract.path);
this.catalogLocales = new CatalogLocales({
messagesDir,
extension: getFormatExtension(this.config.messages.format),
locales: this.config.extract.locales,
sourceLocale: this.config.extract.sourceLocale
});
return this.catalogLocales;
}
}
async getTargetLocales() {
return this.getCatalogLocales().getTargetLocales();
}
getSrcPaths() {
return (Array.isArray(this.config.extract.srcPath) ? this.config.extract.srcPath : [this.config.extract.srcPath]).map(srcPath => path__default.default.join(this.projectRoot, srcPath));
}
async loadMessages() {
const sourceDiskMessages = await this.loadSourceMessages();
this.loadCatalogsPromise = this.loadTargetMessages();
await this.loadCatalogsPromise;
this.scanCompletePromise = (async () => {
const sourceFiles = Array.from(await SourceFileScanner.getSourceFiles(this.getSrcPaths()))
// Stable file order keeps catalog ties independent of processing timing
.toSorted(localeCompare);
const extractedFiles = await Promise.all(sourceFiles.map(async filePath => ({
filePath,
messages: await this.extractFile(filePath)
})));
for (const {
filePath,
messages
} of extractedFiles) {
if (messages) {
this.applyFileMessages(filePath, messages);
}
}
this.mergeSourceDiskMetadata(sourceDiskMessages);
})();
await this.scanCompletePromise;
if (this.isDevelopment) {
const catalogLocales = this.getCatalogLocales();
catalogLocales.subscribeLocalesChange(this.onLocalesChange);
}
}
async loadSourceMessages() {
// Load source catalog to hydrate metadata (e.g. flags) later without
// treating catalog entries as source of truth.
const diskMessages = await this.loadLocaleMessages(this.config.extract.sourceLocale);
const byId = new Map();
for (const diskMessage of diskMessages) {
byId.set(diskMessage.id, diskMessage);
}
return byId;
}
async loadLocaleMessages(locale) {
const persister = await this.getPersister();
const messages = await persister.read(locale);
const fileTime = await persister.getLastModified(locale);
this.lastWriteByLocale.set(locale, fileTime);
return messages;
}
async loadTargetMessages() {
const targetLocales = await this.getTargetLocales();
await Promise.all(targetLocales.map(locale => this.reloadLocaleCatalog(locale)));
}
async reloadLocaleCatalog(locale) {
const diskMessages = await this.loadLocaleMessages(locale);
if (locale === this.config.extract.sourceLocale) {
// For source: Merge additional properties like flags
for (const diskMessage of diskMessages) {
const prev = this.messagesById.get(diskMessage.id);
if (prev) {
for (const key of Object.keys(diskMessage)) {
if (!CatalogManager.extractorOwnedAggregatorKeys.has(key)) {
// For unknown properties (like flags), disk wins
prev[key] = diskMessage[key];
}
}
}
}
} else {
// For target: disk wins completely, BUT preserve existing translations
// if we read empty (likely a write in progress by an external tool
// that causes the file to temporarily be empty)
const existingTranslations = this.translationsByTargetLocale.get(locale);
const hasExistingTranslations = existingTranslations && existingTranslations.size > 0;
if (diskMessages.length > 0) {
// We got content from disk, replace with it
const translations = new Map();
for (const message of diskMessages) {
translations.set(message.id, message);
}
this.translationsByTargetLocale.set(locale, translations);
} else if (hasExistingTranslations) ; else {
// We read empty and have no existing translations
const translations = new Map();
this.translationsByTargetLocale.set(locale, translations);
}
}
}
mergeSourceDiskMetadata(diskMessages) {
for (const [id, diskMessage] of diskMessages) {
const existing = this.messagesById.get(id);
if (!existing) continue;
// Fill unknown metadata from disk without replacing extraction-owned fields.
for (const key of Object.keys(diskMessage)) {
if (!CatalogManager.extractorOwnedAggregatorKeys.has(key) && existing[key] == null) {
existing[key] = diskMessage[key];
}
}
}
}
async processFile(absoluteFilePath) {
const messages = await this.extractFile(absoluteFilePath);
// `undefined` only when `extractFile()` throws. An empty array is success
// and must still run `applyFileMessages` to clear stale ids for this file.
if (!messages) return false;
return this.applyFileMessages(absoluteFilePath, messages);
}
async extractFile(absoluteFilePath) {
let messages = [];
try {
const content = await fs__default$1.default.readFile(absoluteFilePath, 'utf8');
let extraction;
try {
extraction = await this.extractor.extract(absoluteFilePath, content);
} catch {
return undefined;
}
messages = extraction.messages;
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
// ENOENT -> treat as no messages
}
return messages;
}
applyFileMessages(absoluteFilePath, messages) {
const prevFileMessages = this.sourceMessagesByFile.get(absoluteFilePath);
const nextFileMessages = this.groupSourceMessagesById(messages);
const affectedIds = new Set([...(prevFileMessages?.keys() ?? []), ...nextFileMessages.keys()]);
if (nextFileMessages.size > 0) {
this.sourceMessagesByFile.set(absoluteFilePath, nextFileMessages);
} else {
this.sourceMessagesByFile.delete(absoluteFilePath);
}
// Clear this file's contribution from the reverse index, then re-insert
// fresh rows and rebuild aggregates (messagesById) per touched id.
for (const id of affectedIds) {
const sourceMessagesForId = this.sourceMessagesById.get(id);
if (sourceMessagesForId) {
sourceMessagesForId.delete(absoluteFilePath);
// No files left for this id: drop the reverse-index entry.
if (sourceMessagesForId.size === 0) {
this.sourceMessagesById.delete(id);
}
}
const nextSourceMessagesForId = nextFileMessages.get(id);
if (nextSourceMessagesForId) {
let sourceMessagesByFile = this.sourceMessagesById.get(id);
if (!sourceMessagesByFile) {
sourceMessagesByFile = new Map();
this.sourceMessagesById.set(id, sourceMessagesByFile);
}
sourceMessagesByFile.set(absoluteFilePath, nextSourceMessagesForId);
}
this.rebuildMessageById(id);
}
const changed = this.haveMessagesChangedForFile(prevFileMessages, nextFileMessages);
return changed;
}
groupSourceMessagesById(messages) {
const result = new Map();
for (const message of messages) {
const messagesById = result.get(message.id);
if (messagesById) {
messagesById.push(message);
} else {
result.set(message.id, [message]);
}
}
return result;
}
rebuildMessageById(id) {
const sourceMessages = Array.from(this.sourceMessagesById.get(id)?.values() ?? []).flat();
if (sourceMessages.length === 0) {
this.messagesById.delete(id);
return;
}
const previousMessage = this.messagesById.get(id);
const aggregate = {
description: this.mergeDescriptions(sourceMessages),
id,
message: sourceMessages[0].message,
references: sourceMessages.map(message => message.reference).sort(compareReferences)
};
if (previousMessage) {
for (const key of Object.keys(previousMessage)) {
// Preserve extra fields (e.g. from disk/codec) across rebuilds; the
// four core fields above are always recomputed from source messages.
if (!CatalogManager.extractorOwnedAggregatorKeys.has(key) && aggregate[key] == null) {
aggregate[key] = previousMessage[key];
}
}
}
this.messagesById.set(id, aggregate);
}
mergeDescriptions(messages) {
const sortedByReference = messages.toSorted((a, b) => compareReferences(a.reference, b.reference));
const merged = [];
for (const message of sortedByReference) {
const {
description
} = message;
if (description != null && !merged.includes(description)) {
merged.push(description);
}
}
return merged;
}
haveMessagesChangedForFile(beforeMessages, afterMessages) {
// If one exists and the other doesn't, there's a change
if (!beforeMessages) {
return afterMessages.size > 0;
}
// Different sizes means changes
if (beforeMessages.size !== afterMessages.size) {
return true;
}
// Check differences in beforeMessages vs afterMessages
for (const [id, prevSourceMessages] of beforeMessages) {
const nextSourceMessages = afterMessages.get(id);
if (!nextSourceMessages) {
return true;
}
if (!this.areSourceMessageArraysEqual(prevSourceMessages, nextSourceMessages)) {
return true; // Early exit on first difference
}
}
return false;
}
areSourceMessageArraysEqual(messages1, messages2) {
return messages1.length === messages2.length && messages1.every((message, index) => this.areSourceMessagesEqual(message, messages2[index]));
}
areSourceMessagesEqual(msg1, msg2) {
return msg1.id === msg2.id && msg1.message === msg2.message && msg1.description === msg2.description && msg1.reference.path === msg2.reference.path && msg1.reference.line === msg2.reference.line;
}
async save() {
return this.saveScheduler.schedule(() => this.saveImpl());
}
async saveImpl() {
await this.saveLocale(this.config.extract.sourceLocale);
const targetLocales = await this.getTargetLocales();
await Promise.all(targetLocales.map(locale => this.saveLocale(locale)));
}
async saveLocale(locale) {
await this.loadCatalogsPromise;
const messages = Array.from(this.messagesById.values());
const persister = await this.getPersister();
const isSourceLocale = locale === this.config.extract.sourceLocale;
// Check if file was modified externally (poll-at-save is cheaper than
// watchers here since stat() is fast and avoids continuous overhead)
const lastWriteTime = this.lastWriteByLocale.get(locale);
const currentFileTime = await persister.getLastModified(locale);
if (currentFileTime && lastWriteTime && currentFileTime > lastWriteTime) {
await this.reloadLocaleCatalog(locale);
}
const localeMessages = isSourceLocale ? this.messagesById : this.translationsByTargetLocale.get(locale);
const messagesToPersist = messages.map(message => {
const localeMessage = localeMessages?.get(message.id);
return {
...localeMessage,
id: message.id,
description: message.description,
references: message.references,
message: isSourceLocale ? message.message : localeMessage?.message ?? ''
};
});
await persister.write(messagesToPersist, {
locale,
sourceMessagesById: this.messagesById
});
// Update timestamps
const newTime = await persister.getLastModified(locale);
this.lastWriteByLocale.set(locale, newTime);
}
onLocalesChange = async params => {
// Chain to existing promise
this.loadCatalogsPromise = Promise.all([this.loadCatalogsPromise, ...params.added.map(locale => this.reloadLocaleCatalog(locale))]);
for (const locale of params.added) {
await this.saveLocale(locale);
}
for (const locale of params.removed) {
this.translationsByTargetLocale.delete(locale);
this.lastWriteByLocale.delete(locale);
}
};
async handleFileEvents(events) {
if (this.loadCatalogsPromise) {
await this.loadCatalogsPromise;
}
// Wait for initial scan to complete to avoid race conditions
if (this.scanCompletePromise) {
await this.scanCompletePromise;
}
let changed = false;
const expandedEvents = await this.sourceWatcher.expandDirectoryDeleteEvents(events, Array.from(this.sourceMessagesByFile.keys()));
// Stable file order keeps catalog ties independent of event timing.
for (const event of expandedEvents.toSorted((a, b) => localeCompare(a.path, b.path))) {
const hasChanged = await this.processFile(event.path);
changed ||= hasChanged;
}
if (changed) {
await this.save();
}
}
[Symbol.dispose]() {
this.sourceWatcher?.stop();
this.sourceWatcher = undefined;
this.saveScheduler[Symbol.dispose]();
if (this.catalogLocales && this.isDevelopment) {
this.catalogLocales.unsubscribeLocalesChange(this.onLocalesChange);
}
}
}
class LRUCache {
constructor(maxSize) {
this.maxSize = maxSize;
this.cache = new Map();
}
set(key, value) {
const isNewKey = !this.cache.has(key);
if (isNewKey && this.cache.size >= this.maxSize) {
const lruKey = this.cache.keys().next().value;
if (lruKey !== undefined) {
this.cache.delete(lruKey);
}
}
this.cache.set(key, {
key,
value
});
}
get(key) {
const item = this.cache.get(key);
if (item) {
this.cache.delete(key);
this.cache.set(key, item);
return item.value;
}
return undefined;
}
}
const require$2 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('plugin-DlFYUFWh.cjs', document.baseURI).href)));
class MessageExtractor {
compileCache = new LRUCache(750);
constructor(opts) {
this.isDevelopment = opts.isDevelopment ?? false;
this.projectRoot = opts.projectRoot ?? getDefaultProjectRoot();
this.sourceMap = opts.sourceMap ?? false;
}
async extract(absoluteFilePath, source) {
const cacheKey = [source, absoluteFilePath].join('!');
const cached = this.compileCache.get(cacheKey);
if (cached) return cached;
// Shortcut parsing if hook is not used. The Turbopack integration already
// pre-filters this, but for webpack this feature doesn't exist, so we need
// to do it here.
if (!source.includes('useExtracted') && !source.includes('getExtracted')) {
return {
messages: [],
code: source
};
}
const filePath = normalizePathToPosix(path__default.default.relative(this.projectRoot, absoluteFilePath));
const result = await core.transform(source, {
jsc: {
target: 'esnext',
parser: {
syntax: 'typescript',
tsx: true,
decorators: true
},
experimental: {
cacheRoot: 'node_modules/.cache/swc',
disableBuiltinTransformsForInternalTesting: true,
disableAllLints: true,
plugins: [[require$2.resolve('next-intl-swc-plugin-extractor'), {
isDevelopment: this.isDevelopment,
filePath
}]]
}
},
sourceMaps: this.sourceMap,
sourceFileName: filePath,
filename: filePath
});
// TODO: Improve the typing of @swc/core
const output = result.output;
const messages = JSON.parse(JSON.parse(output).results);
const extractionResult = {
code: result.code,
map: result.map,
messages
};
this.compileCache.set(cacheKey, extractionResult);
return extractionResult;
}
}
class ExtractionCompiler {
constructor(config, opts = {}) {
const extractor = opts.extractor ?? new MessageExtractor(opts);
this.manager = new CatalogManager(config, {
...opts,
extractor
});
this[Symbol.dispose] = this[Symbol.dispose].bind(this);
this.installExitHandlers();
}
async extractAll() {
// We can't rely on all files being compiled (e.g. due to persistent
// caching), so loading the messages initially is necessary.
await this.manager.loadMessages();
await this.manager.save();
}
[Symbol.dispose]() {
this.uninstallExitHandlers();
this.manager[Symbol.dispose]();
}
installExitHandlers() {
const cleanup = this[Symbol.dispose];
process.on('exit', cleanup);
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
}
uninstallExitHandlers() {
const cleanup = this[Symbol.dispose];
process.off('exit', cleanup);
process.off('SIGINT', cleanup);
process.off('SIGTERM', cleanup);
}
}
// Avoid rollup's `replace` plugin to compile this away
const nodeEnvKey = 'NODE_ENV'.trim();
// We avoid reading `argv.includes('dev')` related to
// https://github.com/amannn/next-intl/issues/2006
const isDevelopment = process.env[nodeEnvKey] === 'development';
const isNextBuild = process.argv.includes('build');
const isDevelopmentOrNextBuild = isDevelopment || isNextBuild;
// Single compiler instance, initialized once per process
let compiler;
const runOnce = once('_NEXT_INTL_EXTRACT');
function initExtractionCompiler(extractorConfig) {
if (!extractorConfig || !hasLocalesToExtract(extractorConfig)) {
return;
}
// Avoid running for:
// - info
// - start
// - typegen
//
// Doesn't consult Next.js config anyway:
// - lint
// - telemetry (however, the `detached-flush` DOES - see `createNextIntlPlugin`)
//
// What remains are:
// - dev (NODE_ENV=development)
// - build (NODE_ENV=production)
const shouldRun = isDevelopment || isNextBuild;
if (!shouldRun) return;
runOnce(() => {
compiler = new ExtractionCompiler(extractorConfig, {
isDevelopment,
projectRoot: process.cwd()
});
// Fire-and-forget: Start extraction, don't block config return.
// In dev mode, this also starts the file watcher.
// In prod, ideally we would wait until the extraction is complete,
// but we can't `await` anywhere (at least for Turbopack).
// The result is ok though, as if we encounter untranslated messages,
// we'll simply add empty messages to the catalog. So for actually
// running the app, there is no difference.
compiler.extractAll();
function cleanup() {
if (compiler) {
compiler[Symbol.dispose]();
compiler = undefined;
}
}
process.on('exit', cleanup);
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
});
}
function getCurrentVersion() {
try {
const require$1 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('plugin-DlFYUFWh.cjs', document.baseURI).href)));
const pkg = require$1('next/package.json');
return pkg.version;
} catch (error) {
throw new Error('Failed to get current Next.js version. This can happen if next-intl/plugin is imported into your app code outside of your next.config.js.', {
cause: error
});
}
}
function compareVersions(version1, version2) {
const v1Parts = version1.split('.').map(Number);
const v2Parts = version2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const v1 = v1Parts[i] || 0;
const v2 = v2Parts[i] || 0;
if (v1 > v2) return 1;
if (v1 < v2) return -1;
}
return 0;
}
function hasStableTurboConfig() {
return compareVersions(getCurrentVersion(), '15.3.0') >= 0;
}
function isNextJs16OrHigher() {
return compareVersions(getCurrentVersion(), '16.0.0') >= 0;
}
const require$1 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('plugin-DlFYUFWh.cjs', document.baseURI).href)));
function withExtensions(localPath) {
return [`${localPath}.ts`, `${localPath}.tsx`, `${localPath}.js`, `${localPath}.jsx`];
}
function normalizeTurbopackAliasPath(pathname) {
// Turbopack alias targets should use forward slashes; Windows backslashes can
// break resolution in dev (see `next-intl/config` alias path style).
return pathname.replace(/\\/g, '/');
}
function resolveI18nPath(providedPath, cwd) {
function resolvePath(pathname) {
const parts = [];
if (cwd) parts.push(cwd);
parts.push(pathname);
return path__default.default.resolve(...parts);
}
function pathExists(pathname) {
return fs__default.default.existsSync(resolvePath(pathname));
}
if (providedPath) {
// We use the `isNextDevOrBuild` condition to avoid throwing errors
// if `next.config.ts` is read by a non-Next.js process.
// https://github.com/amannn/next-intl/discussions/2209#discussioncomment-15650927
if (isDevelopmentOrNextBuild && !pathExists(providedPath)) {
throwError(`Could not find i18n config at ${providedPath}, please provide a valid path.`);
}
return providedPath;
} else {
for (const candidate of [...withExtensions('./i18n/request'), ...withExtensions('./src/i18n/request')]) {
if (pathExists(candidate)) {
return candidate;
}
}
if (isDevelopmentOrNextBuild) {
throwError(`Could not locate request configuration module.\n\nThis path is supported by default: ./(src/)i18n/request.{js,jsx,ts,tsx}\n\nAlternatively, you can specify a custom location in your Next.js config:\n\nconst withNextIntl = createNextIntlPlugin(\n './path/to/i18n/request.tsx'\n);`);
}
// Default as fallback
if (pathExists('./src')) {
return './src/i18n/request.ts';
} else {
return './i18n/request.ts';
}
}
}
function getNextConfig(pluginConfig, nextConfig, extractorConfig) {
const useTurbo = process.env.TURBOPACK != null;
// `experimental-analyze` doesn’t set the TURBOPACK env param. Since Next.js
// 16 doesn't print a warning when we configure both Turbo- and Webpack, just
// always configure Turbopack just in case.
const shouldConfigureTurbo = useTurbo || isNextJs16OrHigher();
const nextIntlConfig = {};
let messageLoadPaths = [];
if (pluginConfig.experimental?.messages) {
messageLoadPaths = normalizeMessagesCatalogPaths(pluginConfig.experimental.messages.path);
}
function getExtractMessagesLoaderConfig(config) {
return {
loader: 'next-intl/extractor/extractionLoader',
options: config
};
}
function getCatalogLoaderConfig() {
const messages = pluginConfig.experimental.messages;
return {
loader: 'next-intl/extractor/catalogLoader',
options: {
messages: {
format: messages.format,
...(messages.precompile !== undefined && {
precompile: messages.precompile
})
}
}
};
}
function getTurboRules() {
return nextConfig?.turbopack?.rules ||
// @ts-expect-error -- For Next.js <16
nextConfig?.experimental?.turbo?.rules || {};
}
function addTurboRule(rules, glob, rule) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (rules[glob]) {
if (Array.isArray(rules[glob])) {
rules[glob].push(rule);
} else {
rules[glob] = [rules[glob], rule];
}
} else {
rules[glob] = rule;
}
}
if (shouldConfigureTurbo) {
if (pluginConfig.requestConfig && path__default.default.isAbsolute(pluginConfig.requestConfig)) {
throwError("Turbopack support for next-intl currently does not support absolute paths, please provide a relative one (e.g. './src/i18n/config.ts').\n\nFound: " + pluginConfig.requestConfig);
}
// Assign alias for `next-intl/config`
const resolveAlias = {
// Turbo aliases don't work with absolute
// paths (see error handling above)
'next-intl/config': resolveI18nPath(pluginConfig.requestConfig)
};
// Add alias for precompiled message formatting
if (pluginConfig.experimental?.messages?.precompile) {
// Workaround for https://github.com/vercel/next.js/issues/88540
let formatOnlyPath = path__default.default.relative(process.cwd(), require$1.resolve('use-intl/format-message/format-only'));
// Turbopack seems to require this, otherwise `use-intl/format-message` is
// still bundled (despite the code correctly calling into `format-only`).
// Note that in this monorepo this is not necessary, because we'll end
// up with a path like `../…` — but for actual consumers this is required.
if (!formatOnlyPath.startsWith('.')) {
formatOnlyPath = `./${formatOnlyPath}`;
}
resolveAlias['use-intl/format-message'] = normalizeTurbopackAliasPath(formatOnlyPath);
}
// Add loaders
let rules;
// Add loader for extrac