deploy: v0.12 fix 存档key名

This commit is contained in:
2026-06-24 03:09:02 +00:00
parent 7a08d49fe3
commit e0a5281119
58743 changed files with 7297269 additions and 39505 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Jan Amann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+69
View File
@@ -0,0 +1,69 @@
<p align="center">
<br>
<a href="https://next-intl.dev">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="media/logo-dark-mode.svg">
<source media="(prefers-color-scheme: light)" srcset="media/logo.svg">
<img alt="next-intl" src="media/logo.svg" width="600">
</picture>
</a>
<br>
<br>
</p>
Internationalization for Next.js.
## Features
Internationalization (i18n) is an essential part of the user experience, therefore `next-intl` gives you all the parts you need to get language nuances right.
- 🌟 **ICU message syntax**: Localize your messages with interpolation, cardinal & ordinal plurals, enum-based label selection and rich text.
- 📅 **Dates, times & numbers**: Apply appropriate formatting without worrying about server/client differences like time zones.
- ✅ **Type-safe**: Speed up development with autocompletion for message keys and catch typos early with compile-time checks.
- 💡 **Hooks-based API**: Learn a single API that can be used across your code base to turn translations into plain strings or rich text.
- 🚀 **Next.js-native and performance-obsessed**: App Router, Server Components, static rendering—pick the right tool for the right job, next-intl works everywhere.
- 🌍 **Internationalized routing**: Provide unique pathnames per language and optionally localize pathnames for search engine optimization.
## What does it look like?
```jsx
// UserProfile.tsx
import {useTranslations} from 'next-intl';
export default function UserProfile({user}) {
const t = useTranslations('UserProfile');
return (
<section>
<h1>{t('title', {firstName: user.firstName})}</h1>
<p>{t('membership', {memberSince: user.memberSince})}</p>
<p>{t('followers', {count: user.numFollowers})}</p>
</section>
);
}
```
```js
// en.json
{
"UserProfile": {
"title": "{firstName}'s profile",
"membership": "Member since {memberSince, date, short}",
"followers": "{count, plural, ↵
=0 {No followers yet} ↵
=1 {One follower} ↵
other {# followers} ↵
}"
}
}
```
### [→ Read the docs](https://next-intl.dev)
<div align="center">
<a href="https://next-intl.dev/redirect?href=https://crowdin.com" target="_blank">
<img width="350" src="media/partner.svg" alt="Crowdin logo">
</a>
<br>
<p>Hosted on <a href="https://vercel.com?utm_source=next-intl&utm_campaign=oss">Vercel</a></p>
</div>
+4
View File
@@ -0,0 +1,4 @@
// Needed for projects with `moduleResolution: 'node'`
import config from './dist/types/config';
export default config;
@@ -0,0 +1,7 @@
'use strict';
function defineCodec(factory) {
return factory;
}
exports.defineCodec = defineCodec;
+48
View File
@@ -0,0 +1,48 @@
'use strict';
var plugin = require('./plugin-B0KIcFlc.cjs');
var ExtractorCodec = require('./ExtractorCodec-D9Tw618d.cjs');
require('fs/promises');
require('path');
require('@parcel/watcher');
require('fs');
require('module');
require('@swc/core');
var JSONCodec = ExtractorCodec.defineCodec(() => ({
decode(source) {
const json = JSON.parse(source);
const messages = [];
traverseMessages(json, (message, id) => {
messages.push({
id,
message
});
});
return messages;
},
encode(messages) {
const root = {};
for (const message of plugin.getSortedMessages(messages)) {
plugin.setNestedProperty(root, message.id, message.message);
}
return JSON.stringify(root, null, 2) + '\n';
},
toJSONString(source) {
return source;
}
}));
function traverseMessages(obj, callback, path = '') {
const NAMESPACE_SEPARATOR = '.';
for (const key of Object.keys(obj)) {
const newPath = path ? path + NAMESPACE_SEPARATOR + key : key;
const value = obj[key];
if (typeof value === 'string') {
callback(value, newPath);
} else if (typeof value === 'object') {
traverseMessages(value, callback, newPath);
}
}
}
exports.default = JSONCodec;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
var POParser = require('po-parser');
var plugin = require('./plugin-B0KIcFlc.cjs');
var ExtractorCodec = require('./ExtractorCodec-D9Tw618d.cjs');
require('fs/promises');
require('path');
require('@parcel/watcher');
require('fs');
require('module');
require('@swc/core');
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
var POParser__default = /*#__PURE__*/_interopDefaultCompat(POParser);
var POCodec = ExtractorCodec.defineCodec(() => {
// See also https://www.gnu.org/software/gettext/manual/html_node/Header-Entry.html
const DEFAULT_METADATA = {
// Recommended by spec
'Content-Type': 'text/plain; charset=utf-8',
'Content-Transfer-Encoding': '8bit',
// Otherwise other tools might set this
'X-Generator': 'next-intl',
// Crowdin defaults to using msgid as source key
'X-Crowdin-SourceKey': 'msgstr'
};
// Move all parts before the last dot to msgctxt
const NAMESPACE_SEPARATOR = '.';
// Metadata is stored so it can be retained when writing
const metadataByLocale = new Map();
return {
decode(content, context) {
const catalog = POParser__default.default.parse(content);
if (catalog.meta) {
metadataByLocale.set(context.locale, catalog.meta);
}
const messages = catalog.messages || [];
return messages.map(msg => {
const {
extractedComments,
msgctxt,
msgid,
msgstr,
...rest
} = msg;
if (extractedComments && extractedComments.length > 1) {
throw new Error(`Multiple extracted comments are not supported. Found ${extractedComments.length} comments for msgid "${msgid}".`);
}
return {
...rest,
id: msgctxt ? [msgctxt, msgid].join(NAMESPACE_SEPARATOR) : msgid,
message: msgstr,
...(extractedComments && extractedComments.length > 0 && {
description: extractedComments[0]
})
};
});
},
encode(messages, context) {
const encodedMessages = plugin.getSortedMessages(messages).map(msg => {
const {
description,
id,
message,
...rest
} = msg;
const lastDotIndex = id.lastIndexOf(NAMESPACE_SEPARATOR);
const hasNamespace = id.includes(NAMESPACE_SEPARATOR);
const msgid = hasNamespace ? id.slice(lastDotIndex + NAMESPACE_SEPARATOR.length) : id;
return {
msgid,
msgstr: message,
...(description && {
extractedComments: [description]
}),
...(hasNamespace && {
msgctxt: id.slice(0, lastDotIndex)
}),
...rest
};
});
return POParser__default.default.serialize({
meta: {
Language: context.locale,
...DEFAULT_METADATA,
...metadataByLocale.get(context.locale)
},
messages: encodedMessages
});
},
toJSONString(source, context) {
const parsed = this.decode(source, context);
const messagesObject = {};
for (const message of parsed) {
plugin.setNestedProperty(messagesObject, message.id, message.message);
}
return JSON.stringify(messagesObject);
}
};
});
exports.default = POCodec;
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
'use strict';
var plugin = require('./plugin-B0KIcFlc.cjs');
require('fs/promises');
require('path');
require('@parcel/watcher');
require('fs');
require('module');
require('@swc/core');
module.exports = plugin.createNextIntlPlugin;
+5
View File
@@ -0,0 +1,5 @@
function getConfig() {
throw new Error("Couldn't find next-intl config file. Please follow the instructions at https://next-intl.dev/docs/getting-started/app-router");
}
export { getConfig as default };
+2
View File
@@ -0,0 +1,2 @@
export { default as unstable_extractMessages } from './extractor/extractMessages.js';
export { defineCodec } from './extractor/format/ExtractorCodec.js';
@@ -0,0 +1,38 @@
import CatalogManager from './catalog/CatalogManager.js';
import MessageExtractor from './extractor/MessageExtractor.js';
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);
}
}
export { ExtractionCompiler as default };
@@ -0,0 +1,84 @@
import fs$1 from 'fs';
import fs from 'fs/promises';
import path from 'path';
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.readdir(this.messagesDir);
return files.filter(file => file.endsWith(this.extension)).map(file => path.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.mkdir(this.messagesDir, {
recursive: true
});
this.watcher = fs$1.watch(this.messagesDir, {
persistent: false,
recursive: false
}, (event, filename) => {
const isCatalogFile = filename != null && filename.endsWith(this.extension) && !filename.includes(path.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
});
}
}
}
}
export { CatalogLocales as default };
@@ -0,0 +1,370 @@
import fs from 'fs/promises';
import path from 'path';
import { resolveCodec, getFormatExtension } from '../format/index.js';
import SourceFileScanner from '../source/SourceFileScanner.js';
import SourceFileWatcher from '../source/SourceFileWatcher.js';
import { getDefaultProjectRoot, compareReferences } from '../utils.js';
import CatalogLocales from './CatalogLocales.js';
import CatalogPersister from './CatalogPersister.js';
import SaveScheduler from './SaveScheduler.js';
class CatalogManager {
/**
* The source of truth for which messages are used.
* NOTE: Should be mutated in place to keep `messagesById` and `messagesByFile` in sync.
*/
messagesByFile = (() => new Map())();
/**
* Fast lookup for messages by ID across all files,
* contains the same messages as `messagesByFile`.
* NOTE: Should be mutated in place to keep `messagesById` and `messagesByFile` in sync.
*/
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(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.messages.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.join(this.projectRoot, this.config.messages.path);
this.catalogLocales = new CatalogLocales({
messagesDir,
sourceLocale: this.config.sourceLocale,
extension: getFormatExtension(this.config.messages.format),
locales: this.config.messages.locales
});
return this.catalogLocales;
}
}
async getTargetLocales() {
return this.getCatalogLocales().getTargetLocales();
}
getSrcPaths() {
return (Array.isArray(this.config.srcPath) ? this.config.srcPath : [this.config.srcPath]).map(srcPath => path.join(this.projectRoot, srcPath));
}
async loadMessages() {
const sourceDiskMessages = await this.loadSourceMessages();
this.loadCatalogsPromise = this.loadTargetMessages();
await this.loadCatalogsPromise;
this.scanCompletePromise = (async () => {
const sourceFiles = await SourceFileScanner.getSourceFiles(this.getSrcPaths());
await Promise.all(Array.from(sourceFiles).map(async filePath => this.processFile(filePath)));
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.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.sourceLocale) {
// For source: Merge additional properties like flags
for (const diskMessage of diskMessages) {
const prev = this.messagesById.get(diskMessage.id);
if (prev) {
// Mutate the existing object instead of creating a copy
// to keep messagesById and messagesByFile in sync.
// Unknown properties (like flags): disk wins
// Known properties: existing (from extraction) wins
for (const key of Object.keys(diskMessage)) {
if (!['id', 'message', 'description', 'references'].includes(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;
// Mutate the existing object instead of creating a copy.
// This keeps `messagesById` and `messagesByFile` in sync since
// they reference the same object instance.
for (const key of Object.keys(diskMessage)) {
if (existing[key] == null) {
existing[key] = diskMessage[key];
}
}
}
}
async processFile(absoluteFilePath) {
let messages = [];
try {
const content = await fs.readFile(absoluteFilePath, 'utf8');
let extraction;
try {
extraction = await this.extractor.extract(absoluteFilePath, content);
} catch {
return false;
}
messages = extraction.messages;
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
// ENOENT -> treat as no messages
}
const prevFileMessages = this.messagesByFile.get(absoluteFilePath);
const relativeFilePath = path.relative(this.projectRoot, absoluteFilePath);
// Init with all previous ones
const idsToRemove = Array.from(prevFileMessages?.keys() ?? []);
// Replace existing messages with new ones
const fileMessages = new Map();
for (let message of messages) {
const prevMessage = this.messagesById.get(message.id);
// Merge with previous message if it exists
if (prevMessage) {
message = {
...message
};
if (message.references) {
message.references = this.mergeReferences(prevMessage.references ?? [], relativeFilePath, message.references);
}
// Merge other properties like description, or unknown
// attributes like flags that are opaque to us
for (const key of Object.keys(prevMessage)) {
if (message[key] == null) {
message[key] = prevMessage[key];
}
}
}
this.messagesById.set(message.id, message);
fileMessages.set(message.id, message);
// This message continues to exist in this file
const index = idsToRemove.indexOf(message.id);
if (index !== -1) idsToRemove.splice(index, 1);
}
// Clean up removed messages from `messagesById`
idsToRemove.forEach(id => {
const message = this.messagesById.get(id);
if (!message) return;
const hasOtherReferences = message.references?.some(ref => ref.path !== relativeFilePath);
if (!hasOtherReferences) {
// No other references, delete the message entirely
this.messagesById.delete(id);
} else {
// Message is used elsewhere, remove this file from references
// Mutate the existing object to keep `messagesById` and `messagesByFile` in sync
message.references = message.references?.filter(ref => ref.path !== relativeFilePath);
}
});
// Update the stored messages
if (messages.length > 0) {
this.messagesByFile.set(absoluteFilePath, fileMessages);
} else {
this.messagesByFile.delete(absoluteFilePath);
}
const changed = this.haveMessagesChangedForFile(prevFileMessages, fileMessages);
return changed;
}
mergeReferences(existing, currentFilePath, currentFileRefs) {
// Keep refs from other files, replace all refs from the current file
const otherFileRefs = existing.filter(ref => ref.path !== currentFilePath);
const merged = [...otherFileRefs, ...currentFileRefs];
return merged.sort(compareReferences);
}
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, msg1] of beforeMessages) {
const msg2 = afterMessages.get(id);
if (!msg2 || !this.areMessagesEqual(msg1, msg2)) {
return true; // Early exit on first difference
}
}
return false;
}
areMessagesEqual(msg1, msg2) {
// Note: We intentionally don't compare references here.
// References are aggregated metadata from multiple files and comparing
// them would cause false positives due to parallel extraction order.
return msg1.id === msg2.id && msg1.message === msg2.message && msg1.description === msg2.description;
}
async save() {
return this.saveScheduler.schedule(() => this.saveImpl());
}
async saveImpl() {
await this.saveLocale(this.config.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.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.messagesByFile.keys()));
for (const event of expandedEvents) {
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);
}
}
}
export { CatalogManager as default };
@@ -0,0 +1,63 @@
import fs from 'fs/promises';
import path from 'path';
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.join(this.messagesPath, this.getFileName(locale));
}
async read(locale) {
const filePath = this.getFilePath(locale);
let content;
try {
content = await fs.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.dirname(filePath);
await fs.mkdir(outputDir, {
recursive: true
});
await fs.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.stat(filePath);
return stats.mtime;
} catch {
return undefined;
}
}
}
export { CatalogPersister as default };
@@ -0,0 +1,85 @@
/**
* 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;
}
}
export { SaveScheduler as default };
+35
View File
@@ -0,0 +1,35 @@
import path from 'path';
import { getFormatExtension, resolveCodec } from './format/index.js';
let cachedCodec = null;
async function getCodec(options, projectRoot) {
if (!cachedCodec) {
cachedCodec = await resolveCodec(options.messages.format, projectRoot);
}
return cachedCodec;
}
/**
* Parses and optimizes catalog files.
*
* Note that if we use a dynamic import like `import(`${locale}.json`)`, then
* the loader will optimistically run for all candidates in this folder (both
* during dev as well as at build time).
*/
function catalogLoader(source) {
const options = this.getOptions();
const callback = this.async();
const extension = getFormatExtension(options.messages.format);
getCodec(options, this.rootContext).then(codec => {
const locale = path.basename(this.resourcePath, extension);
const jsonString = codec.toJSONString(source, {
locale
});
// https://v8.dev/blog/cost-of-javascript-2019#json
const result = `export default JSON.parse(${JSON.stringify(jsonString)});`;
callback(null, result);
}).catch(callback);
}
export { catalogLoader as default };
@@ -0,0 +1,15 @@
import ExtractionCompiler from './ExtractionCompiler.js';
import MessageExtractor from './extractor/MessageExtractor.js';
import { getDefaultProjectRoot } from './utils.js';
async function extractMessages(params) {
const compiler = new ExtractionCompiler(params, {
extractor: new MessageExtractor({
isDevelopment: false,
projectRoot: getDefaultProjectRoot()
})
});
await compiler.extractAll();
}
export { extractMessages as default };
@@ -0,0 +1,26 @@
import MessageExtractor from './extractor/MessageExtractor.js';
// Module-level extractor instance for transformation caching.
// Note: Next.js/Turbopack may create multiple loader instances, but each
// only handles file transformation. The ExtractionCompiler (which manages
// catalogs) is initialized separately in createNextIntlPlugin.
let extractor;
function extractionLoader(source) {
const callback = this.async();
const projectRoot = this.rootContext;
// Avoid rollup's `replace` plugin to compile this away
const isDevelopment = process.env['NODE_ENV'.trim()] === 'development';
if (!extractor) {
extractor = new MessageExtractor({
isDevelopment,
projectRoot,
sourceMap: this.sourceMap
});
}
extractor.extract(this.resourcePath, source).then(result => {
callback(null, result.code, result.map);
}).catch(callback);
}
export { extractionLoader as default };
@@ -0,0 +1,30 @@
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;
}
}
export { LRUCache as default };
@@ -0,0 +1,66 @@
import { createRequire } from 'module';
import path from 'path';
import { transform } from '@swc/core';
import { getDefaultProjectRoot } from '../utils.js';
import LRUCache from './LRUCache.js';
const require = createRequire(import.meta.url);
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 = path.relative(this.projectRoot, absoluteFilePath);
const result = await transform(source, {
jsc: {
target: 'esnext',
parser: {
syntax: 'typescript',
tsx: true,
decorators: true
},
experimental: {
cacheRoot: 'node_modules/.cache/swc',
disableBuiltinTransformsForInternalTesting: true,
disableAllLints: true,
plugins: [[require.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;
}
}
export { MessageExtractor as default };
@@ -0,0 +1,5 @@
function defineCodec(factory) {
return factory;
}
export { defineCodec };
@@ -0,0 +1,40 @@
import { getSortedMessages, setNestedProperty } from '../../utils.js';
import { defineCodec } from '../ExtractorCodec.js';
var JSONCodec = defineCodec(() => ({
decode(source) {
const json = JSON.parse(source);
const messages = [];
traverseMessages(json, (message, id) => {
messages.push({
id,
message
});
});
return messages;
},
encode(messages) {
const root = {};
for (const message of getSortedMessages(messages)) {
setNestedProperty(root, message.id, message.message);
}
return JSON.stringify(root, null, 2) + '\n';
},
toJSONString(source) {
return source;
}
}));
function traverseMessages(obj, callback, path = '') {
const NAMESPACE_SEPARATOR = '.';
for (const key of Object.keys(obj)) {
const newPath = path ? path + NAMESPACE_SEPARATOR + key : key;
const value = obj[key];
if (typeof value === 'string') {
callback(value, newPath);
} else if (typeof value === 'object') {
traverseMessages(value, callback, newPath);
}
}
}
export { JSONCodec as default };
@@ -0,0 +1,93 @@
import POParser from 'po-parser';
import { setNestedProperty, getSortedMessages } from '../../utils.js';
import { defineCodec } from '../ExtractorCodec.js';
var POCodec = defineCodec(() => {
// See also https://www.gnu.org/software/gettext/manual/html_node/Header-Entry.html
const DEFAULT_METADATA = {
// Recommended by spec
'Content-Type': 'text/plain; charset=utf-8',
'Content-Transfer-Encoding': '8bit',
// Otherwise other tools might set this
'X-Generator': 'next-intl',
// Crowdin defaults to using msgid as source key
'X-Crowdin-SourceKey': 'msgstr'
};
// Move all parts before the last dot to msgctxt
const NAMESPACE_SEPARATOR = '.';
// Metadata is stored so it can be retained when writing
const metadataByLocale = new Map();
return {
decode(content, context) {
const catalog = POParser.parse(content);
if (catalog.meta) {
metadataByLocale.set(context.locale, catalog.meta);
}
const messages = catalog.messages || [];
return messages.map(msg => {
const {
extractedComments,
msgctxt,
msgid,
msgstr,
...rest
} = msg;
if (extractedComments && extractedComments.length > 1) {
throw new Error(`Multiple extracted comments are not supported. Found ${extractedComments.length} comments for msgid "${msgid}".`);
}
return {
...rest,
id: msgctxt ? [msgctxt, msgid].join(NAMESPACE_SEPARATOR) : msgid,
message: msgstr,
...(extractedComments && extractedComments.length > 0 && {
description: extractedComments[0]
})
};
});
},
encode(messages, context) {
const encodedMessages = getSortedMessages(messages).map(msg => {
const {
description,
id,
message,
...rest
} = msg;
const lastDotIndex = id.lastIndexOf(NAMESPACE_SEPARATOR);
const hasNamespace = id.includes(NAMESPACE_SEPARATOR);
const msgid = hasNamespace ? id.slice(lastDotIndex + NAMESPACE_SEPARATOR.length) : id;
return {
msgid,
msgstr: message,
...(description && {
extractedComments: [description]
}),
...(hasNamespace && {
msgctxt: id.slice(0, lastDotIndex)
}),
...rest
};
});
return POParser.serialize({
meta: {
Language: context.locale,
...DEFAULT_METADATA,
...metadataByLocale.get(context.locale)
},
messages: encodedMessages
});
},
toJSONString(source, context) {
const parsed = this.decode(source, context);
const messagesObject = {};
for (const message of parsed) {
setNestedProperty(messagesObject, message.id, message.message);
}
return JSON.stringify(messagesObject);
}
};
});
export { POCodec as default };
+44
View File
@@ -0,0 +1,44 @@
import path from 'path';
import { throwError } from '../../plugin/utils.js';
const formats = {
json: {
codec: () => import('./codecs/JSONCodec.js'),
extension: '.json'
},
po: {
codec: () => import('./codecs/POCodec.js'),
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.isAbsolute(format.codec) ? format.codec : path.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();
}
}
export { formats as default, getFormatExtension, resolveCodec };
@@ -0,0 +1,29 @@
import path from 'path';
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.extname(filePath);
return SourceFileFilter.EXTENSIONS.map(cur => '.' + cur).includes(ext);
}
static shouldEnterDirectory(dirPath, srcPaths) {
const dirName = path.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.relative(basePath, targetPath);
return relativePath === '' || !relativePath.startsWith('..');
}
}
export { SourceFileFilter as default };
@@ -0,0 +1,31 @@
import fs from 'fs/promises';
import path from 'path';
import SourceFileFilter from './SourceFileFilter.js';
class SourceFileScanner {
static async walkSourceFiles(dir, srcPaths, acc = []) {
const entries = await fs.readdir(dir, {
withFileTypes: true
});
for (const entry of entries) {
const entryPath = path.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);
}
}
export { SourceFileScanner as default };
@@ -0,0 +1,132 @@
import fs from 'fs/promises';
import path from 'path';
import { subscribe } from '@parcel/watcher';
import SourceFileFilter from './SourceFileFilter.js';
import SourceFileScanner from './SourceFileScanner.js';
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 subscribe(root, async (err, events) => {
if (err) {
console.error(err);
return;
}
const filtered = await this.normalizeEvents(events);
if (filtered.length > 0) {
void 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.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.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();
}
}
export { SourceFileWatcher as default };
+38
View File
@@ -0,0 +1,38 @@
// Essentialls lodash/set, but we avoid this dependency
function setNestedProperty(obj, keyPath, value) {
const keys = keyPath.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
function getSortedMessages(messages) {
return messages.toSorted((messageA, messageB) => {
const refA = messageA.references?.[0];
const refB = messageB.references?.[0];
// No references: preserve original (extraction) order
if (!refA || !refB) return 0;
// Sort by path, then line. Same path+line: preserve original order
return compareReferences(refA, refB);
});
}
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();
}
export { compareReferences, getDefaultProjectRoot, getSortedMessages, localeCompare, setNestedProperty };
+4
View File
@@ -0,0 +1,4 @@
export { useFormatter, useTranslations } from './react-client/index.js';
export { default as NextIntlClientProvider } from './shared/NextIntlClientProvider.js';
export { IntlProvider, _useExtracted as useExtracted, useLocale, useMessages, useNow, useTimeZone } from 'use-intl/react';
export * from 'use-intl/core';
+9
View File
@@ -0,0 +1,9 @@
export { default as useLocale } from './react-server/useLocale.js';
export { default as useTranslations } from './react-server/useTranslations.js';
export { default as useFormatter } from './react-server/useFormatter.js';
export { default as useNow } from './react-server/useNow.js';
export { default as useTimeZone } from './react-server/useTimeZone.js';
export { default as useMessages } from './react-server/useMessages.js';
export { default as NextIntlClientProvider } from './react-server/NextIntlClientProviderServer.js';
export { default as useExtracted } from './react-server/useExtracted.js';
export * from 'use-intl/core';
+1
View File
@@ -0,0 +1 @@
export { default } from './middleware/middleware.js';
@@ -0,0 +1,91 @@
import { normalizeTrailingSlash } from '../shared/utils.js';
import { getHost, getNormalizedPathname, getLocalePrefixes, isLocaleSupportedOnDomain, applyBasePath, formatTemplatePathname } from './utils.js';
/**
* See https://developers.google.com/search/docs/specialty/international/localized-versions
*/
function getAlternateLinksHeaderValue({
internalTemplateName,
localizedPathnames,
request,
resolvedLocale,
routing
}) {
const normalizedUrl = request.nextUrl.clone();
const host = getHost(request.headers);
if (host) {
normalizedUrl.port = '';
normalizedUrl.host = host;
}
normalizedUrl.protocol = request.headers.get('x-forwarded-proto') ?? normalizedUrl.protocol;
normalizedUrl.pathname = getNormalizedPathname(normalizedUrl.pathname, routing.locales, routing.localePrefix);
function getAlternateEntry(url, locale) {
url.pathname = normalizeTrailingSlash(url.pathname);
if (request.nextUrl.basePath) {
url = new URL(url);
url.pathname = applyBasePath(url.pathname, request.nextUrl.basePath);
}
return `<${url.toString()}>; rel="alternate"; hreflang="${locale}"`;
}
function getLocalizedPathname(pathname, locale) {
if (localizedPathnames && typeof localizedPathnames === 'object') {
const sourceTemplate = localizedPathnames[resolvedLocale];
return formatTemplatePathname(pathname, sourceTemplate ?? internalTemplateName, localizedPathnames[locale] ?? internalTemplateName);
} else {
return pathname;
}
}
const links = getLocalePrefixes(routing.locales, routing.localePrefix, false).flatMap(([locale, prefix]) => {
function prefixPathname(pathname) {
if (pathname === '/') {
return prefix;
} else {
return prefix + pathname;
}
}
let url;
if (routing.domains) {
const domainConfigs = routing.domains.filter(cur => isLocaleSupportedOnDomain(locale, cur));
return domainConfigs.map(domainConfig => {
url = new URL(normalizedUrl);
url.port = '';
url.host = domainConfig.domain;
// Important: Use `normalizedUrl` here, as `url` potentially uses
// a `basePath` that automatically gets applied to the pathname
url.pathname = getLocalizedPathname(normalizedUrl.pathname, locale);
if (locale !== domainConfig.defaultLocale || routing.localePrefix.mode === 'always') {
url.pathname = prefixPathname(url.pathname);
}
return getAlternateEntry(url, locale);
});
} else {
let pathname;
if (localizedPathnames && typeof localizedPathnames === 'object') {
pathname = getLocalizedPathname(normalizedUrl.pathname, locale);
} else {
pathname = normalizedUrl.pathname;
}
if (locale !== routing.defaultLocale || routing.localePrefix.mode === 'always') {
pathname = prefixPathname(pathname);
}
url = new URL(pathname, normalizedUrl);
}
return getAlternateEntry(url, locale);
});
// Add x-default entry
const shouldAddXDefault =
// For domain-based routing there is no reasonable x-default
!routing.domains || routing.domains.length === 0;
if (shouldAddXDefault) {
const localizedPathname = getLocalizedPathname(normalizedUrl.pathname, routing.defaultLocale);
if (localizedPathname) {
const url = new URL(localizedPathname, normalizedUrl);
links.push(getAlternateEntry(url, 'x-default'));
}
}
return links.join(', ');
}
export { getAlternateLinksHeaderValue as default };
+168
View File
@@ -0,0 +1,168 @@
import { NextResponse } from 'next/server';
import { receiveRoutingConfig } from '../routing/config.js';
import { HEADER_LOCALE_NAME } from '../shared/constants.js';
import { matchesPathname, normalizeTrailingSlash, getLocalePrefix, getLocalizedTemplate } from '../shared/utils.js';
import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.js';
import resolveLocale from './resolveLocale.js';
import syncCookie from './syncCookie.js';
import { sanitizePathname, isLocaleSupportedOnDomain, getNormalizedPathname, getPathnameMatch, getInternalTemplate, formatTemplatePathname, formatPathname, getBestMatchingDomain, applyBasePath, getLocaleAsPrefix } from './utils.js';
function createMiddleware(routing) {
const resolvedRouting = receiveRoutingConfig(routing);
return function middleware(request) {
let unsafeExternalPathname;
try {
// Resolve potential foreign symbols (e.g. /ja/%E7%B4%84 → /ja/約))
unsafeExternalPathname = decodeURI(request.nextUrl.pathname);
} catch {
// In case an invalid pathname is encountered, forward
// it to Next.js which in turn responds with a 400
return NextResponse.next();
}
// Sanitize malicious URIs to prevent open redirect attacks due to
// decodeURI doesn't escape encoded backslashes ('%5C' & '%5c')
const externalPathname = sanitizePathname(unsafeExternalPathname);
const {
domain,
locale
} = resolveLocale(resolvedRouting, request.headers, request.cookies, externalPathname);
const hasMatchedDefaultLocale = domain ? domain.defaultLocale === locale : locale === resolvedRouting.defaultLocale;
const domainsConfig = resolvedRouting.domains?.filter(curDomain => isLocaleSupportedOnDomain(locale, curDomain)) || [];
const hasUnknownHost = resolvedRouting.domains != null && !domain;
function next(url) {
const urlObj = new URL(url, request.url);
if (request.nextUrl.basePath) {
urlObj.pathname = applyBasePath(urlObj.pathname, request.nextUrl.basePath);
}
const headers = new Headers(request.headers);
headers.set(HEADER_LOCALE_NAME, locale);
const isRewriteNecessary = normalizeTrailingSlash(request.nextUrl.pathname) !== normalizeTrailingSlash(urlObj.pathname);
if (isRewriteNecessary) {
return NextResponse.rewrite(urlObj, {
request: {
headers
}
});
} else {
return NextResponse.next({
request: {
headers
}
});
}
}
function redirect(url, redirectDomain) {
const urlObj = new URL(url, request.url);
urlObj.pathname = normalizeTrailingSlash(urlObj.pathname);
if (domainsConfig.length > 0 && !redirectDomain && domain) {
const bestMatchingDomain = getBestMatchingDomain(domain, locale, domainsConfig);
if (bestMatchingDomain) {
redirectDomain = bestMatchingDomain.domain;
if (bestMatchingDomain.defaultLocale === locale && resolvedRouting.localePrefix.mode === 'as-needed') {
urlObj.pathname = getNormalizedPathname(urlObj.pathname, resolvedRouting.locales, resolvedRouting.localePrefix);
}
}
}
if (redirectDomain) {
urlObj.host = redirectDomain;
if (request.headers.get('x-forwarded-host')) {
urlObj.protocol = request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol;
const redirectDomainPort = redirectDomain.split(':')[1];
urlObj.port = redirectDomainPort ?? request.headers.get('x-forwarded-port') ?? '';
}
}
if (request.nextUrl.basePath) {
urlObj.pathname = applyBasePath(urlObj.pathname, request.nextUrl.basePath);
}
hasRedirected = true;
return NextResponse.redirect(urlObj.toString());
}
const unprefixedExternalPathname = getNormalizedPathname(externalPathname, resolvedRouting.locales, resolvedRouting.localePrefix);
const pathnameMatch = getPathnameMatch(externalPathname, resolvedRouting.locales, resolvedRouting.localePrefix, domain);
const hasLocalePrefix = pathnameMatch != null;
const isUnprefixedRouting = resolvedRouting.localePrefix.mode === 'never' || hasMatchedDefaultLocale && resolvedRouting.localePrefix.mode === 'as-needed';
let response;
let internalTemplateName;
let hasRedirected;
let unprefixedInternalPathname = unprefixedExternalPathname;
const pathnames = resolvedRouting.pathnames;
if (pathnames) {
let resolvedTemplateLocale;
[resolvedTemplateLocale, internalTemplateName] = getInternalTemplate(pathnames, unprefixedExternalPathname, locale);
if (internalTemplateName) {
const pathnameConfig = pathnames[internalTemplateName];
const localeTemplate = getLocalizedTemplate(pathnameConfig, locale, internalTemplateName);
if (matchesPathname(localeTemplate, unprefixedExternalPathname)) {
unprefixedInternalPathname = formatTemplatePathname(unprefixedExternalPathname, localeTemplate, internalTemplateName);
} else {
let sourceTemplate;
if (resolvedTemplateLocale) {
// A localized pathname from another locale has matched
sourceTemplate = getLocalizedTemplate(pathnameConfig, resolvedTemplateLocale, internalTemplateName);
} else {
// An internal pathname has matched that
// doesn't have a localized pathname
sourceTemplate = internalTemplateName;
}
const localePrefix = isUnprefixedRouting ? undefined : getLocalePrefix(locale, resolvedRouting.localePrefix);
const template = formatTemplatePathname(unprefixedExternalPathname, sourceTemplate, localeTemplate);
response = redirect(formatPathname(template, localePrefix, request.nextUrl.search));
}
}
}
if (!response) {
if (unprefixedInternalPathname === '/' && !hasLocalePrefix) {
if (isUnprefixedRouting) {
response = next(formatPathname(unprefixedInternalPathname, getLocaleAsPrefix(locale), request.nextUrl.search));
} else {
response = redirect(formatPathname(unprefixedExternalPathname, getLocalePrefix(locale, resolvedRouting.localePrefix), request.nextUrl.search));
}
} else {
const internalHref = formatPathname(unprefixedInternalPathname, getLocaleAsPrefix(locale), request.nextUrl.search);
if (hasLocalePrefix) {
const externalHref = formatPathname(unprefixedExternalPathname, pathnameMatch.prefix, request.nextUrl.search);
if (resolvedRouting.localePrefix.mode === 'never') {
response = redirect(formatPathname(unprefixedExternalPathname, undefined, request.nextUrl.search));
} else if (pathnameMatch.exact) {
if (hasMatchedDefaultLocale && isUnprefixedRouting) {
response = redirect(formatPathname(unprefixedExternalPathname, undefined, request.nextUrl.search));
} else {
if (resolvedRouting.domains) {
const pathDomain = getBestMatchingDomain(domain, pathnameMatch.locale, domainsConfig);
if (domain?.domain !== pathDomain?.domain && !hasUnknownHost) {
response = redirect(externalHref, pathDomain?.domain);
} else {
response = next(internalHref);
}
} else {
response = next(internalHref);
}
}
} else {
response = redirect(externalHref);
}
} else {
if (isUnprefixedRouting) {
response = next(internalHref);
} else {
response = redirect(formatPathname(unprefixedExternalPathname, getLocalePrefix(locale, resolvedRouting.localePrefix), request.nextUrl.search));
}
}
}
}
syncCookie(request, response, locale, resolvedRouting, domain);
if (!hasRedirected && resolvedRouting.localePrefix.mode !== 'never' && resolvedRouting.alternateLinks && resolvedRouting.locales.length > 1) {
response.headers.set('Link', getAlternateLinksHeaderValue({
routing: resolvedRouting,
internalTemplateName,
localizedPathnames: internalTemplateName != null && pathnames ? pathnames[internalTemplateName] : undefined,
request,
resolvedLocale: locale
}));
}
return response;
};
}
export { createMiddleware as default };
+126
View File
@@ -0,0 +1,126 @@
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
import { getPathnameMatch, isLocaleSupportedOnDomain, getHost } from './utils.js';
function findDomainFromHost(requestHeaders, domains) {
const host = getHost(requestHeaders);
if (host) {
return domains.find(cur => cur.domain === host);
}
return undefined;
}
function orderLocales(locales) {
// Workaround for https://github.com/formatjs/formatjs/issues/4469
return locales.slice().sort((a, b) => b.length - a.length);
}
function getAcceptLanguageLocale(requestHeaders, locales, defaultLocale) {
let locale;
const languages = new Negotiator({
headers: {
'accept-language': requestHeaders.get('accept-language') || undefined
}
}).languages();
try {
const orderedLocales = orderLocales(locales);
locale = match(languages, orderedLocales, defaultLocale);
} catch {
// Invalid language
}
return locale;
}
function getLocaleFromCookie(routing, requestCookies) {
if (routing.localeCookie && requestCookies.has(routing.localeCookie.name)) {
const value = requestCookies.get(routing.localeCookie.name)?.value;
if (value && routing.locales.includes(value)) {
return value;
}
}
}
function resolveLocaleFromPrefix(routing, requestHeaders, requestCookies, pathname) {
let locale;
// Prio 1: Use route prefix
if (pathname) {
locale = getPathnameMatch(pathname, routing.locales, routing.localePrefix)?.locale;
}
// Prio 2: Use existing cookie
if (!locale && routing.localeDetection) {
locale = getLocaleFromCookie(routing, requestCookies);
}
// Prio 3: Use the `accept-language` header
if (!locale && routing.localeDetection) {
locale = getAcceptLanguageLocale(requestHeaders, routing.locales, routing.defaultLocale);
}
// Prio 4: Use default locale
if (!locale) {
locale = routing.defaultLocale;
}
return locale;
}
function resolveLocaleFromDomain(routing, requestHeaders, requestCookies, pathname) {
const domains = routing.domains;
const domain = findDomainFromHost(requestHeaders, domains);
if (!domain) {
return {
locale: resolveLocaleFromPrefix(routing, requestHeaders, requestCookies, pathname)
};
}
let locale;
// Prio 1: Use route prefix
if (pathname) {
const prefixLocale = getPathnameMatch(pathname, routing.locales, routing.localePrefix, domain)?.locale;
if (prefixLocale) {
if (isLocaleSupportedOnDomain(prefixLocale, domain)) {
locale = prefixLocale;
} else {
// Causes a redirect to a domain that supports the locale
return {
locale: prefixLocale,
domain
};
}
}
}
// Prio 2: Use existing cookie
if (!locale && routing.localeDetection) {
const cookieLocale = getLocaleFromCookie(routing, requestCookies);
if (cookieLocale) {
if (isLocaleSupportedOnDomain(cookieLocale, domain)) {
locale = cookieLocale;
}
}
}
// Prio 3: Use the `accept-language` header
if (!locale && routing.localeDetection) {
const headerLocale = getAcceptLanguageLocale(requestHeaders, domain.locales, domain.defaultLocale);
if (headerLocale) {
locale = headerLocale;
}
}
// Prio 4: Use default locale
if (!locale) {
locale = domain.defaultLocale;
}
return {
locale,
domain
};
}
function resolveLocale(routing, requestHeaders, requestCookies, pathname) {
if (routing.domains) {
return resolveLocaleFromDomain(routing, requestHeaders, requestCookies, pathname);
} else {
return {
locale: resolveLocaleFromPrefix(routing, requestHeaders, requestCookies, pathname)
};
}
}
export { resolveLocale as default, getAcceptLanguageLocale };
+27
View File
@@ -0,0 +1,27 @@
import { getAcceptLanguageLocale } from './resolveLocale.js';
function syncCookie(request, response, locale, routing, domain) {
if (!routing.localeCookie) return;
const {
name,
...rest
} = routing.localeCookie;
const hasLocaleCookie = request.cookies.has(name);
const hasOutdatedCookie = hasLocaleCookie && request.cookies.get(name)?.value !== locale;
if (hasOutdatedCookie) {
response.cookies.set(name, locale, {
path: request.nextUrl.basePath || undefined,
...rest
});
} else if (!hasLocaleCookie) {
const acceptLanguageLocale = getAcceptLanguageLocale(request.headers, domain?.locales || routing.locales, routing.defaultLocale);
if (acceptLanguageLocale !== locale) {
response.cookies.set(name, locale, {
path: request.nextUrl.basePath || undefined,
...rest
});
}
}
}
export { syncCookie as default };
+194
View File
@@ -0,0 +1,194 @@
import { normalizeTrailingSlash, getSortedPathnames, matchesPathname, prefixPathname, getLocalePrefix, templateToRegex, getLocalizedTemplate } from '../shared/utils.js';
function getInternalTemplate(pathnames, pathname, locale) {
const sortedPathnames = getSortedPathnames(Object.keys(pathnames));
// Try to find a localized pathname that matches
for (const internalPathname of sortedPathnames) {
const localizedPathnamesOrPathname = pathnames[internalPathname];
if (typeof localizedPathnamesOrPathname === 'string') {
const localizedPathname = localizedPathnamesOrPathname;
if (matchesPathname(localizedPathname, pathname)) {
return [undefined, internalPathname];
}
} else {
// Prefer the entry with the current locale in case multiple
// localized pathnames match the current pathname
const sortedEntries = Object.entries(localizedPathnamesOrPathname);
const curLocaleIndex = sortedEntries.findIndex(([entryLocale]) => entryLocale === locale);
if (curLocaleIndex > 0) {
sortedEntries.unshift(sortedEntries.splice(curLocaleIndex, 1)[0]);
}
for (const [entryLocale] of sortedEntries) {
const localizedTemplate = getLocalizedTemplate(pathnames[internalPathname], entryLocale, internalPathname);
if (matchesPathname(localizedTemplate, pathname)) {
return [entryLocale, internalPathname];
}
}
}
}
// Try to find an internal pathname that matches (this can be the case
// if all localized pathnames are different from the internal pathnames)
for (const internalPathname of Object.keys(pathnames)) {
if (matchesPathname(internalPathname, pathname)) {
return [undefined, internalPathname];
}
}
// No match
return [undefined, undefined];
}
function formatTemplatePathname(sourcePathname, sourceTemplate, targetTemplate, prefix) {
const params = getRouteParams(sourceTemplate, sourcePathname);
let targetPathname = '';
targetPathname += formatPathnameTemplate(targetTemplate, params);
// A pathname with an optional catchall like `/categories/[[...slug]]`
// should be normalized to `/categories` if the catchall is not present
// and no trailing slash is configured
targetPathname = normalizeTrailingSlash(targetPathname);
return targetPathname;
}
/**
* Removes potential prefixes from the pathname.
*/
function getNormalizedPathname(pathname, locales, localePrefix) {
// Add trailing slash for consistent handling
// both for the root as well as nested paths
if (!pathname.endsWith('/')) {
pathname += '/';
}
const localePrefixes = getLocalePrefixes(locales, localePrefix);
const regex = new RegExp(`^(${localePrefixes.map(([, prefix]) => prefix.replaceAll('/', '\\/')).join('|')})/(.*)`, 'i');
const match = pathname.match(regex);
let result = match ? '/' + match[2] : pathname;
if (result !== '/') {
result = normalizeTrailingSlash(result);
}
return result;
}
function getLocalePrefixes(locales, localePrefix, sort = true) {
const prefixes = locales.map(locale => [locale, getLocalePrefix(locale, localePrefix)]);
if (sort) {
// More specific ones first
prefixes.sort((a, b) => b[1].length - a[1].length);
}
return prefixes;
}
function getPathnameMatch(pathname, locales, localePrefix, domain) {
const localePrefixes = getLocalePrefixes(locales, localePrefix);
// Sort to prioritize domain locales
if (domain) {
localePrefixes.sort(([localeA], [localeB]) => {
if (localeA === domain.defaultLocale) return -1;
if (localeB === domain.defaultLocale) return 1;
const isLocaleAInDomain = domain.locales.includes(localeA);
const isLocaleBInDomain = domain.locales.includes(localeB);
if (isLocaleAInDomain && !isLocaleBInDomain) return -1;
if (!isLocaleAInDomain && isLocaleBInDomain) return 1;
return 0;
});
}
for (const [locale, prefix] of localePrefixes) {
let exact, matches;
if (pathname === prefix || pathname.startsWith(prefix + '/')) {
exact = matches = true;
} else {
const normalizedPathname = pathname.toLowerCase();
const normalizedPrefix = prefix.toLowerCase();
if (normalizedPathname === normalizedPrefix || normalizedPathname.startsWith(normalizedPrefix + '/')) {
exact = false;
matches = true;
}
}
if (matches) {
return {
locale,
prefix,
matchedPrefix: pathname.slice(0, prefix.length),
exact
};
}
}
}
function getRouteParams(template, pathname) {
const normalizedPathname = normalizeTrailingSlash(pathname);
const normalizedTemplate = normalizeTrailingSlash(template);
const regex = templateToRegex(normalizedTemplate);
const match = regex.exec(normalizedPathname);
if (!match) return undefined;
const params = {};
const keys = normalizedTemplate.match(/\[([^\]]+)\]/g) ?? [];
for (let i = 1; i < match.length; i++) {
const rawKey = keys[i - 1];
if (!rawKey) continue;
const key = rawKey.replace(/[[\]]/g, '');
const value = match[i] ?? '';
params[key] = value;
}
return params;
}
function formatPathnameTemplate(template, params) {
if (!params) return template;
// Simplify syntax for optional catchall ('[[...slug]]') so
// we can replace the value with simple interpolation
template = template.replace(/\[\[/g, '[').replace(/\]\]/g, ']');
let result = template;
Object.entries(params).forEach(([key, value]) => {
result = result.replace(`[${key}]`, value);
});
return result;
}
function formatPathname(pathname, prefix, search) {
let result = pathname;
if (prefix) {
result = prefixPathname(prefix, result);
}
if (search) {
result += search;
}
return result;
}
function getHost(requestHeaders) {
return requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host') ?? undefined;
}
function isLocaleSupportedOnDomain(locale, domain) {
return domain.defaultLocale === locale || domain.locales.includes(locale);
}
function getBestMatchingDomain(curHostDomain, locale, domainsConfig) {
let domainConfig;
// Prio 1: Stay on current domain
if (curHostDomain && isLocaleSupportedOnDomain(locale, curHostDomain)) {
domainConfig = curHostDomain;
}
// Prio 2: Use alternative domain with matching default locale
if (!domainConfig) {
domainConfig = domainsConfig.find(cur => cur.defaultLocale === locale);
}
// Prio 3: Use alternative domain that supports the locale
if (!domainConfig) {
domainConfig = domainsConfig.find(cur => cur.locales.includes(locale));
}
return domainConfig;
}
function applyBasePath(pathname, basePath) {
return normalizeTrailingSlash(basePath + pathname);
}
function getLocaleAsPrefix(locale) {
return `/${locale}`;
}
function sanitizePathname(pathname) {
// Sanitize malicious URIs, e.g.:
// '/en/\\example.org → /en/%5C%5Cexample.org'
// '/en////example.org → /en/example.org'
return pathname.replace(/\\/g, '%5C').replace(/\/+/g, '/');
}
export { applyBasePath, formatPathname, formatPathnameTemplate, formatTemplatePathname, getBestMatchingDomain, getHost, getInternalTemplate, getLocaleAsPrefix, getLocalePrefixes, getNormalizedPathname, getPathnameMatch, getRouteParams, isLocaleSupportedOnDomain, sanitizePathname };
@@ -0,0 +1 @@
export { default as createNavigation } from './navigation/react-client/createNavigation.js';
@@ -0,0 +1 @@
export { default as createNavigation } from './navigation/react-server/createNavigation.js';
@@ -0,0 +1,80 @@
import { useRouter, usePathname } from 'next/navigation';
import { useMemo } from 'react';
import { useLocale } from 'use-intl';
import createSharedNavigationFns from '../shared/createSharedNavigationFns.js';
import syncLocaleCookie from '../shared/syncLocaleCookie.js';
import { getRoute } from '../shared/utils.js';
import useBasePathname from './useBasePathname.js';
function createNavigation(routing) {
const {
Link,
config,
getPathname,
...redirects
} = createSharedNavigationFns(useLocale, routing);
/** @see https://next-intl.dev/docs/routing/navigation#usepathname */
function usePathname$1() {
const pathname = useBasePathname(config);
const locale = useLocale();
// @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned.
return useMemo(() => pathname &&
// @ts-expect-error -- This is fine
config.pathnames ? getRoute(locale, pathname,
// @ts-expect-error -- This is fine
config.pathnames) : pathname, [locale, pathname]);
}
function useRouter$1() {
const router = useRouter();
const curLocale = useLocale();
const nextPathname = usePathname();
return useMemo(() => {
function createHandler(fn) {
return function handler(href, options) {
const {
locale: nextLocale,
...rest
} = options || {};
const pathname = getPathname({
href,
locale: nextLocale || curLocale,
// Always include a prefix when changing locales. Theoretically,
// this is only necessary for the case described in #2020. However,
// the full detection is rather expensive, and this behavior is
// consistent with the `Link` component. The downside is an
// additional redirect for users in other situations. Locale
// changes should be rare though, so this might be fine.
forcePrefix: nextLocale != null || undefined
});
const args = [pathname];
if (Object.keys(rest).length > 0) {
// @ts-expect-error -- This is fine
args.push(rest);
}
syncLocaleCookie(config.localeCookie, nextPathname, curLocale, nextLocale);
fn(...args);
};
}
return {
...router,
/** @see https://next-intl.dev/docs/routing/navigation#userouter */
push: createHandler(router.push),
/** @see https://next-intl.dev/docs/routing/navigation#userouter */
replace: createHandler(router.replace),
/** @see https://next-intl.dev/docs/routing/navigation#userouter */
prefetch: createHandler(router.prefetch)
};
}, [curLocale, nextPathname, router]);
}
return {
...redirects,
Link,
usePathname: usePathname$1,
useRouter: useRouter$1,
getPathname
};
}
export { createNavigation as default };
@@ -0,0 +1,36 @@
import { usePathname } from 'next/navigation';
import { useMemo } from 'react';
import { useLocale } from 'use-intl';
import { hasPathnamePrefixed, unprefixPathname, getLocalePrefix, getLocaleAsPrefix } from '../../shared/utils.js';
function useBasePathname(config) {
// The types aren't entirely correct here. Outside of Next.js
// `useParams` can be called, but the return type is `null`.
// Notes on `useNextPathname`:
// - Types aren't entirely correct. Outside of Next.js the
// hook will return `null` (e.g. unit tests)
// - A base path is stripped from the result
// - Rewrites *are* taken into account (i.e. the pathname
// that the user sees in the browser is returned)
const pathname = usePathname();
const locale = useLocale();
return useMemo(() => {
if (!pathname) return pathname;
let unlocalizedPathname = pathname;
const prefix = getLocalePrefix(locale, config.localePrefix);
const isPathnamePrefixed = hasPathnamePrefixed(prefix, pathname);
if (isPathnamePrefixed) {
unlocalizedPathname = unprefixPathname(pathname, prefix);
} else if (config.localePrefix.mode !== 'never' && config.localePrefix.prefixes) {
// Workaround for https://github.com/vercel/next.js/issues/73085
const localeAsPrefix = getLocaleAsPrefix(locale);
if (hasPathnamePrefixed(localeAsPrefix, pathname)) {
unlocalizedPathname = unprefixPathname(pathname, localeAsPrefix);
}
}
return unlocalizedPathname;
}, [config.localePrefix, locale, pathname]);
}
export { useBasePathname as default };
@@ -0,0 +1,22 @@
import createSharedNavigationFns from '../shared/createSharedNavigationFns.js';
import getServerLocale from './getServerLocale.js';
function createNavigation(routing) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {
config,
...fns
} = createSharedNavigationFns(getServerLocale, routing);
function notSupported(hookName) {
return () => {
throw new Error(`\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the calling component to a Client Component.`);
};
}
return {
...fns,
usePathname: notSupported('usePathname'),
useRouter: notSupported('useRouter')
};
}
export { createNavigation as default };
@@ -0,0 +1,12 @@
import getConfig from '../../server/react-server/getConfig.js';
/**
* This is only moved to a separate module for easier mocking in
* `../createNavigatoin.test.tsx` in order to avoid suspending.
*/
async function getServerLocale() {
const config = await getConfig();
return config.locale;
}
export { getServerLocale as default };
@@ -0,0 +1,51 @@
"use client";
import NextLink from 'next/link';
import { usePathname } from 'next/navigation';
import { forwardRef } from 'react';
import { useLocale } from 'use-intl';
import syncLocaleCookie from './syncLocaleCookie.js';
import { jsx } from 'react/jsx-runtime';
function BaseLink({
href,
locale,
localeCookie,
onClick,
prefetch,
...rest
}, ref) {
const curLocale = useLocale();
const isChangingLocale = locale != null && locale !== curLocale;
// The types aren't entirely correct here. Outside of Next.js
// `useParams` can be called, but the return type is `null`.
const pathname = usePathname();
function onLinkClick(event) {
// Even though we force a prefix when changing locales,
// this could be a cache hit of the client-side router,
// therefore we sync the cookie to ensure it's up to date.
syncLocaleCookie(localeCookie, pathname, curLocale, locale);
if (onClick) onClick(event);
}
if (isChangingLocale) {
if (prefetch && "development" !== 'production') {
console.error('The `prefetch` prop is currently not supported when using the `locale` prop on `Link` to switch the locale.`');
}
prefetch = false;
}
// Somehow the types for `next/link` don't work as expected
// when `moduleResolution: "nodenext"` is used.
const Link = NextLink;
return /*#__PURE__*/jsx(Link, {
ref: ref,
href: href,
hrefLang: isChangingLocale ? locale : undefined,
onClick: onLinkClick,
prefetch: prefetch,
...rest
});
}
var BaseLink$1 = /*#__PURE__*/forwardRef(BaseLink);
export { BaseLink$1 as default };
@@ -0,0 +1,105 @@
import { redirect, permanentRedirect } from 'next/navigation';
import { forwardRef } from 'react';
import { receiveRoutingConfig } from '../../routing/config.js';
import use from '../../shared/use.js';
import { isLocalizableHref, isPromise } from '../../shared/utils.js';
import BaseLink from './BaseLink.js';
import { validateReceivedConfig, serializeSearchParams, compileLocalizedPathname, applyPathnamePrefix, normalizeNameOrNameWithParams } from './utils.js';
import { jsx } from 'react/jsx-runtime';
/**
* Shared implementations for `react-server` and `react-client`
*/
function createSharedNavigationFns(getLocale, routing) {
const config = receiveRoutingConfig(routing || {});
{
validateReceivedConfig(config);
}
const pathnames = config.pathnames;
function Link({
href,
locale,
...rest
}, ref) {
let pathname, params;
if (typeof href === 'object') {
pathname = href.pathname;
// @ts-expect-error -- This is ok
params = href.params;
} else {
pathname = href;
}
// @ts-expect-error -- This is ok
const isLocalizable = isLocalizableHref(href);
const localePromiseOrValue = getLocale();
const curLocale = isPromise(localePromiseOrValue) ? use(localePromiseOrValue) : localePromiseOrValue;
const finalPathname = isLocalizable ? getPathname({
locale: locale || curLocale,
// @ts-expect-error -- This is ok
href: pathnames == null ? pathname : {
pathname,
params
},
// Always include a prefix when changing locales
forcePrefix: locale != null || undefined
}) : pathname;
return /*#__PURE__*/jsx(BaseLink, {
ref: ref
// @ts-expect-error -- This is ok
,
href: typeof href === 'object' ? {
...href,
pathname: finalPathname
} : finalPathname,
locale: locale,
localeCookie: config.localeCookie,
...rest
});
}
const LinkWithRef = /*#__PURE__*/forwardRef(Link);
function getPathname(args) {
const {
forcePrefix,
href,
locale
} = args;
let pathname;
if (pathnames == null) {
if (typeof href === 'object') {
pathname = href.pathname;
if (href.query) {
pathname += serializeSearchParams(href.query);
}
} else {
pathname = href;
}
} else {
pathname = compileLocalizedPathname({
locale,
// @ts-expect-error -- This is ok
...normalizeNameOrNameWithParams(href),
// @ts-expect-error -- This is ok
pathnames: config.pathnames
});
}
return applyPathnamePrefix(pathname, locale, config, forcePrefix);
}
function getRedirectFn(fn) {
/** @see https://next-intl.dev/docs/routing/navigation#redirect */
return function redirectFn(args, ...rest) {
return fn(getPathname(args), ...rest);
};
}
const redirect$1 = getRedirectFn(redirect);
const permanentRedirect$1 = getRedirectFn(permanentRedirect);
return {
config,
Link: LinkWithRef,
redirect: redirect$1,
permanentRedirect: permanentRedirect$1,
getPathname
};
}
export { createSharedNavigationFns as default };
@@ -0,0 +1,47 @@
import { getBasePath } from './utils.js';
/**
* We have to keep the cookie value in sync as Next.js might
* skip a request to the server due to its router cache.
* See https://github.com/amannn/next-intl/issues/786.
*/
function syncLocaleCookie(localeCookie, pathname, locale, nextLocale) {
const isSwitchingLocale = nextLocale !== locale && nextLocale != null;
if (!localeCookie || !isSwitchingLocale ||
// Theoretical case, we always have a pathname in a real app,
// only not when running e.g. in a simulated test environment
!pathname) {
return;
}
const basePath = getBasePath(pathname);
const hasBasePath = basePath !== '';
const defaultPath = hasBasePath ? basePath : '/';
const {
name,
...rest
} = localeCookie;
if (!rest.path) {
rest.path = defaultPath;
}
let localeCookieString = `${name}=${nextLocale};`;
for (const [key, value] of Object.entries(rest)) {
// Map object properties to cookie properties.
// Interestingly, `maxAge` corresponds to `max-age`,
// while `sameSite` corresponds to `SameSite`.
// Also, keys are case-insensitive.
const targetKey = key === 'maxAge' ? 'max-age' : key;
localeCookieString += `${targetKey}`;
if (typeof value !== 'boolean') {
localeCookieString += '=' + value;
}
// A trailing ";" is allowed by browsers
localeCookieString += ';';
}
// Note that writing to `document.cookie` doesn't overwrite all
// cookies, but only the ones referenced via the name here.
document.cookie = localeCookieString;
}
export { syncLocaleCookie as default };
+165
View File
@@ -0,0 +1,165 @@
import { getSortedPathnames, matchesPathname, isLocalizableHref, prefixPathname, getLocalizedTemplate, normalizeTrailingSlash, getLocalePrefix } from '../../shared/utils.js';
// Minor false positive: A route that has both optional and
// required params will allow optional params.
// For `Link`
// For `getPathname` (hence also its consumers: `redirect`, `useRouter`, …)
function normalizeNameOrNameWithParams(href) {
return typeof href === 'string' ? {
pathname: href
} : href;
}
function serializeSearchParams(searchParams) {
function serializeValue(value) {
return String(value);
}
const urlSearchParams = new URLSearchParams();
for (const [key, value] of Object.entries(searchParams)) {
if (Array.isArray(value)) {
value.forEach(cur => {
urlSearchParams.append(key, serializeValue(cur));
});
} else {
urlSearchParams.set(key, serializeValue(value));
}
}
return '?' + urlSearchParams.toString();
}
function compileLocalizedPathname({
pathname,
locale,
params,
pathnames,
query
}) {
function compilePath(value) {
const pathnameConfig = pathnames[value];
let compiled;
if (pathnameConfig) {
const template = getLocalizedTemplate(pathnameConfig, locale, value);
compiled = template;
if (params) {
Object.entries(params).forEach(([key, paramValue]) => {
let regexp, replacer;
if (Array.isArray(paramValue)) {
regexp = `(\\[)?\\[...${key}\\](\\])?`;
replacer = paramValue.map(v => String(v)).join('/');
} else {
regexp = `\\[${key}\\]`;
replacer = String(paramValue);
}
compiled = compiled.replace(new RegExp(regexp, 'g'), replacer);
});
}
// Clean up optional catch-all segments that were not replaced
compiled = compiled.replace(/\[\[\.\.\..+\]\]/g, '');
if (compiled.includes('[')) {
// Next.js throws anyway, therefore better provide a more helpful error message
throw new Error(`Insufficient params provided for localized pathname.\nTemplate: ${template}\nParams: ${JSON.stringify(params)}`);
}
compiled = encodePathname(compiled);
} else {
// Unknown pathnames
compiled = value;
}
compiled = normalizeTrailingSlash(compiled);
if (query) {
// This also encodes non-ASCII characters by
// using `new URLSearchParams()` internally
compiled += serializeSearchParams(query);
}
return compiled;
}
if (typeof pathname === 'string') {
return compilePath(pathname);
} else {
const {
pathname: internalPathname,
...rest
} = pathname;
const compiled = compilePath(internalPathname);
const result = {
...rest,
pathname: compiled
};
return result;
}
}
function encodePathname(pathname) {
// Generally, to comply with RFC 3986 and Google's best practices for URL structures
// (https://developers.google.com/search/docs/crawling-indexing/url-structure),
// we should always encode non-ASCII characters.
//
// There are two places where next-intl interacts with potentially non-ASCII URLs:
// 1. Middleware: When mapping a localized pathname to a non-localized pathname internally
// 2. Navigation APIs: When generating a URLs to be used for <Link /> & friends
//
// Next.js normalizes incoming pathnames to always be encoded, therefore we can safely
// decode them there (see middleware.tsx). On the other hand, Next.js doesn't consistently
// encode non-ASCII characters that are passed to navigation APIs:
// 1. <Link /> doesn't encode non-ASCII characters
// 2. useRouter() uses `new URL()` internally, which will encode—but only if necessary
// 3. redirect() uses useRouter() on the client, but on the server side only
// assigns the location header without encoding.
//
// In addition to this, for getPathname() we need to encode non-ASCII characters.
//
// Therefore, the bottom line is that next-intl should take care of encoding non-ASCII
// characters in all cases, but can rely on `new URL()` to not double-encode characters.
return new URL(pathname, 'http://l').pathname;
}
function getRoute(locale, pathname, pathnames) {
const sortedPathnames = getSortedPathnames(Object.keys(pathnames));
const decoded = decodeURI(pathname);
for (const internalPathname of sortedPathnames) {
const localizedPathnamesOrPathname = pathnames[internalPathname];
if (typeof localizedPathnamesOrPathname === 'string') {
const localizedPathname = localizedPathnamesOrPathname;
if (matchesPathname(localizedPathname, decoded)) {
return internalPathname;
}
} else {
if (matchesPathname(getLocalizedTemplate(localizedPathnamesOrPathname, locale, internalPathname), decoded)) {
return internalPathname;
}
}
}
return pathname;
}
function getBasePath(pathname, windowPathname = window.location.pathname) {
if (pathname === '/') {
return windowPathname;
} else {
return windowPathname.replace(pathname, '');
}
}
function applyPathnamePrefix(pathname, locale, routing, force) {
const {
mode
} = routing.localePrefix;
let shouldPrefix;
if (force !== undefined) {
shouldPrefix = force;
} else if (isLocalizableHref(pathname)) {
if (mode === 'always') {
shouldPrefix = true;
} else if (mode === 'as-needed') {
shouldPrefix = routing.domains ?
// Since locales are unique per domain, any locale that is a
// default locale of a domain doesn't require a prefix
!routing.domains.some(cur => cur.defaultLocale === locale) : locale !== routing.defaultLocale;
}
}
return shouldPrefix ? prefixPathname(getLocalePrefix(locale, routing.localePrefix), pathname) : pathname;
}
function validateReceivedConfig(config) {
if (config.localePrefix?.mode === 'as-needed' && !('defaultLocale' in config)) {
throw new Error("`localePrefix: 'as-needed' requires a `defaultLocale`.");
}
}
export { applyPathnamePrefix, compileLocalizedPathname, getBasePath, getRoute, normalizeNameOrNameWithParams, serializeSearchParams, validateReceivedConfig };
+1
View File
@@ -0,0 +1 @@
export { default } from './plugin/createNextIntlPlugin.js';
@@ -0,0 +1,26 @@
import initExtractionCompiler from './extractor/initExtractionCompiler.js';
import getNextConfig from './getNextConfig.js';
import { warn } from './utils.js';
import createMessagesDeclaration from './declaration/createMessagesDeclaration.js';
function initPlugin(pluginConfig, nextConfig) {
if (nextConfig?.i18n != null) {
warn("An `i18n` property was found in your Next.js config. This likely causes conflicts and should therefore be removed if you use the App Router.\n\nIf you're in progress of migrating from the Pages Router, you can refer to this example: https://next-intl.dev/examples#app-router-migration\n");
}
const messagesPathOrPaths = pluginConfig.experimental?.createMessagesDeclaration;
if (messagesPathOrPaths) {
createMessagesDeclaration(typeof messagesPathOrPaths === 'string' ? [messagesPathOrPaths] : messagesPathOrPaths);
}
initExtractionCompiler(pluginConfig);
return getNextConfig(pluginConfig, nextConfig);
}
function createNextIntlPlugin(i18nPathOrConfig = {}) {
const config = typeof i18nPathOrConfig === 'string' ? {
requestConfig: i18nPathOrConfig
} : i18nPathOrConfig;
return function withNextIntl(nextConfig) {
return initPlugin(config, nextConfig);
};
}
export { createNextIntlPlugin as default };
@@ -0,0 +1,70 @@
import fs from 'fs';
import path from 'path';
import { once, throwError } from '../utils.js';
import watchFile from '../watchFile.js';
const runOnce = 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
// - lint
//
// What remains are:
// - dev
// - build
// - typegen
].some(arg => process.argv.includes(arg));
if (shouldBailOut) {
return;
}
runOnce(() => {
for (const messagesPath of messagesPaths) {
const fullPath = path.resolve(messagesPath);
if (!fs.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.promises.readFile(messagesPath, 'utf-8').then(content => fs.promises.writeFile(declarationPath, createDeclaration(content)));
}
const content = fs.readFileSync(messagesPath, 'utf-8');
fs.writeFileSync(declarationPath, createDeclaration(content));
}
export { createMessagesDeclaration as default };
@@ -0,0 +1,61 @@
import ExtractionCompiler from '../../extractor/ExtractionCompiler.js';
import { once } from '../utils.js';
// Single compiler instance, initialized once per process
let compiler;
const runOnce = once('_NEXT_INTL_EXTRACT');
function initExtractionCompiler(pluginConfig) {
const experimental = pluginConfig.experimental;
if (!experimental?.extract) {
return;
}
// Avoid rollup's `replace` plugin to compile this away
const isDevelopment = process.env['NODE_ENV'.trim()] === 'development';
// Avoid running for:
// - info
// - start
// - typegen
//
// Doesn't consult Next.js config anyway:
// - telemetry
// - lint
//
// What remains are:
// - dev (NODE_ENV=development)
// - build (NODE_ENV=production)
const shouldRun = isDevelopment || process.argv.includes('build');
if (!shouldRun) return;
runOnce(() => {
const extractorConfig = {
srcPath: experimental.srcPath,
sourceLocale: experimental.extract.sourceLocale,
messages: experimental.messages
};
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);
});
}
export { initExtractionCompiler as default };
+212
View File
@@ -0,0 +1,212 @@
import fs from 'fs';
import path from 'path';
import { getFormatExtension } from '../extractor/format/index.js';
import SourceFileFilter from '../extractor/source/SourceFileFilter.js';
import { isNextJs16OrHigher, hasStableTurboConfig } from './nextFlags.js';
import { throwError } from './utils.js';
function withExtensions(localPath) {
return [`${localPath}.ts`, `${localPath}.tsx`, `${localPath}.js`, `${localPath}.jsx`];
}
function resolveI18nPath(providedPath, cwd) {
function resolvePath(pathname) {
const parts = [];
if (cwd) parts.push(cwd);
parts.push(pathname);
return path.resolve(...parts);
}
function pathExists(pathname) {
return fs.existsSync(resolvePath(pathname));
}
if (providedPath) {
if (!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;
}
}
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(
Alternatively, you can specify a custom location in your Next.js config:
const withNextIntl = createNextIntlPlugin(
'./path/to/i18n/request.tsx'
);`);
}
}
function getNextConfig(pluginConfig, nextConfig) {
const useTurbo = process.env.TURBOPACK != null;
const nextIntlConfig = {};
function getExtractMessagesLoaderConfig() {
const experimental = pluginConfig.experimental;
if (!experimental.srcPath || !pluginConfig.experimental?.messages) {
throwError('`srcPath` and `messages` are required when using `extractor`.');
}
return {
loader: 'next-intl/extractor/extractionLoader',
options: {
srcPath: experimental.srcPath,
sourceLocale: experimental.extract.sourceLocale,
messages: pluginConfig.experimental.messages
}
};
}
function getCatalogLoaderConfig() {
return {
loader: 'next-intl/extractor/catalogLoader',
options: {
messages: pluginConfig.experimental.messages
}
};
}
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 (useTurbo) {
if (pluginConfig.requestConfig && path.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 loaders
let rules;
// Add loader for extractor
if (pluginConfig.experimental?.extract) {
if (!isNextJs16OrHigher()) {
throwError('Message extraction requires Next.js 16 or higher.');
}
rules ??= getTurboRules();
const srcPaths = (Array.isArray(pluginConfig.experimental.srcPath) ? pluginConfig.experimental.srcPath : [pluginConfig.experimental.srcPath]).map(srcPath => srcPath.endsWith('/') ? srcPath.slice(0, -1) : srcPath);
addTurboRule(rules, `*.{${SourceFileFilter.EXTENSIONS.join(',')}}`, {
loaders: [getExtractMessagesLoaderConfig()],
condition: {
// Note: We don't need `not: 'foreign'`, because this is
// implied by the filter based on `srcPath`.
path: `{${srcPaths.join(',')}}` + '/**/*',
content: /(useExtracted|getExtracted)/
}
});
}
// Add loader for catalog
if (pluginConfig.experimental?.messages) {
if (!isNextJs16OrHigher()) {
throwError('Message catalog loading requires Next.js 16 or higher.');
}
rules ??= getTurboRules();
const extension = getFormatExtension(pluginConfig.experimental.messages.format);
addTurboRule(rules, `*${extension}`, {
loaders: [getCatalogLoaderConfig()],
condition: {
path: `${pluginConfig.experimental.messages.path}/**/*`
},
as: '*.js'
});
}
if (hasStableTurboConfig() &&
// @ts-expect-error -- For Next.js <16
!nextConfig?.experimental?.turbo) {
nextIntlConfig.turbopack = {
...nextConfig?.turbopack,
...(rules && {
rules
}),
resolveAlias: {
...nextConfig?.turbopack?.resolveAlias,
...resolveAlias
}
};
} else {
nextIntlConfig.experimental = {
...nextConfig?.experimental,
// @ts-expect-error -- For Next.js <16
turbo: {
// @ts-expect-error -- For Next.js <16
...nextConfig?.experimental?.turbo,
...(rules && {
rules
}),
resolveAlias: {
// @ts-expect-error -- For Next.js <16
...nextConfig?.experimental?.turbo?.resolveAlias,
...resolveAlias
}
}
};
}
} else {
nextIntlConfig.webpack = function webpack(config, context) {
if (!config.resolve) config.resolve = {};
if (!config.resolve.alias) config.resolve.alias = {};
// Assign alias for `next-intl/config`
// (Webpack requires absolute paths)
config.resolve.alias['next-intl/config'] = path.resolve(config.context, resolveI18nPath(pluginConfig.requestConfig, config.context));
// Add loader for extractor
if (pluginConfig.experimental?.extract) {
if (!config.module) config.module = {};
if (!config.module.rules) config.module.rules = [];
const srcPath = pluginConfig.experimental.srcPath;
config.module.rules.push({
test: new RegExp(`\\.(${SourceFileFilter.EXTENSIONS.join('|')})$`),
include: Array.isArray(srcPath) ? srcPath.map(cur => path.resolve(config.context, cur)) : path.resolve(config.context, srcPath || ''),
use: [getExtractMessagesLoaderConfig()]
});
}
// Add loader for catalog
if (pluginConfig.experimental?.messages) {
if (!config.module) config.module = {};
if (!config.module.rules) config.module.rules = [];
const extension = getFormatExtension(pluginConfig.experimental.messages.format);
config.module.rules.push({
test: new RegExp(`${extension.replace(/\./g, '\\.')}$`),
include: path.resolve(config.context, pluginConfig.experimental.messages.path),
use: [getCatalogLoaderConfig()],
type: 'javascript/auto'
});
}
if (typeof nextConfig?.webpack === 'function') {
return nextConfig.webpack(config, context);
}
return config;
};
}
// Forward config
if (nextConfig?.trailingSlash) {
nextIntlConfig.env = {
...nextConfig.env,
_next_intl_trailing_slash: 'true'
};
}
return Object.assign({}, nextConfig, nextIntlConfig);
}
export { getNextConfig as default };
+32
View File
@@ -0,0 +1,32 @@
import { createRequire } from 'module';
function getCurrentVersion() {
try {
const require = createRequire(import.meta.url);
const pkg = require('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;
}
export { hasStableTurboConfig, isNextJs16OrHigher };
+26
View File
@@ -0,0 +1,26 @@
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();
};
}
export { once, throwError, warn };
+21
View File
@@ -0,0 +1,21 @@
import fs from 'fs';
import path from 'path';
/**
* Wrapper around `fs.watch` that provides a workaround
* for https://github.com/nodejs/node/issues/5039.
*/
function watchFile(filepath, callback) {
const directory = path.dirname(filepath);
const filename = path.basename(filepath);
return fs.watch(directory, {
persistent: false,
recursive: false
}, (event, changedFilename) => {
if (changedFilename === filename) {
callback();
}
});
}
export { watchFile as default };
+34
View File
@@ -0,0 +1,34 @@
import { useFormatter as useFormatter$1, useTranslations as useTranslations$1 } from 'use-intl';
/**
* This is the main entry file when non-'react-server'
* environments import from 'next-intl'.
*
* Maintainer notes:
* - Make sure this mirrors the API from 'react-server'.
* - Make sure everything exported from this module is
* supported in all Next.js versions that are supported.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
function callHook(name, hook) {
return (...args) => {
try {
return hook(...args);
} catch {
throw new Error(`Failed to call \`${name}\` because the context from \`NextIntlClientProvider\` was not found.
This can happen because:
1) You intended to render this component as a Server Component, the render
failed, and therefore React attempted to render the component on the client
instead. If this is the case, check the console for server errors.
2) You intended to render this component on the client side, but no context was found.
Learn more about this error here: https://next-intl.dev/docs/environments/server-client-components#missing-context` );
}
};
}
const useTranslations = callHook('useTranslations', useTranslations$1);
const useFormatter = callHook('useFormatter', useFormatter$1);
export { useFormatter, useTranslations };
@@ -0,0 +1,34 @@
import getConfigNow from '../server/react-server/getConfigNow.js';
import getFormats from '../server/react-server/getFormats.js';
import NextIntlClientProvider from '../shared/NextIntlClientProvider.js';
import { jsx } from 'react/jsx-runtime';
import getTimeZone from '../server/react-server/getTimeZone.js';
import getMessages from '../server/react-server/getMessages.js';
import getLocaleCached from '../server/react-server/getLocale.js';
async function NextIntlClientProviderServer({
formats,
locale,
messages,
now,
timeZone,
...rest
}) {
return /*#__PURE__*/jsx(NextIntlClientProvider
// We need to be careful about potentially reading from headers here.
// See https://github.com/amannn/next-intl/issues/631
, {
formats: formats === undefined ? await getFormats() : formats,
locale: locale ?? (await getLocaleCached()),
messages: messages === undefined ? await getMessages() : messages
// Note that we don't assign a default for `now` here,
// we only read one from the request config - if any.
// Otherwise this would cause a `dynamicIO` error.
,
now: now ?? (await getConfigNow()),
timeZone: timeZone ?? (await getTimeZone()),
...rest
});
}
export { NextIntlClientProviderServer as default };
+21
View File
@@ -0,0 +1,21 @@
import getConfig from '../server/react-server/getConfig.js';
import use from '../shared/use.js';
function useHook(hookName, promise) {
try {
return use(promise);
} catch (error) {
if (error instanceof TypeError && error.message.includes("Cannot read properties of null (reading 'use')")) {
throw new Error(`\`${hookName}\` is not callable within an async component. Please refer to https://next-intl.dev/docs/environments/server-client-components#async-components`, {
cause: error
});
} else {
throw error;
}
}
}
function useConfig(hookName) {
return useHook(hookName, getConfig());
}
export { useConfig as default };
@@ -0,0 +1,9 @@
import getServerExtractor from '../server/react-server/getServerExtractor.js';
import useConfig from './useConfig.js';
function useExtracted(namespace) {
const config = useConfig('useExtracted');
return getServerExtractor(config, namespace);
}
export { useExtracted as default };
@@ -0,0 +1,9 @@
import getFormatterCached from '../server/react-server/getServerFormatter.js';
import useConfig from './useConfig.js';
function useFormatter() {
const config = useConfig('useFormatter');
return getFormatterCached(config);
}
export { useFormatter as default };
@@ -0,0 +1,8 @@
import useConfig from './useConfig.js';
function useLocale() {
const config = useConfig('useLocale');
return config.locale;
}
export { useLocale as default };
@@ -0,0 +1,9 @@
import { getMessagesFromConfig } from '../server/react-server/getMessages.js';
import useConfig from './useConfig.js';
function useMessages() {
const config = useConfig('useMessages');
return getMessagesFromConfig(config);
}
export { useMessages as default };
+12
View File
@@ -0,0 +1,12 @@
import getDefaultNow from '../server/react-server/getDefaultNow.js';
import useConfig from './useConfig.js';
function useNow(options) {
if (options?.updateInterval != null) {
console.error("`useNow` doesn't support the `updateInterval` option in Server Components, the value will be ignored. If you need the value to update, you can convert the component to a Client Component.");
}
const config = useConfig('useNow');
return config.now ?? getDefaultNow();
}
export { useNow as default };
@@ -0,0 +1,8 @@
import useConfig from './useConfig.js';
function useTimeZone() {
const config = useConfig('useTimeZone');
return config.timeZone;
}
export { useTimeZone as default };
@@ -0,0 +1,9 @@
import getServerTranslator from '../server/react-server/getServerTranslator.js';
import useConfig from './useConfig.js';
function useTranslations(...[namespace]) {
const config = useConfig('useTranslations');
return getServerTranslator(config, namespace);
}
export { useTranslations as default };
+1
View File
@@ -0,0 +1 @@
export { default as defineRouting } from './routing/defineRouting.js';
+26
View File
@@ -0,0 +1,26 @@
function receiveRoutingConfig(input) {
return {
...input,
localePrefix: receiveLocalePrefixConfig(input.localePrefix),
localeCookie: receiveLocaleCookie(input.localeCookie),
localeDetection: input.localeDetection ?? true,
alternateLinks: input.alternateLinks ?? true
};
}
function receiveLocaleCookie(localeCookie) {
return localeCookie ?? true ? {
name: 'NEXT_LOCALE',
sameSite: 'lax',
...(typeof localeCookie === 'object' && localeCookie)
// `path` needs to be provided based on a detected base path
// that depends on the environment when setting a cookie
} : false;
}
function receiveLocalePrefixConfig(localePrefix) {
return typeof localePrefix === 'object' ? localePrefix : {
mode: localePrefix || 'always'
};
}
export { receiveRoutingConfig };
+25
View File
@@ -0,0 +1,25 @@
function defineRouting(config) {
if (config.domains) {
validateUniqueLocalesPerDomain(config.domains);
}
return config;
}
function validateUniqueLocalesPerDomain(domains) {
const domainsByLocale = new Map();
for (const {
domain,
locales
} of domains) {
for (const locale of locales) {
const localeDomains = domainsByLocale.get(locale) || new Set();
localeDomains.add(domain);
domainsByLocale.set(locale, localeDomains);
}
}
const duplicateLocaleMessages = Array.from(domainsByLocale.entries()).filter(([, localeDomains]) => localeDomains.size > 1).map(([locale, localeDomains]) => `- "${locale}" is used by: ${Array.from(localeDomains).join(', ')}`);
if (duplicateLocaleMessages.length > 0) {
console.warn('Locales are expected to be unique per domain, but found overlap:\n' + duplicateLocaleMessages.join('\n') + '\nPlease see https://next-intl.dev/docs/routing/configuration#domains');
}
}
export { defineRouting as default };
+1
View File
@@ -0,0 +1 @@
export { getExtracted, getFormatter, getLocale, getMessages, getNow, getRequestConfig, getTimeZone, getTranslations, setRequestLocale } from './server/react-client/index.js';
+9
View File
@@ -0,0 +1,9 @@
export { default as getRequestConfig } from './server/react-server/getRequestConfig.js';
export { default as getFormatter } from './server/react-server/getFormatter.js';
export { default as getNow } from './server/react-server/getNow.js';
export { default as getTimeZone } from './server/react-server/getTimeZone.js';
export { default as getExtracted } from './server/react-server/getExtracted.js';
export { default as getMessages } from './server/react-server/getMessages.js';
export { default as getLocale } from './server/react-server/getLocale.js';
export { default as getTranslations } from './server/react-server/getTranslations.js';
export { setCachedRequestLocale as setRequestLocale } from './server/react-server/RequestLocaleCache.js';
@@ -0,0 +1,32 @@
/**
* Allows to import `next-intl/server` in non-RSC environments.
*
* This is mostly relevant for testing, since e.g. a `generateMetadata`
* export from a page might use `next-intl/server`, but the test
* only uses the default export for a page.
*/
function notSupported(message) {
return () => {
throw new Error(`\`${message}\` is not supported in Client Components.`);
};
}
function getRequestConfig(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
...args) {
return notSupported('getRequestConfig');
}
const getFormatter = notSupported('getFormatter');
const getNow = notSupported('getNow');
const getTimeZone = notSupported('getTimeZone');
const getMessages = notSupported('getMessages');
const getLocale = notSupported('getLocale');
const getExtracted = notSupported('getExtracted');
// The type of `getTranslations` is not assigned here because it
// causes a type error. The types use the `react-server` entry
// anyway, therefore this is irrelevant.
const getTranslations = notSupported('getTranslations');
const setRequestLocale = notSupported('setRequestLocale');
export { getExtracted, getFormatter, getLocale, getMessages, getNow, getRequestConfig, getTimeZone, getTranslations, setRequestLocale };
@@ -0,0 +1,36 @@
import { headers } from 'next/headers';
import { cache } from 'react';
import { HEADER_LOCALE_NAME } from '../../shared/constants.js';
import { isPromise } from '../../shared/utils.js';
import { getCachedRequestLocale } from './RequestLocaleCache.js';
async function getHeadersImpl() {
const promiseOrValue = headers();
// Compatibility with Next.js <15
return isPromise(promiseOrValue) ? await promiseOrValue : promiseOrValue;
}
const getHeaders = cache(getHeadersImpl);
async function getLocaleFromHeaderImpl() {
let locale;
try {
locale = (await getHeaders()).get(HEADER_LOCALE_NAME) || undefined;
} catch (error) {
if (error instanceof Error && error.digest === 'DYNAMIC_SERVER_USAGE') {
const wrappedError = new Error('Usage of next-intl APIs in Server Components currently opts into dynamic rendering. This limitation will eventually be lifted, but as a stopgap solution, you can use the `setRequestLocale` API to enable static rendering, see https://next-intl.dev/docs/routing/setup#static-rendering', {
cause: error
});
wrappedError.digest = error.digest;
throw wrappedError;
} else {
throw error;
}
}
return locale;
}
const getLocaleFromHeader = cache(getLocaleFromHeaderImpl);
async function getRequestLocale() {
return getCachedRequestLocale() || (await getLocaleFromHeader());
}
export { getRequestLocale };
@@ -0,0 +1,18 @@
import { cache } from 'react';
// See https://github.com/vercel/next.js/discussions/58862
function getCacheImpl() {
const value = {
locale: undefined
};
return value;
}
const getCache = cache(getCacheImpl);
function getCachedRequestLocale() {
return getCache().locale;
}
function setCachedRequestLocale(locale) {
getCache().locale = locale;
}
export { getCachedRequestLocale, setCachedRequestLocale };
@@ -0,0 +1,2 @@
import getRuntimeConfig from 'next-intl/config';
export { default } from 'next-intl/config';
@@ -0,0 +1,59 @@
import { cache } from 'react';
import { initializeConfig, _createIntlFormatters, _createCache } from 'use-intl/core';
import { isPromise } from '../../shared/utils.js';
import { getRequestLocale } from './RequestLocale.js';
import getRuntimeConfig from 'next-intl/config';
import validateLocale from './validateLocale.js';
// This is automatically inherited by `NextIntlClientProvider` if
// the component is rendered from a Server Component
function getDefaultTimeZoneImpl() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
const getDefaultTimeZone = cache(getDefaultTimeZoneImpl);
async function receiveRuntimeConfigImpl(getConfig, localeOverride) {
if (typeof getConfig !== 'function') {
throw new Error(`Invalid i18n request configuration detected.
Please verify that:
1. In case you've specified a custom location in your Next.js config, make sure that the path is correct.
2. You have a default export in your i18n request configuration file.
See also: https://next-intl.dev/docs/usage/configuration#i18n-request
`);
}
const params = {
locale: localeOverride,
// In case the consumer doesn't read `params.locale` and instead provides the
// `locale` (either in a single-language workflow or because the locale is
// read from the user settings), don't attempt to read the request locale.
get requestLocale() {
return localeOverride ? Promise.resolve(localeOverride) : getRequestLocale();
}
};
let result = getConfig(params);
if (isPromise(result)) {
result = await result;
}
if (!result.locale) {
throw new Error('No locale was returned from `getRequestConfig`.\n\nSee https://next-intl.dev/docs/usage/configuration#i18n-request');
}
{
validateLocale(result.locale);
}
return result;
}
const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl);
const getFormatters = cache(_createIntlFormatters);
const getCache = cache(_createCache);
async function getConfigImpl(localeOverride) {
const runtimeConfig = await receiveRuntimeConfig(getRuntimeConfig, localeOverride);
return {
...initializeConfig(runtimeConfig),
_formatters: getFormatters(getCache()),
timeZone: runtimeConfig.timeZone || getDefaultTimeZone()
};
}
const getConfig = cache(getConfigImpl);
export { getConfig as default };
@@ -0,0 +1,10 @@
import { cache } from 'react';
import getConfig from './getConfig.js';
async function getConfigNowImpl(locale) {
const config = await getConfig(locale);
return config.now;
}
const getConfigNow = cache(getConfigNowImpl);
export { getConfigNow as default };
@@ -0,0 +1,9 @@
import { cache } from 'react';
function defaultNow() {
// See https://next-intl.dev/docs/usage/dates-times#relative-times-server
return new Date();
}
const getDefaultNow = cache(defaultNow);
export { getDefaultNow as default };
@@ -0,0 +1,24 @@
import { cache } from 'react';
import getConfig from './getConfig.js';
import getServerExtractor from './getServerExtractor.js';
// Call signature 1: `getExtracted(namespace)`
// Call signature 2: `getExtracted({locale, namespace})`
// Implementation
async function getExtractedImpl(namespaceOrOpts) {
let namespace;
let locale;
if (typeof namespaceOrOpts === 'string') {
namespace = namespaceOrOpts;
} else if (namespaceOrOpts) {
locale = namespaceOrOpts.locale;
namespace = namespaceOrOpts.namespace;
}
const config = await getConfig(locale);
return getServerExtractor(config, namespace);
}
const getExtracted = cache(getExtractedImpl);
export { getExtracted as default };
@@ -0,0 +1,10 @@
import { cache } from 'react';
import getConfig from './getConfig.js';
async function getFormatsCachedImpl() {
const config = await getConfig();
return config.formats;
}
const getFormats = cache(getFormatsCachedImpl);
export { getFormats as default };
@@ -0,0 +1,21 @@
import { cache } from 'react';
import getConfig from './getConfig.js';
import getFormatterCached$1 from './getServerFormatter.js';
async function getFormatterCachedImpl(locale) {
const config = await getConfig(locale);
return getFormatterCached$1(config);
}
const getFormatterCached = cache(getFormatterCachedImpl);
/**
* Returns a formatter based on the given locale.
*
* The formatter automatically receives the request config, but
* you can override it by passing in additional options.
*/
async function getFormatter(opts) {
return getFormatterCached(opts?.locale);
}
export { getFormatter as default };
@@ -0,0 +1,10 @@
import { cache } from 'react';
import getConfig from './getConfig.js';
async function getLocaleCachedImpl() {
const config = await getConfig();
return config.locale;
}
const getLocaleCached = cache(getLocaleCachedImpl);
export { getLocaleCached as default };
@@ -0,0 +1,19 @@
import { cache } from 'react';
import getConfig from './getConfig.js';
function getMessagesFromConfig(config) {
if (!config.messages) {
throw new Error('No messages found. Have you configured them correctly? See https://next-intl.dev/docs/configuration#messages');
}
return config.messages;
}
async function getMessagesCachedImpl(locale) {
const config = await getConfig(locale);
return getMessagesFromConfig(config);
}
const getMessagesCached = cache(getMessagesCachedImpl);
async function getMessages(opts) {
return getMessagesCached(opts?.locale);
}
export { getMessages as default, getMessagesFromConfig };
@@ -0,0 +1,8 @@
import getConfigNow from './getConfigNow.js';
import getDefaultNow from './getDefaultNow.js';
async function getNow(opts) {
return (await getConfigNow(opts?.locale)) ?? getDefaultNow();
}
export { getNow as default };
@@ -0,0 +1,8 @@
/**
* Should be called in `i18n/request.ts` to create the configuration for the current request.
*/
function getRequestConfig(createRequestConfig) {
return createRequestConfig;
}
export { getRequestConfig as default };
@@ -0,0 +1,38 @@
import { cache } from 'react';
import getServerTranslator from './getServerTranslator.js';
// Note: This API is usually compiled into `useTranslations`,
// but there is some fallback handling which allows this hook
// to still work when not being compiled.
//
// This is relevant for:
// - Isolated environments like tests, Storybook, etc.
// - Fallbacks in case an extracted message is not yet available
function getServerExtractorImpl(config, namespace) {
const t = getServerTranslator(config, namespace);
function translateFn(...[message, values, formats]) {
return t(undefined, values, formats,
// @ts-expect-error -- Secret fallback parameter
message );
}
translateFn.rich = function translateRichFn(...[message, values, formats]) {
return t.rich(undefined, values, formats,
// @ts-expect-error -- Secret fallback parameter
message );
};
translateFn.markup = function translateMarkupFn(...[message, values, formats]) {
return t.markup(undefined, values, formats,
// @ts-expect-error -- Secret fallback parameter
message );
};
translateFn.has = function translateHasFn(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
...[message]) {
// Not really something better we can do here
return true;
};
return translateFn;
}
var getServerExtractor = cache(getServerExtractorImpl);
export { getServerExtractor as default };
@@ -0,0 +1,17 @@
import { cache } from 'react';
import { createFormatter } from 'use-intl/core';
import getDefaultNow from './getDefaultNow.js';
function getFormatterCachedImpl(config) {
return createFormatter({
...config,
// Only init when necessary to avoid triggering a `dynamicIO` error
// unnecessarily (`now` is only needed for `format.relativeTime`)
get now() {
return config.now ?? getDefaultNow();
}
});
}
const getFormatterCached = cache(getFormatterCachedImpl);
export { getFormatterCached as default };
@@ -0,0 +1,12 @@
import { cache } from 'react';
import { createTranslator } from 'use-intl/core';
function getServerTranslatorImpl(config, namespace) {
return createTranslator({
...config,
namespace
});
}
var getServerTranslator = cache(getServerTranslatorImpl);
export { getServerTranslator as default };
@@ -0,0 +1,13 @@
import { cache } from 'react';
import getConfig from './getConfig.js';
async function getTimeZoneCachedImpl(locale) {
const config = await getConfig(locale);
return config.timeZone;
}
const getTimeZoneCached = cache(getTimeZoneCachedImpl);
async function getTimeZone(opts) {
return getTimeZoneCached(opts?.locale);
}
export { getTimeZone as default };
@@ -0,0 +1,28 @@
import { cache } from 'react';
import getConfig from './getConfig.js';
import getServerTranslator from './getServerTranslator.js';
// Maintainer note: `getTranslations` has two different call signatures.
// We need to define these with function overloads, otherwise TypeScript
// messes up the return type.
// Call signature 1: `getTranslations(namespace)`
// Call signature 2: `getTranslations({locale, namespace})`
// Implementation
async function getTranslations(namespaceOrOpts) {
let namespace;
let locale;
if (typeof namespaceOrOpts === 'string') {
namespace = namespaceOrOpts;
} else if (namespaceOrOpts) {
locale = namespaceOrOpts.locale;
namespace = namespaceOrOpts.namespace;
}
const config = await getConfig(locale);
return getServerTranslator(config, namespace);
}
var getTranslations$1 = cache(getTranslations);
export { getTranslations$1 as default };
@@ -0,0 +1,12 @@
function validateLocale(locale) {
try {
const constructed = new Intl.Locale(locale);
if (!constructed.language) {
throw new Error('Language is required');
}
} catch {
console.error(`An invalid locale was provided: "${locale}"\nPlease ensure you're using a valid Unicode locale identifier (e.g. "en-US").`);
}
}
export { validateLocale as default };
@@ -0,0 +1,18 @@
"use client";
import { IntlProvider } from 'use-intl/react';
import { jsx } from 'react/jsx-runtime';
function NextIntlClientProvider({
locale,
...rest
}) {
if (!locale) {
throw new Error("Couldn't infer the `locale` prop in `NextIntlClientProvider`, please provide it explicitly.\n\nSee https://next-intl.dev/docs/configuration#locale" );
}
return /*#__PURE__*/jsx(IntlProvider, {
locale: locale,
...rest
});
}
export { NextIntlClientProvider as default };
+4
View File
@@ -0,0 +1,4 @@
// Used to read the locale from the middleware
const HEADER_LOCALE_NAME = 'X-NEXT-INTL-LOCALE';
export { HEADER_LOCALE_NAME };
+13
View File
@@ -0,0 +1,13 @@
import * as react from 'react';
// @ts-expect-error -- Ooof, Next.js doesn't make this easy.
// `use` is only available in React 19 canary, but we can
// use it in Next.js already as Next.js "vendors" a fixed
// version of React. However, if we'd simply put `use` in
// ESM code, then the build doesn't work since React does
// not export `use` officially. Therefore, we have to use
// something that is not statically analyzable. Once React
// 19 is out, we can remove this in the next major version.
var use = react['use'.trim()];
export { use as default };
+140
View File
@@ -0,0 +1,140 @@
function isRelativeHref(href) {
const pathname = typeof href === 'object' ? href.pathname : href;
return pathname != null && !pathname.startsWith('/');
}
function isLocalHref(href) {
if (typeof href === 'object') {
return href.host == null && href.hostname == null;
} else {
const hasProtocol = /^[a-z]+:/i.test(href);
return !hasProtocol;
}
}
function isLocalizableHref(href) {
return isLocalHref(href) && !isRelativeHref(href);
}
function unprefixPathname(pathname, prefix) {
return pathname.replace(new RegExp(`^${prefix}`), '') || '/';
}
function prefixPathname(prefix, pathname) {
let localizedHref = prefix;
// Avoid trailing slashes
if (/^\/(\?.*)?$/.test(pathname)) {
pathname = pathname.slice(1);
}
localizedHref += pathname;
return localizedHref;
}
function hasPathnamePrefixed(prefix, pathname) {
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
function hasTrailingSlash() {
try {
// Provided via `env` setting in `next.config.js` via the plugin
return process.env._next_intl_trailing_slash === 'true';
} catch {
return false;
}
}
function getLocalizedTemplate(pathnameConfig, locale, internalTemplate) {
return typeof pathnameConfig === 'string' ? pathnameConfig : pathnameConfig[locale] || internalTemplate;
}
function normalizeTrailingSlash(pathname) {
const trailingSlash = hasTrailingSlash();
const [path, ...hashParts] = pathname.split('#');
const hash = hashParts.join('#');
let normalizedPath = path;
if (normalizedPath !== '/') {
const pathnameEndsWithSlash = normalizedPath.endsWith('/');
if (trailingSlash && !pathnameEndsWithSlash) {
normalizedPath += '/';
} else if (!trailingSlash && pathnameEndsWithSlash) {
normalizedPath = normalizedPath.slice(0, -1);
}
}
if (hash) {
normalizedPath += '#' + hash;
}
return normalizedPath;
}
function matchesPathname(/** E.g. `/users/[userId]-[userName]` */
template, /** E.g. `/users/23-jane` */
pathname) {
const normalizedTemplate = normalizeTrailingSlash(template);
const normalizedPathname = normalizeTrailingSlash(pathname);
const regex = templateToRegex(normalizedTemplate);
return regex.test(normalizedPathname);
}
function getLocalePrefix(locale, localePrefix) {
return localePrefix.mode !== 'never' && localePrefix.prefixes?.[locale] ||
// We return a prefix even if `mode: 'never'`. It's up to the consumer
// to decide to use it or not.
getLocaleAsPrefix(locale);
}
function getLocaleAsPrefix(locale) {
return '/' + locale;
}
function templateToRegex(template) {
const regexPattern = template
// Replace optional catchall ('[[...slug]]')
.replace(/\/\[\[(\.\.\.[^\]]+)\]\]/g, '(?:/(.*))?') // With leading slash
.replace(/\[\[(\.\.\.[^\]]+)\]\]/g, '(?:/(.*))?') // Without leading slash
// Replace catchall ('[...slug]')
.replace(/\[(\.\.\.[^\]]+)\]/g, '(.+)')
// Replace regular parameter ('[slug]')
.replace(/\[([^\]]+)\]/g, '([^/]+)');
return new RegExp(`^${regexPattern}$`);
}
function isOptionalCatchAllSegment(pathname) {
return pathname.includes('[[...');
}
function isCatchAllSegment(pathname) {
return pathname.includes('[...');
}
function isDynamicSegment(pathname) {
return pathname.includes('[');
}
function comparePathnamePairs(a, b) {
const pathA = a.split('/');
const pathB = b.split('/');
const maxLength = Math.max(pathA.length, pathB.length);
for (let i = 0; i < maxLength; i++) {
const segmentA = pathA[i];
const segmentB = pathB[i];
// If one of the paths ends, prioritize the shorter path
if (!segmentA && segmentB) return -1;
if (segmentA && !segmentB) return 1;
if (!segmentA && !segmentB) continue;
// Prioritize static segments over dynamic segments
if (!isDynamicSegment(segmentA) && isDynamicSegment(segmentB)) return -1;
if (isDynamicSegment(segmentA) && !isDynamicSegment(segmentB)) return 1;
// Prioritize non-catch-all segments over catch-all segments
if (!isCatchAllSegment(segmentA) && isCatchAllSegment(segmentB)) return -1;
if (isCatchAllSegment(segmentA) && !isCatchAllSegment(segmentB)) return 1;
// Prioritize non-optional catch-all segments over optional catch-all segments
if (!isOptionalCatchAllSegment(segmentA) && isOptionalCatchAllSegment(segmentB)) {
return -1;
}
if (isOptionalCatchAllSegment(segmentA) && !isOptionalCatchAllSegment(segmentB)) {
return 1;
}
if (segmentA === segmentB) continue;
}
// Both pathnames are completely static
return 0;
}
function getSortedPathnames(pathnames) {
return pathnames.sort(comparePathnamePairs);
}
function isPromise(value) {
// https://github.com/amannn/next-intl/issues/1711
return typeof value.then === 'function';
}
export { getLocaleAsPrefix, getLocalePrefix, getLocalizedTemplate, getSortedPathnames, hasPathnamePrefixed, isLocalizableHref, isPromise, matchesPathname, normalizeTrailingSlash, prefixPathname, templateToRegex, unprefixPathname };
+1
View File
@@ -0,0 +1 @@
function t(){throw new Error("Couldn't find next-intl config file. Please follow the instructions at https://next-intl.dev/docs/getting-started/app-router")}export{t as default};
+1
View File
@@ -0,0 +1 @@
export{default as unstable_extractMessages}from"./extractor/extractMessages.js";export{defineCodec}from"./extractor/format/ExtractorCodec.js";
@@ -0,0 +1 @@
import s from"./catalog/CatalogManager.js";import t from"./extractor/MessageExtractor.js";class o{constructor(o,a={}){const e=a.extractor??new t(a);this.manager=new s(o,{...a,extractor:e}),this[Symbol.dispose]=this[Symbol.dispose].bind(this),this.installExitHandlers()}async extractAll(){await this.manager.loadMessages(),await this.manager.save()}[Symbol.dispose](){this.uninstallExitHandlers(),this.manager[Symbol.dispose]()}installExitHandlers(){const s=this[Symbol.dispose];process.on("exit",s),process.on("SIGINT",s),process.on("SIGTERM",s)}uninstallExitHandlers(){const s=this[Symbol.dispose];process.off("exit",s),process.off("SIGINT",s),process.off("SIGTERM",s)}}export{o as default};
@@ -0,0 +1 @@
import e from"fs";import s from"fs/promises";import t from"path";class a{onChangeCallbacks=(()=>new Set)();constructor(e){this.messagesDir=e.messagesDir,this.sourceLocale=e.sourceLocale,this.extension=e.extension,this.locales=e.locales}async getTargetLocales(){return this.targetLocales||("infer"===this.locales?this.targetLocales=await this.readTargetLocales():this.targetLocales=this.locales.filter((e=>e!==this.sourceLocale))),this.targetLocales}async readTargetLocales(){try{return(await s.readdir(this.messagesDir)).filter((e=>e.endsWith(this.extension))).map((e=>t.basename(e,this.extension))).filter((e=>e!==this.sourceLocale))}catch{return[]}}subscribeLocalesChange(e){this.onChangeCallbacks.add(e),"infer"!==this.locales||this.watcher||this.startWatcher()}unsubscribeLocalesChange(e){this.onChangeCallbacks.delete(e),0===this.onChangeCallbacks.size&&this.stopWatcher()}async startWatcher(){this.watcher||(await s.mkdir(this.messagesDir,{recursive:!0}),this.watcher=e.watch(this.messagesDir,{persistent:!1,recursive:!1},((e,s)=>{null!=s&&s.endsWith(this.extension)&&!s.includes(t.sep)&&this.onChange()})))}stopWatcher(){this.watcher&&(this.watcher.close(),this.watcher=void 0)}async onChange(){const e=new Set(this.targetLocales||[]);this.targetLocales=await this.readTargetLocales();const s=new Set(this.targetLocales),t=this.targetLocales.filter((s=>!e.has(s))),a=Array.from(e).filter((e=>!s.has(e)));if(t.length>0||a.length>0)for(const e of this.onChangeCallbacks)e({added:t,removed:a})}}export{a as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import e from"fs/promises";import t from"path";class r{constructor(e){this.messagesPath=e.messagesPath,this.codec=e.codec,this.extension=e.extension}getFileName(e){return e+this.extension}getFilePath(e){return t.join(this.messagesPath,this.getFileName(e))}async read(t){const r=this.getFilePath(t);let i;try{i=await e.readFile(r,"utf8")}catch(e){if(e&&"object"==typeof e&&"code"in e&&"ENOENT"===e.code)return[];throw new Error(`Error while reading ${this.getFileName(t)}:\n> ${e}`,{cause:e})}try{return this.codec.decode(i,{locale:t})}catch(e){throw new Error(`Error while decoding ${this.getFileName(t)}:\n> ${e}`,{cause:e})}}async write(r,i){const a=this.getFilePath(i.locale),s=this.codec.encode(r,i);try{const r=t.dirname(a);await e.mkdir(r,{recursive:!0}),await e.writeFile(a,s)}catch(e){console.error(`❌ Failed to write catalog: ${e}`)}}async getLastModified(t){const r=this.getFilePath(t);try{return(await e.stat(r)).mtime}catch{return}}}export{r as default};
@@ -0,0 +1 @@
class e{isSaving=!1;pendingResolvers=[];constructor(e=50){this.delayMs=e}async schedule(e){return new Promise(((s,i)=>{this.pendingResolvers.push({resolve:s,reject:i}),this.nextSaveTask=e,this.isSaving||this.saveTimeout?this.saveTimeout&&this.scheduleSave():this.executeSave()}))}scheduleSave(){this.saveTimeout&&clearTimeout(this.saveTimeout),this.saveTimeout=setTimeout((()=>{this.saveTimeout=void 0,this.executeSave()}),this.delayMs)}async executeSave(){if(this.isSaving)return;const e=this.nextSaveTask;if(!e)return;const s=this.pendingResolvers;this.pendingResolvers=[],this.nextSaveTask=void 0,this.isSaving=!0;try{const i=await e();s.forEach((({resolve:e})=>e(i)))}catch(e){s.forEach((({reject:s})=>s(e)))}finally{this.isSaving=!1,this.pendingResolvers.length>0&&this.scheduleSave()}}[Symbol.dispose](){this.saveTimeout&&(clearTimeout(this.saveTimeout),this.saveTimeout=void 0),this.pendingResolvers=[],this.nextSaveTask=void 0,this.isSaving=!1}}export{e as default};

Some files were not shown because too many files have changed in this diff Show More