Add internationalization support with astro-i18next integration

- Implemented astro-i18next for multi-language support, including English, Dutch, and Italian.
- Configured default locale and language fallback settings.
- Defined routes for localized content in the configuration.
- Updated package.json and package-lock.json to include new dependencies for i18next and related plugins.
This commit is contained in:
becarta
2025-05-23 15:10:00 +02:00
parent 8a3507dce0
commit 3168826fa8
581 changed files with 88691 additions and 494 deletions

94
node_modules/astro-i18next/src/cli/generate.ts generated vendored Normal file
View File

@@ -0,0 +1,94 @@
import fs from "fs";
import { resolve } from "pathe";
import { AstroI18nextConfig } from "../types";
import {
getAstroPagesFullPaths,
createFiles,
FileToGenerate,
generateLocalizedFrontmatter,
overwriteAstroFrontmatter,
parseFrontmatter,
resolveTranslatedAstroPath,
resolveRelativePathsLevel,
} from "./utils";
/**
* Reads all files inside inputPath
*
* @param inputPath
* @param locales
* @param outputPath
*/
export const generate = (
inputPath: string,
defaultLocale: AstroI18nextConfig["defaultLocale"],
locales: AstroI18nextConfig["locales"],
showDefaultLocale = false,
flatRoutes?: AstroI18nextConfig["flatRoutes"],
outputPath: string = inputPath
): { filesToGenerate: FileToGenerate[]; timeToProcess: number } => {
const start = process.hrtime();
// default locale page paths
const astroPagesFullPaths = showDefaultLocale
? getAstroPagesFullPaths(inputPath, defaultLocale, locales)
: getAstroPagesFullPaths(inputPath, undefined, locales);
const filesToGenerate: FileToGenerate[] = [];
astroPagesFullPaths.forEach(async function (astroFileFullPath: string) {
const astroFilePath = resolve(astroFileFullPath).replace(
resolve(inputPath),
""
);
const inputFilePath = showDefaultLocale
? [inputPath, defaultLocale, astroFilePath].join("/")
: [inputPath, astroFilePath].join("/");
const fileContents = fs.readFileSync(inputFilePath);
const fileContentsString = fileContents.toString();
const parsedFrontmatter = parseFrontmatter(fileContentsString);
locales.forEach((locale) => {
const isOtherLocale = locale !== defaultLocale;
const fileDepth = showDefaultLocale ? 0 : Number(isOtherLocale);
// add i18next's changeLanguage function to frontmatter
const frontmatterCode = generateLocalizedFrontmatter(
parsedFrontmatter,
locale
);
// get the astro file contents
let newFileContents = overwriteAstroFrontmatter(
fileContentsString,
frontmatterCode
);
// add depth to imports and Astro.glob pattern
newFileContents = resolveRelativePathsLevel(newFileContents, fileDepth);
const createLocaleFolder = showDefaultLocale ? true : isOtherLocale;
filesToGenerate.push({
path: resolve(
resolveTranslatedAstroPath(
astroFilePath,
createLocaleFolder ? locale : undefined,
outputPath,
flatRoutes
)
),
source: newFileContents,
});
});
});
createFiles(filesToGenerate);
return {
filesToGenerate,
timeToProcess: process.hrtime(start)[1] / 1000000,
};
};

74
node_modules/astro-i18next/src/cli/index.ts generated vendored Executable file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env node
import { flattenRoutes } from "../config";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { generate } from "./generate";
import { loadConfig, normalizePath } from "./middlewares";
import { GenerateArgs } from "./types";
yargs(hideBin(process.argv))
.usage("usage: $0 <command>")
.command<GenerateArgs>(
"generate [path] [options]",
"generates localized Astro pages",
(yargs) => {
return yargs
.positional("path", {
type: "string",
description: "Path to the Astro project folder",
default: "./",
})
.option("output", {
alias: "o",
type: "string",
description:
"Set the output of the generated pages if different from input",
});
},
async (argv) => {
if (argv.verbose) {
console.info(`Generating localized pages: ${argv.config.locales}`);
}
const pagesPath = argv.path + "src/pages";
const flatRoutes = flattenRoutes(argv.config.routes);
const result = generate(
pagesPath,
argv.config.defaultLocale,
argv.config.locales,
argv.config.showDefaultLocale,
flatRoutes,
argv.output
);
if (argv.verbose) {
const filepaths = result.filesToGenerate.map(
(fileToGenerate) => fileToGenerate.path
);
console.log(`\n✨ ${filepaths.join("\n✨ ")}\n`);
}
// All good! Show success feedback
console.log(
`🧪 Localized .astro pages were generated successfully, it took ${result.timeToProcess.toFixed()}ms!`
);
}
)
.middleware([normalizePath, loadConfig], true)
.options({
config: {
alias: "c",
type: "string",
description:
"Set the output of the generated pages if different from input",
},
verbose: {
alias: "v",
type: "boolean",
description: "Run with verbose logging",
},
})
.parse();

32
node_modules/astro-i18next/src/cli/middlewares.ts generated vendored Normal file
View File

@@ -0,0 +1,32 @@
import { pathToFileURL } from "url";
import { MiddlewareFunction } from "yargs";
import { getUserConfig } from "../utils";
import { GenerateArgs, GlobalArgs } from "./types";
// @ts-ignore
export const loadConfig: MiddlewareFunction<GlobalArgs & GenerateArgs> = async (
argv
): Promise<GlobalArgs & GenerateArgs> => {
const { path, config } = argv;
const userConfig = await getUserConfig(pathToFileURL(path), config as any);
if (path && !userConfig?.value) {
throw new Error(
`Could not find a config file at ${JSON.stringify(
path
)}. Does the file exist?`
);
}
return { ...argv, config: userConfig?.value };
};
// @ts-ignore
export const normalizePath: MiddlewareFunction<
GlobalArgs & GenerateArgs
> = async (argv): Promise<GlobalArgs & GenerateArgs> => {
const { path } = argv;
return { ...argv, path: path.endsWith("/") ? path : path + "/" };
};

162
node_modules/astro-i18next/src/cli/transformer.ts generated vendored Normal file
View File

@@ -0,0 +1,162 @@
import ts from "typescript";
/**
* Traverse ts' AST to inject i18next's language switch
* @param context
* @returns
*/
export const transformer: ts.TransformerFactory<ts.SourceFile> =
(context: ts.TransformationContext) => (rootNode) => {
const { factory, getCompilerOptions } = context;
let doesI18nextImportExist = false;
const { locale } = getCompilerOptions();
function visit(node: ts.Node): ts.Node {
// isolate i18next import statement
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
node.moduleSpecifier.text === "i18next" &&
ts.isImportClause(node.importClause)
) {
doesI18nextImportExist = true;
if (!node.importClause.namedBindings) {
return factory.updateImportDeclaration(
node,
node.modifiers,
factory.createImportClause(
node.importClause.isTypeOnly,
node.importClause.name,
factory.createNamedImports([
factory.createImportSpecifier(
false,
undefined,
factory.createIdentifier("changeLanguage")
),
])
),
node.moduleSpecifier,
node.assertClause
);
}
if (
ts.isImportClause(node.importClause) &&
ts.isNamedImports(node.importClause.namedBindings) &&
!node.importClause.namedBindings.elements.find(
(namedImport) => namedImport.name.escapedText === "changeLanguage"
)
) {
// import changeLanguage function in i18next import declaration
return factory.updateImportDeclaration(
node,
node.modifiers,
factory.updateImportClause(
node.importClause,
false,
node.importClause.name,
factory.updateNamedImports(node.importClause.namedBindings, [
...node.importClause.namedBindings.elements,
factory.createImportSpecifier(
false,
undefined,
factory.createIdentifier("changeLanguage")
),
])
),
node.moduleSpecifier,
node.assertClause
);
}
return node;
}
// remove any occurrence of changeLanguage() call
if (
ts.isExpressionStatement(node) &&
ts.isCallExpression(node.expression) &&
((ts.isPropertyAccessExpression(node.expression.expression) &&
node.expression.expression.name.escapedText === "changeLanguage") ||
(ts.isIdentifier(node.expression.expression) &&
node.expression.expression.escapedText === "changeLanguage"))
) {
return undefined;
}
return ts.visitEachChild(node, visit, context);
}
let visitedNode = ts.visitNode(rootNode, visit);
const statements = [...visitedNode.statements];
if (!doesI18nextImportExist) {
statements.unshift(
factory.createImportDeclaration(
undefined,
factory.createImportClause(
false,
undefined,
factory.createNamedImports([
factory.createImportSpecifier(
false,
undefined,
factory.createIdentifier("changeLanguage")
),
])
),
factory.createStringLiteral("i18next"),
undefined
)
);
}
// append the changeLanguage statement after imports
// get a boolean array with values telling whether or not a statement is an import
const importDeclarationsMap = statements.map((statement) =>
ts.isImportDeclaration(statement)
);
const lastIndexOfImportDeclaration =
importDeclarationsMap.lastIndexOf(true);
// insert changeLanguage statement after the imports
// and surrounded by line breaks
statements.splice(
lastIndexOfImportDeclaration + 1,
0,
factory.createIdentifier("\n") as unknown as ts.Statement
);
statements.splice(
lastIndexOfImportDeclaration + 1,
0,
factory.createExpressionStatement(
factory.createCallExpression(
factory.createIdentifier("changeLanguage"),
undefined,
[factory.createStringLiteral(locale as string)]
)
)
);
statements.splice(
lastIndexOfImportDeclaration + 1,
0,
factory.createIdentifier("\n") as unknown as ts.Statement
);
visitedNode = factory.updateSourceFile(
visitedNode,
statements,
visitedNode.isDeclarationFile,
visitedNode.referencedFiles,
visitedNode.typeReferenceDirectives,
visitedNode.hasNoDefaultLib,
visitedNode.libReferenceDirectives
);
return visitedNode;
};

11
node_modules/astro-i18next/src/cli/types.ts generated vendored Normal file
View File

@@ -0,0 +1,11 @@
import { AstroI18nextConfig } from "../types";
export interface GlobalArgs {
verbose: boolean;
}
export interface GenerateArgs {
path: string;
config: AstroI18nextConfig;
output: string;
}

182
node_modules/astro-i18next/src/cli/utils.ts generated vendored Normal file
View File

@@ -0,0 +1,182 @@
import { fdir, PathsOutput } from "fdir";
import fsExtra from "fs-extra";
import path from "path";
import fs from "fs";
import ts from "typescript";
import { transformer } from "./transformer";
import { AstroI18nextConfig } from "../types";
export interface FileToGenerate {
path: string;
source: string;
}
export const doesStringIncludeFrontmatter = (source: string): boolean =>
/---.*---/s.test(source);
export const extractFrontmatterFromAstroSource = (source: string): string => {
if (doesStringIncludeFrontmatter(source)) {
const {
groups: { frontmatter },
} = /---(?<frontmatter>(.*))---/s.exec(source);
return frontmatter;
}
return "";
};
export const overwriteAstroFrontmatter = (
source: string,
frontmatter: string
): string => {
if (doesStringIncludeFrontmatter(source)) {
return source.replace(/---[\s\S]*---/g, `---\n${frontmatter.trim()}\n---`);
}
return `---\n${frontmatter.trim()}\n---\n\n` + source;
};
export const addDepthToRelativePath = (
relativePath: string,
depth: number = 1
): string => {
if (relativePath.startsWith("./") && depth > 0) {
// remove "./" from relativePath
relativePath = relativePath.slice(2);
}
return relativePath.padStart(relativePath.length + depth * 3, "../");
};
/**
* file is hidden if its name or any of its parent folders start with an underscore
*/
export const isFileHidden = (filepath: string): boolean => {
return /((^_)|(\/_))/.test(filepath);
};
export const resolveRelativePathsLevel = (
fileContents: string,
fileDepth: number
) => {
fileContents = fileContents.replace(
/(import\s+.*["'])(\..*)(["'])/g,
(_, before, relativePath, after) =>
`${before}${addDepthToRelativePath(relativePath, fileDepth)}${after}`
);
fileContents = fileContents.replace(
/(Astro.glob\(["'])(\..*)(["']\))/g,
(_, before, relativePath, after) =>
`${before}${addDepthToRelativePath(relativePath, fileDepth)}${after}`
);
fileContents = fileContents.replace(
/(<script\s+src=["'])(\..*)(["'])/g,
(_, before, relativePath, after) =>
`${before}${addDepthToRelativePath(relativePath, fileDepth)}${after}`
);
return fileContents;
};
/* c8 ignore start */
/**
* parse frontmatter using typescript compiler
*
* @param source
*/
export const parseFrontmatter = (source: string): ts.SourceFile =>
ts.createSourceFile(
"x.ts",
extractFrontmatterFromAstroSource(source),
ts.ScriptTarget.Latest
);
export const generateLocalizedFrontmatter = (
tsNode: ts.SourceFile,
locale: string
) => {
// generate for default locale, then loop over locales to generate other pages
const result: ts.TransformationResult<ts.SourceFile> = ts.transform(
tsNode,
[transformer],
{ locale }
);
const printer = ts.createPrinter();
return printer.printNode(
ts.EmitHint.Unspecified,
result.transformed[0],
tsNode
);
};
/**
* Crawls pages directory and returns all Astro pages
* except for locale folders and excluded pages / directories (starting with underscore).
* (https://docs.astro.build/en/core-concepts/routing/#excluding-pages)
*
* @param pagesDirectoryPath
* @param childDirToCrawl will make the function crawl inside the given
* `childDirToCrawl` (doesn't take paths, only dirname).
*/
export const getAstroPagesFullPaths = (
pagesDirectoryPath: string,
childDirToCrawl: AstroI18nextConfig["defaultLocale"] | undefined = undefined,
locales: AstroI18nextConfig["locales"] = []
): PathsOutput => {
// eslint-disable-next-line new-cap
const api = new fdir()
.filter(
(filepath) => !isFileHidden(filepath) && filepath.endsWith(".astro")
)
.exclude((dirName) => locales.includes(dirName))
.withFullPaths();
return childDirToCrawl
? (api
.crawl(`${pagesDirectoryPath}${path.sep}${childDirToCrawl}`)
.sync() as PathsOutput)
: (api.crawl(pagesDirectoryPath).sync() as PathsOutput);
};
export const createFiles = (filesToGenerate: FileToGenerate[]): void => {
filesToGenerate.forEach((fileToGenerate) => {
fsExtra.ensureDirSync(path.dirname(fileToGenerate.path));
fs.writeFileSync(fileToGenerate.path, fileToGenerate.source);
});
};
/* c8 ignore stop */
/**
* Resolves the right translated path based on
* a given `astroFilePath` and a locale,
* with the `routeTranslations` mapping.
*/
export const resolveTranslatedAstroPath = (
astroFilePath: string,
locale: string | null = null,
basePath: string = "",
flatRoutes: AstroI18nextConfig["flatRoutes"] = {}
) => {
astroFilePath = astroFilePath.replace(/^\/+|\/+$/g, "");
// remove trailing slash of basePath if any
basePath = basePath.replace(/\/+$/g, "");
if (locale === null) {
return `${basePath}/${astroFilePath}`;
}
astroFilePath = astroFilePath.replace(/.astro$/, "");
const filePath = `/${locale}/${astroFilePath}`;
// is route translated?
if (Object.prototype.hasOwnProperty.call(flatRoutes, filePath)) {
return `${basePath}${flatRoutes[filePath]}.astro`;
}
return `${basePath}/${locale}/${astroFilePath}.astro`;
};

View File

@@ -0,0 +1,17 @@
---
import i18next from "i18next";
import { localizeUrl } from "../..";
const supportedLanguages = i18next.languages;
const currentUrl = Astro.url.href;
---
{
supportedLanguages.map((supportedLanguage) => (
<link
rel="alternate"
hreflang={supportedLanguage}
href={localizeUrl(currentUrl, supportedLanguage)}
/>
))
}

View File

@@ -0,0 +1,49 @@
---
import i18next from "i18next";
import { localizePath } from "../..";
import localeEmoji from "locale-emoji";
import ISO6991 from "iso-639-1";
interface languageMapping {
[localeCode: string]: string;
}
export interface Props extends astroHTML.JSX.SelectHTMLAttributes {
showFlag?: boolean;
languageMapping?: languageMapping;
}
const supportedLanguages = i18next.languages;
const currentLanguage = i18next.language;
const { pathname } = Astro.url;
const { showFlag = false, languageMapping, ...attributes } = Astro.props;
---
<select onchange="location = this.value;" {...attributes}>
{
supportedLanguages.map((supportedLanguage: string) => {
let value = localizePath(pathname, supportedLanguage);
const flag = showFlag ? localeEmoji(supportedLanguage) + " " : "";
let nativeName = "";
if (
languageMapping &&
languageMapping.hasOwnProperty(supportedLanguage)
) {
nativeName = languageMapping[supportedLanguage];
} else {
nativeName = ISO6991.getNativeName(supportedLanguage);
}
const label = flag + nativeName;
return (
<option value={value} selected={supportedLanguage === currentLanguage}>
{label}
</option>
);
})
}
</select>

21
node_modules/astro-i18next/src/components/Trans.astro generated vendored Normal file
View File

@@ -0,0 +1,21 @@
---
import { interpolate, createReferenceStringFromHTML } from "..";
export interface Props {
i18nKey?: string;
ns?: string;
}
const { i18nKey, ns } = Astro.props;
const referenceString = await Astro.slots.render("default");
let key: string;
if (typeof i18nKey === "string") {
key = i18nKey;
} else {
key = createReferenceStringFromHTML(referenceString);
}
---
<Fragment set:html={interpolate(key, referenceString, ns)} />

3
node_modules/astro-i18next/src/components/index.d.ts generated vendored Normal file
View File

@@ -0,0 +1,3 @@
export { default as Trans } from "./Trans.astro";
export { default as LanguageSelector } from "./LanguageSelector.astro";
export { default as HeadHrefLangs } from "./HeadHrefLangs.astro";

3
node_modules/astro-i18next/src/components/index.ts generated vendored Normal file
View File

@@ -0,0 +1,3 @@
export { default as Trans } from "./Trans.astro";
export { default as LanguageSelector } from "./LanguageSelector.astro";
export { default as HeadHrefLangs } from "./HeadHrefLangs.astro";

89
node_modules/astro-i18next/src/config.ts generated vendored Normal file
View File

@@ -0,0 +1,89 @@
import { AstroI18nextConfig, AstroI18nextGlobal, Routes } from "./types";
export const AstroI18next: AstroI18nextGlobal = {
config: {
defaultLocale: "cimode",
locales: [],
namespaces: "translation",
defaultNamespace: "translation",
load: ["server"],
routes: {},
flatRoutes: {},
showDefaultLocale: false,
trailingSlash: "ignore",
resourcesBasePath: "/locales",
},
};
/* c8 ignore start */
export const setAstroI18nextConfig = (config: AstroI18nextConfig) => {
let flatRoutes = {};
for (const key in config) {
if (key === "routes") {
flatRoutes = flattenRoutes(config[key]);
}
AstroI18next.config[key] = config[key];
}
// @ts-ignore
AstroI18next.config.flatRoutes = flatRoutes;
};
export const astroI18nextConfigBuilder = (
config: AstroI18nextConfig
): AstroI18nextConfig => {
return { ...AstroI18next.config, ...config };
};
/* c8 ignore stop */
/**
* This will create a mapping of translated routes to search them easily.
*
* TODO: render all routes mappings in here (even those not translated),
* this will help simplify utility functions logic
*/
export const flattenRoutes = (
routes: AstroI18nextConfig["routes"],
previous: string[] = [],
translatedPrevious: string[] = [],
prevResult: AstroI18nextConfig["flatRoutes"] = null
): AstroI18nextConfig["flatRoutes"] => {
let result = prevResult || {};
for (const i in routes) {
if (typeof routes[i] === "object" && routes[i] !== null) {
// Recursion on deeper objects
flattenRoutes(
routes[i] as Routes,
[...previous, i],
[
...translatedPrevious,
Object.prototype.hasOwnProperty.call(routes[i], "index")
? routes[i]["index"]
: i,
],
result
);
} else {
let key = "/" + previous.join("/");
let value = "/" + translatedPrevious.join("/");
if (i === "index") {
result[key] = value;
key += "/" + i;
value += "/" + i;
result[key] = value;
} else {
key += "/" + i;
value += "/" + routes[i];
result[key] = value;
}
}
}
return result;
};

185
node_modules/astro-i18next/src/index.ts generated vendored Normal file
View File

@@ -0,0 +1,185 @@
import { AstroIntegration } from "astro";
import { InitOptions } from "i18next";
import { astroI18nextConfigBuilder, setAstroI18nextConfig } from "./config";
import { AstroI18nextConfig, AstroI18nextOptions, Plugins } from "./types";
import {
moveDefaultLocaleToFirstIndex,
deeplyStringifyObject,
getUserConfig,
} from "./utils";
import { resolve } from "pathe";
export default (options?: AstroI18nextOptions): AstroIntegration => {
const customConfigPath = options?.configPath;
return {
name: "astro-i18next",
hooks: {
"astro:config:setup": async ({ config, injectScript }) => {
/**
* 0. Get user config
*/
const userConfig = await getUserConfig(config.root, customConfigPath);
if (customConfigPath && !userConfig?.value) {
throw new Error(
`[astro-i18next]: Could not find a config file at ${JSON.stringify(
customConfigPath
)}. Does the file exist?`
);
}
const astroI18nextConfig: AstroI18nextConfig =
astroI18nextConfigBuilder(userConfig?.value as AstroI18nextConfig);
/**
* 1. Validate and prepare config
*/
if (
!astroI18nextConfig.defaultLocale ||
astroI18nextConfig.defaultLocale === ""
) {
throw new Error(
"[astro-i18next]: you must set a `defaultLocale` in your astro-i18next config!"
);
}
if (!astroI18nextConfig.locales) {
astroI18nextConfig.locales = [astroI18nextConfig.defaultLocale];
}
if (
!astroI18nextConfig.locales.includes(astroI18nextConfig.defaultLocale)
) {
astroI18nextConfig.locales.unshift(astroI18nextConfig.defaultLocale);
}
// make sure to have default locale set as first element in locales (for supportedLngs)
if (
astroI18nextConfig.locales[0] !== astroI18nextConfig.defaultLocale
) {
moveDefaultLocaleToFirstIndex(
astroI18nextConfig.locales as string[],
astroI18nextConfig.defaultLocale
);
}
// add trailingSlash config from astro if not set
astroI18nextConfig.trailingSlash = config.trailingSlash;
if (astroI18nextConfig.load.includes("server")) {
// Build server side i18next config
// set i18next supported and fallback languages (same as locales)
const serverConfig: InitOptions = {
supportedLngs: astroI18nextConfig.locales as string[],
fallbackLng: astroI18nextConfig.locales as string[],
ns: astroI18nextConfig.namespaces,
defaultNS: astroI18nextConfig.defaultNamespace,
initImmediate: false,
backend: {
loadPath: resolve(
`${config.publicDir.pathname}/${astroI18nextConfig.resourcesBasePath}/{{lng}}/{{ns}}.json`
),
},
...astroI18nextConfig.i18nextServer,
};
const defaultI18nextServerPlugins: Plugins = {
fsBackend: "i18next-fs-backend",
};
const i18nextServerPlugins = {
...defaultI18nextServerPlugins,
...astroI18nextConfig.i18nextServerPlugins,
};
let { imports: serverImports, i18nextInit: i18nextInitServer } =
i18nextScriptBuilder(serverConfig, i18nextServerPlugins);
// initializing runtime astro-i18next config
serverImports += `import {initAstroI18next} from "astro-i18next";`;
const astroI18nextInit = `initAstroI18next(${deeplyStringifyObject(
astroI18nextConfig
)});`;
// server side i18next instance
injectScript(
"page-ssr",
serverImports + i18nextInitServer + astroI18nextInit
);
}
if (astroI18nextConfig.load?.includes("client")) {
const clientConfig: InitOptions = {
supportedLngs: astroI18nextConfig.locales as string[],
fallbackLng: astroI18nextConfig.locales as string[],
ns: astroI18nextConfig.namespaces,
defaultNS: astroI18nextConfig.defaultNamespace,
detection: {
order: ["htmlTag"],
caches: [],
},
backend: {
loadPath: `${astroI18nextConfig.resourcesBasePath}/{{lng}}/{{ns}}.json`,
},
...astroI18nextConfig.i18nextClient,
};
const defaultI18nextClientPlugins: Plugins = {
httpBackend: "i18next-http-backend",
LanguageDetector: "i18next-browser-languagedetector",
};
const i18nextClientPlugins = {
...defaultI18nextClientPlugins,
...astroI18nextConfig.i18nextClientPlugins,
};
let { imports: clientImports, i18nextInit: i18nextInitClient } =
i18nextScriptBuilder(clientConfig, i18nextClientPlugins);
// client side i18next instance
injectScript("before-hydration", clientImports + i18nextInitClient);
}
},
},
};
};
const i18nextScriptBuilder = (config: InitOptions, plugins: Plugins) => {
let imports = `import i18next from "i18next";`;
let i18nextInit = "i18next";
if (Object.keys(plugins).length > 0) {
for (const key of Object.keys(plugins)) {
// discard plugin if it does not have import name
if (plugins[key] === null) {
continue;
}
imports += `import ${key} from "${plugins[key]}";`;
i18nextInit += `.use(${key.replace(/[{}]/g, "")})`;
}
}
i18nextInit += `.init(${deeplyStringifyObject(config)});`;
return { imports, i18nextInit };
};
export function initAstroI18next(config: AstroI18nextConfig) {
// init runtime config
setAstroI18nextConfig(config);
}
export { AstroI18next } from "./config";
export {
createReferenceStringFromHTML,
detectLocaleFromPath,
interpolate,
localizePath,
localizeUrl,
} from "./utils";
export { AstroI18nextConfig, AstroI18nextOptions } from "./types";

134
node_modules/astro-i18next/src/types.ts generated vendored Normal file
View File

@@ -0,0 +1,134 @@
import { InitOptions } from "i18next";
export interface AstroI18nextGlobal {
config: AstroI18nextConfig;
}
export interface AstroI18nextOptions {
/**
* Path to your astro-i18next config file
*
* @default 'astro-i18next.config.js'
*/
configPath?: string;
}
export interface Routes {
[segment: string]: string | Record<string, string | Routes>;
}
export interface Plugins {
[importName: string]: string | null;
}
export interface AstroI18nextConfig {
/**
* The default locale for your website.
*
* @default "cimode"
*/
defaultLocale: string;
/**
* The locales that are supported by your website.
*
* @default []
*/
locales: string[];
/**
* String or array of namespaces to load
*
* @default "translation"
*/
namespaces?: string | string[];
/**
* Default namespace used if not passed to the translation function
*
* @default "translation"
*/
defaultNamespace?: string;
/**
* Load i18next on server side only, client side only or both.
*
* @default ["server"]
*/
load?: ("server" | "client")[];
/**
* Set base path for i18next resources.
*
* @default "/locales"
*/
resourcesBasePath?: string;
/**
* i18next server side config. See https://www.i18next.com/overview/configuration-options
*/
i18nextServer?: InitOptions;
/**
* i18next client side config. See https://www.i18next.com/overview/configuration-options
*/
i18nextClient?: InitOptions;
/**
* The translations for your routes.
*
* @default {}
*/
routes?: Routes;
/**
* Generated mappings based on the routes
*
* @default {}
*/
readonly flatRoutes?: Record<string, string>;
/**
* The display behaviour for the URL locale.
*
* @default false
*/
showDefaultLocale?: boolean;
/**
* i18next server side plugins. See https://www.i18next.com/overview/plugins-and-utils
*
* Include the plugins with the key being the import name and the value being the plugin name.
*
* Eg.:
* ```
* {
* "Backend": "i18next-fs-backend",
* }
* ```
*/
i18nextServerPlugins?: Plugins;
/**
* i18next client side plugins. See https://www.i18next.com/overview/plugins-and-utils
*
* Include the plugins with the key being the import name and the value being the plugin name.
*
* Eg.:
* ```
* {
* "{initReactI18next}": "react-i18next",
* }
* ```
*/
i18nextClientPlugins?: Plugins;
/**
* Set the route matching behavior of the dev server. Choose from the following options:
*
* 'always' - Only match URLs that include a trailing slash (ex: "/foo/")
* 'never' - Never match URLs that include a trailing slash (ex: "/foo")
* 'ignore' - Match URLs regardless of whether a trailing "/" exists
*/
trailingSlash?: "always" | "never" | "ignore";
}

387
node_modules/astro-i18next/src/utils.ts generated vendored Normal file
View File

@@ -0,0 +1,387 @@
import i18next, { t } from "i18next";
import { fileURLToPath } from "url";
import load from "@proload/core";
import { AstroI18nextConfig } from "./types";
import typescript from "@proload/plugin-tsm";
import { AstroI18next } from "./config";
/**
* Adapted from astro's tailwind integration:
* https://github.com/withastro/astro/tree/main/packages/integrations/tailwind
*/
/* c8 ignore start */
export const getUserConfig = async (
root: URL,
configPath?: string
): Promise<load.Config<AstroI18nextConfig>> => {
const resolvedRoot = fileURLToPath(root);
let userConfigPath: string | undefined;
if (configPath) {
const configPathWithLeadingSlash = /^\.*\//.test(configPath)
? configPath
: `./${configPath}`;
userConfigPath = fileURLToPath(new URL(configPathWithLeadingSlash, root));
}
load.use([typescript]);
return (await load("astro-i18next", {
mustExist: false,
cwd: resolvedRoot,
filePath: userConfigPath,
})) as load.Config<AstroI18nextConfig>;
};
/* c8 ignore stop */
/**
* Moves the default locale in the first index
*/
export const moveDefaultLocaleToFirstIndex = (
locales: string[],
baseLocale: string
): void => {
const baseLocaleIndex = locales.indexOf(baseLocale);
locales.splice(baseLocaleIndex, 1);
locales.unshift(baseLocale);
};
/**
* Interpolates a localized string (loaded with the i18nKey) to a given reference string.
*/
export const interpolate = (
i18nKey: string,
referenceString: string,
namespace: string | null = null
): string => {
const localizedString = t(i18nKey, { ns: namespace });
if (localizedString === i18nKey) {
console.warn(`WARNING(astro-i18next): missing translation key ${i18nKey}.`);
return referenceString;
}
const tagsRegex = /<([\w\d]+)([^>]*)>/gi;
const referenceStringMatches = referenceString.match(tagsRegex);
if (!referenceStringMatches) {
console.warn(
"WARNING(astro-i18next): default slot does not include any HTML tag to interpolate! You should use the `t` function directly."
);
return localizedString;
}
const referenceTags = [];
referenceStringMatches.forEach((tagNode) => {
const [, name, attributes] = tagsRegex.exec(tagNode);
referenceTags.push({ name, attributes });
// reset regex state
tagsRegex.exec("");
});
let interpolatedString = localizedString;
for (let index = 0; index < referenceTags.length; index++) {
const referencedTag = referenceTags[index];
// Replace opening tags
interpolatedString = interpolatedString.replaceAll(
`<${index}>`,
`<${referencedTag.name}${referencedTag.attributes}>`
);
// Replace closing tags
interpolatedString = interpolatedString.replaceAll(
`</${index}>`,
`</${referencedTag.name}>`
);
}
return interpolatedString;
};
/**
* Creates a reference string from an HTML string. The reverse of interpolate(), for use
* with <Trans> when not explicitly setting a key
*/
export const createReferenceStringFromHTML = (html: string) => {
// Allow these tags to carry through to the output
const allowedTags = ["strong", "br", "em", "i", "b"];
let forbiddenStrings: { key: string; str: string }[] = [];
if (i18next.options) {
forbiddenStrings = [
"keySeparator",
"nsSeparator",
"pluralSeparator",
"contextSeparator",
]
.map((key) => {
return {
key,
str: i18next.options[key],
};
})
.filter(function <T>(val: T | undefined): val is T {
return typeof val !== "undefined";
});
}
const tagsRegex = /<([\w\d]+)([^>]*)>/gi;
const referenceStringMatches = html.match(tagsRegex);
if (!referenceStringMatches) {
console.warn(
"WARNING(astro-i18next): default slot does not include any HTML tag to interpolate! You should use the `t` function directly."
);
return html;
}
const referenceTags = [];
referenceStringMatches.forEach((tagNode) => {
const [, name, attributes] = tagsRegex.exec(tagNode);
referenceTags.push({ name, attributes });
// reset regex state
tagsRegex.exec("");
});
let sanitizedString = html.replace(/\s+/g, " ").trim();
for (let index = 0; index < referenceTags.length; index++) {
const referencedTag = referenceTags[index];
if (
allowedTags.includes(referencedTag.name) &&
referencedTag.attributes.trim().length === 0
) {
continue;
}
sanitizedString = sanitizedString.replaceAll(
new RegExp(`<${referencedTag.name}[^>]*?\\s*\\/>`, "gi"),
`<${index}/>`
);
sanitizedString = sanitizedString.replaceAll(
`<${referencedTag.name}${referencedTag.attributes}>`,
`<${index}>`
);
sanitizedString = sanitizedString.replaceAll(
`</${referencedTag.name}>`,
`</${index}>`
);
}
for (let index = 0; index < forbiddenStrings.length; index++) {
const { key, str } = forbiddenStrings[index];
if (sanitizedString.includes(str)) {
console.warn(
`WARNING(astro-i18next): "${str}" was found in a <Trans> translation key, but it is also used as ${key}. Either explicitly set an i18nKey or change the value of ${key}.`
);
}
}
return sanitizedString;
};
export const handleTrailingSlash = (
path: string,
trailingSlash: AstroI18nextConfig["trailingSlash"]
) => {
if (path === "/") {
return path;
}
switch (trailingSlash) {
case "always":
return path.endsWith("/") ? path : path + "/";
case "never":
return path.replace(/\/$/, "");
default:
return path;
}
};
/**
* Injects the given locale to a path
*/
export const localizePath = (
path: string = "/",
locale: string | null = null,
base: string = import.meta.env.BASE_URL
): string => {
if (!locale) {
locale = i18next.language;
}
let pathSegments = path.split("/").filter((segment) => segment !== "");
const baseSegments = base.split("/").filter((segment) => segment !== "");
if (
JSON.stringify(pathSegments).startsWith(
JSON.stringify(baseSegments).replace(/]+$/, "")
)
) {
// remove base from path
pathSegments.splice(0, baseSegments.length);
}
path = pathSegments.length === 0 ? "" : pathSegments.join("/");
base = baseSegments.length === 0 ? "/" : "/" + baseSegments.join("/") + "/";
const {
flatRoutes,
showDefaultLocale,
defaultLocale,
locales,
trailingSlash,
} = AstroI18next.config;
if (!locales.includes(locale)) {
console.warn(
`WARNING(astro-i18next): "${locale}" locale is not supported, add it to the locales in your astro config.`
);
return handleTrailingSlash(`${base}${path}`, trailingSlash);
}
if (pathSegments.length === 0) {
if (showDefaultLocale) {
return handleTrailingSlash(`${base}${locale}`, trailingSlash);
}
return handleTrailingSlash(
locale === defaultLocale ? base : `${base}${locale}`,
trailingSlash
);
}
// check if the path is not already present in flatRoutes
if (locale === defaultLocale) {
const translatedPathKey = Object.keys(flatRoutes).find(
(key) => flatRoutes[key] === "/" + path
);
if (typeof translatedPathKey !== "undefined") {
pathSegments = translatedPathKey
.split("/")
.filter((segment) => segment !== "");
}
}
// remove locale from pathSegments (if there is any)
for (const locale of locales) {
if (pathSegments[0] === locale) {
pathSegments.shift();
break;
}
}
// prepend the given locale if it's not the base one (unless showDefaultLocale)
if (showDefaultLocale || locale !== defaultLocale) {
pathSegments = [locale, ...pathSegments];
}
const localizedPath = base + pathSegments.join("/");
// is path translated?
if (
Object.prototype.hasOwnProperty.call(
flatRoutes,
localizedPath.replace(/\/$/, "")
)
) {
return handleTrailingSlash(
flatRoutes[localizedPath.replace(/\/$/, "")],
trailingSlash
);
}
return handleTrailingSlash(localizedPath, trailingSlash);
};
/**
* Injects the given locale to a url
*/
export const localizeUrl = (
url: string,
locale: string | null = null,
base: string = import.meta.env.BASE_URL
): string => {
const [protocol, , host, ...path] = url.split("/");
const baseUrl = protocol + "//" + host;
return baseUrl + localizePath(path.join("/"), locale, base);
};
/**
* Returns the locale detected from a given path
*/
export const detectLocaleFromPath = (path: string) => {
// remove all leading slashes
path = path.replace(/^\/+/g, "");
const { defaultLocale, locales } = AstroI18next.config;
const pathSegments = path.split("/");
if (
JSON.stringify(pathSegments) === JSON.stringify([""]) ||
JSON.stringify(pathSegments) === JSON.stringify(["", ""])
) {
return defaultLocale;
}
// make a copy of supported locales
let otherLocales = [...locales];
otherLocales = otherLocales.filter((locale) => locale !== defaultLocale); // remove base locale (first index)
// loop over all locales except the base one
for (const otherLocale of otherLocales) {
if (pathSegments[0] === otherLocale) {
// if the path starts with one of the other locales, then detected!
return otherLocale;
}
}
// return default locale by default
return defaultLocale;
};
export const deeplyStringifyObject = (obj: object | Array<any>): string => {
const isArray = Array.isArray(obj);
let str = isArray ? "[" : "{";
for (const key in obj) {
if (obj[key] === null || obj[key] === undefined) {
continue;
}
let value = null;
// see typeof result: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof#description
switch (typeof obj[key]) {
case "string": {
value = `"${obj[key]}"`;
break;
}
case "number":
case "boolean": {
value = obj[key];
break;
}
case "object": {
value = deeplyStringifyObject(obj[key]);
break;
}
case "function": {
value = obj[key].toString().replace(/\s+/g, " ");
break;
}
case "symbol": {
value = `Symbol("${obj[key].description}")`;
break;
}
/* c8 ignore start */
default:
break;
/* c8 ignore stop */
}
str += isArray ? `${value},` : `"${key}": ${value},`;
}
return `${str}${isArray ? "]" : "}"}`;
};