astro-loader-i18n
Version:
An Astro content loader for i18n files and folder structures.
467 lines (466 loc) • 18.5 kB
JavaScript
import { file, glob } from "astro/loaders";
import { z } from "astro/zod";
//#region libs/astro-utils-i18n/dist/astro-utils-i18n.js
function getAllUniqueKeys(obj, keys = /* @__PURE__ */ new Set(), ignore) {
Object.entries(obj).forEach(([key, value]) => {
if (!ignore) keys.add(key);
if (value && typeof value === "object") if (Array.isArray(value)) value.forEach((item) => {
if (item && typeof item === "object") if (Array.isArray(item)) getAllUniqueKeys({ array: item }, keys, true);
else getAllUniqueKeys(item, keys);
});
else getAllUniqueKeys(value, keys);
});
return keys;
}
function recursivePruneLocales(obj, locales, locale) {
const result = {};
for (const [key, value] of Object.entries(obj)) if (Array.isArray(value)) result[key] = value.map((item) => {
if (item && typeof item === "object" && !Array.isArray(item)) return recursivePruneLocales(item, locales, locale);
return item;
});
else if (value && typeof value === "object") {
const valueAsRecord = value;
const hasLocales = Object.keys(valueAsRecord).some((k) => locales.includes(k));
let prunedValue;
if (hasLocales) prunedValue = valueAsRecord[locale] ?? void 0;
else prunedValue = recursivePruneLocales(valueAsRecord, locales, locale);
result[key] = prunedValue;
} else result[key] = value;
return result;
}
function pruneLocales(obj, locales, locale) {
if (Object.keys(obj).find((key) => locales.includes(key))) throw new Error("Top-level locales are not allowed");
if (Object.keys(obj).length === 0) return obj;
return recursivePruneLocales(obj, locales, locale);
}
var TRIM_SLASHES_PATTERN = /^\/|\/$/g;
var TRIM_RELATIVE_PATTERN = /^\.{0,2}\/|\/$/g;
var DOUBLE_POINTS_PATTERN = /\.{2}/g;
var DOUBLE_SLASH_PATTERN = /\/{2}/g;
var DIRNAME_PATTERN = /\/[^/]+$/g;
var INDEX_COMMON_TRANSLATION_ID = "index";
var createLocalePattern = (pathLocale) => new RegExp(`(?<=[./])${pathLocale}(?=[./])`, "i");
function joinPath(...paths) {
return paths.filter(Boolean).join("/");
}
/**
* Resolves and joins multiple path segments into a single normalized path.
*
* This function trims leading and trailing slashes from string path segments
* and ensures the resulting path starts with a single forward slash (`/`).
* Non-string segments (e.g., numbers or `undefined`) are included as-is.
*
* @param paths - An array of path segments which can be strings, numbers, or `undefined`.
* @returns A normalized path string starting with a forward slash.
*/
function resolvePath$1(...paths) {
return `/${joinPath(...paths.map((path) => typeof path === "string" ? path.replace(TRIM_SLASHES_PATTERN, "") : path))}`;
}
var trimSlashes = (path) => {
return path === "/" ? path : path.replace(TRIM_SLASHES_PATTERN, "");
};
var trimRelativePath = (path) => {
return path === "/" ? path : path.replace(TRIM_RELATIVE_PATTERN, "");
};
var parseLocale = (path, locales, defaultLocale) => {
if (!path.startsWith("/")) path = `/${path}`;
if (!path.endsWith("/")) path = `${path}/`;
const localePattern = createLocalePattern(`(${locales.join("|")})`);
const locale = path.match(localePattern)?.[0];
return locale ? locale : defaultLocale;
};
var createTranslationId = (path, locale) => {
if (locale) path = path.replace(createLocalePattern(locale), "");
path = path.replace(DOUBLE_POINTS_PATTERN, ".");
path = path.replace(DOUBLE_SLASH_PATTERN, "/");
path = trimSlashes(path);
return path === "" || path === "/" ? INDEX_COMMON_TRANSLATION_ID : path;
};
var createContentPath = (path, base, locale) => {
let basePath = base ? base instanceof URL ? base.pathname : base : void 0;
if (basePath) {
basePath = trimRelativePath(basePath);
const basePathIndex = path.indexOf(basePath);
path = basePathIndex !== -1 ? path.slice(basePathIndex + basePath.length) : path;
}
path = createTranslationId(path, locale);
if (path.includes(INDEX_COMMON_TRANSLATION_ID)) path = path.replace(DIRNAME_PATTERN, "");
path = path.includes("/") ? path.replace(DIRNAME_PATTERN, "") : "";
return path;
};
/**
* Parses a route pattern into an array of route segments.
*
* @param routePattern is a string that templates the a route (e.g. /[...locale]/blog/[slug])
*/
function parseRoutePattern(routePattern) {
const segments = routePattern.split("/").filter((segment) => segment !== "").map((segment) => {
if (segment.startsWith("[") && segment.endsWith("]")) return {
spread: segment.startsWith("[..."),
param: true,
value: segment.replace("[", "").replace("...", "").replace("]", "")
};
return {
spread: false,
param: false,
value: segment
};
});
if (segments.length === 0) return [{
spread: false,
param: false,
value: "/"
}];
return segments;
}
/**
* Constructs a full path by resolving a base path with a route pattern and corresponding segment values.
*
* @param routePattern - An array of route segments that define the structure of the route.
* Each segment can either be a static value or a parameter.
* @param segmentValues - An object mapping parameter names to their corresponding values.
* These values are used to replace parameterized segments in the route pattern.
* @param basePath - The base path to resolve the constructed route against.
*
* @returns The resolved path as a string.
*
* @throws {Error} If a required segment value for a parameterized route segment is missing
* and the segment is not marked as a spread segment.
*/
function buildPath(routePattern, segmentValues, basePath) {
return resolvePath$1(basePath, ...routePattern.map((segment) => {
if (segment.param) {
if (!segmentValues[segment.value] && !segment.spread) throw new Error(`No segment value found for route segment "${segment.value}". Did you forget to provide it?`);
if (segmentValues[segment.value]?.includes("/") && !segment.spread) throw new Error(`The segment value "${segmentValues[segment.value]}" for route segment "${segment.value}" contains a slash. Did you forget to add "..." to the route pattern?`);
return segmentValues[segment.value];
}
return `${segment.value}`;
}));
}
//#endregion
//#region libs/astro-loader-i18n/src/loaders/create-content-loader.ts
var UNDETERMINED_LOCALE = "und";
var IMAGE_IMPORT_PREFIX = "__ASTRO_IMAGE_";
function findAssetImports(obj, assets = []) {
if (typeof obj === "string" && obj.startsWith(IMAGE_IMPORT_PREFIX)) assets.push(obj.replace(IMAGE_IMPORT_PREFIX, ""));
else if (Array.isArray(obj)) obj.forEach((item) => findAssetImports(item, assets));
else if (obj && typeof obj === "object") Object.values(obj).forEach((value) => findAssetImports(value, assets));
return assets;
}
function createContentLoader(loader, base) {
return async (context) => {
if (!context.config.i18n) throw new Error("i18n configuration is missing in your astro config");
const { locales } = context.config.i18n;
const localeCodes = locales.flatMap((locale) => typeof locale === "string" ? locale : locale.codes);
const parseData = context.parseData;
const parseDataProxy = (props) => {
if (!props.filePath) return parseData(props);
const locale = UNDETERMINED_LOCALE;
const translationId = createTranslationId(props.filePath);
const contentPath = createContentPath(props.filePath, base);
const basePath = context.config.base;
return parseData({
...props,
data: {
...props.data,
locale,
translationId,
contentPath,
basePath
}
});
};
context.parseData = parseDataProxy;
const originalStore = context.store;
const idToLocaleIds = /* @__PURE__ */ new Map();
for (const key of originalStore.keys()) {
const lastSlashIndex = key.lastIndexOf("/");
if (lastSlashIndex !== -1) {
const potentialLocale = key.substring(lastSlashIndex + 1);
if (localeCodes.includes(potentialLocale)) {
const originalId = key.substring(0, lastSlashIndex);
const existing = idToLocaleIds.get(originalId) || [];
existing.push(key);
idToLocaleIds.set(originalId, existing);
}
}
}
context.store = new Proxy(originalStore, { get(target, prop, receiver) {
if (prop === "get") return (key) => {
if (idToLocaleIds.has(key)) return;
return target.get(key);
};
if (prop === "set") return (entry) => {
const entryLocales = Array.from(getAllUniqueKeys(entry.data)).filter((key) => localeCodes.includes(key));
if (entryLocales.length === 0) return target.set(entry);
else {
let result = false;
const localeIds = [];
entryLocales.forEach((locale) => {
const entryData = pruneLocales(entry.data, entryLocales, locale);
const localeId = `${entry.id}/${locale}`;
localeIds.push(localeId);
if (target.set({
...entry,
id: localeId,
data: {
...entryData,
locale
}
})) result = true;
if (entry.filePath) {
const assetImports = findAssetImports(entryData);
if (assetImports.length > 0) target.addAssetImports(assetImports, entry.filePath);
}
});
idToLocaleIds.set(entry.id, localeIds);
return result;
}
};
if (prop === "delete") return (key) => {
const localeIds = idToLocaleIds.get(key);
if (localeIds) {
localeIds.forEach((localeId) => target.delete(localeId));
idToLocaleIds.delete(key);
} else target.delete(key);
};
if (prop === "keys") return () => {
const originalIds = /* @__PURE__ */ new Set();
for (const key of target.keys()) {
const lastSlashIndex = key.lastIndexOf("/");
if (lastSlashIndex !== -1) {
const potentialLocale = key.substring(lastSlashIndex + 1);
if (localeCodes.includes(potentialLocale)) {
originalIds.add(key.substring(0, lastSlashIndex));
continue;
}
}
originalIds.add(key);
}
return Array.from(originalIds);
};
const value = Reflect.get(target, prop, receiver);
if (typeof value === "function") return value.bind(target);
return value;
} });
await loader.load(context);
};
}
//#endregion
//#region libs/astro-loader-i18n/src/loaders/i18n-content-loader.ts
/**
* A loader function for handling internationalization (i18n) content in an Astro project.
* This loader processes files matching the specified glob pattern, associates them with locales,
* and augments their data with i18n-specific metadata such as locale, translation ID, and content path.
*
* @param options - Configuration options for the glob pattern to match files.
* @returns A loader object with a custom `load` method for processing i18n content.
*
* @throws Will throw an error if the `i18n` configuration is missing in the Astro config.
*/
function i18nContentLoader(options) {
return {
name: "i18n-content-loader",
load: createContentLoader(glob(options), options.base)
};
}
//#endregion
//#region libs/astro-loader-i18n/src/loaders/i18n-file-loader.ts
/**
* A loader function for handling internationalization (i18n) content in an Astro project.
* This loader processes a single data file (yaml, json) and augments their data with
* i18n-specific metadata such as locale, translation ID, and content path.
*
* @param fileName - The name of the file to be processed.
* @param options - Configuration options for matching the file.
* @returns A loader object with a custom `load` method for processing i18n content.
*
* @throws Will throw an error if the `i18n` configuration is missing in the Astro config.
*/
function i18nFileLoader(fileName, options) {
return {
name: "i18n-file-loader",
load: createContentLoader(file(fileName, options))
};
}
//#endregion
//#region libs/astro-loader-i18n/src/loaders/i18n-loader.ts
/**
* Creates a custom i18n loader for Astro projects.
*
* @param options - Configuration options for the glob loader.
* @returns A loader that integrates i18n functionality into the Astro build process.
*
* @throws If the `i18n` configuration is missing in the Astro config.
*/
function i18nLoader(options) {
const globLoader = glob(options);
return {
name: "i18n-loader",
load: async (context) => {
if (!context.config.i18n) throw new Error("i18n configuration is missing in your astro config");
const { locales, defaultLocale } = context.config.i18n;
const localeCodes = locales.flatMap((locale) => typeof locale === "string" ? locale : locale.codes);
const parseData = context.parseData;
const parseDataProxy = (props) => {
if (!props.filePath) return parseData(props);
const locale = parseLocale(props.filePath, localeCodes, defaultLocale);
const translationId = createTranslationId(props.filePath, locale);
const contentPath = createContentPath(props.filePath, options.base, locale);
const basePath = context.config.base;
return parseData({
...props,
data: {
...props.data,
locale,
translationId,
contentPath,
basePath
}
});
};
context.parseData = parseDataProxy;
await globLoader.load(context);
}
};
}
//#endregion
//#region libs/astro-loader-i18n/src/schemas/i18n-content-schema.ts
/**
* Creates a schema for localized content, supporting multiple locales.
*
* @template T - The Zod schema type for the content.
* @template Locales - A tuple of locale strings.
* @param schema - The base schema for the content.
* @param locales - An array of locale strings to define the localization keys.
* @param partial - Whether the schema should allow partial localization (optional).
* @returns A Zod schema that validates either the localized object or the base schema.
*/
var localized = (schema, locales, partial) => {
const createObjectSchema = () => z.object(locales.reduce((acc, key) => {
acc[key] = schema;
return acc;
}, {}));
return (partial ? createObjectSchema().partial() : createObjectSchema()).or(schema);
};
//#endregion
//#region libs/astro-loader-i18n/src/schemas/i18n-loader-schema.ts
/**
* Schema definition for the i18n loader configuration.
* This schema validates the structure of the i18n loader object.
*
* Properties:
* - `translationId` (string): A unique identifier for the translation.
* - `locale` (string): The locale code (e.g., "en", "fr", "es") for the translation.
* - `contentPath` (string): The path from the contents root to the content file.
* - `basePath` (string): The base directory path of your website. This is used by i18nPropsAndParams to provide paths with a base path.
*/
var i18nLoaderSchema = z.object({
translationId: z.string(),
locale: z.string(),
contentPath: z.string(),
basePath: z.string()
});
var extendI18nLoaderSchema = (schema) => i18nLoaderSchema.extend(schema.shape);
var i18nLoaderEntrySchema = z.object({
data: i18nLoaderSchema,
filePath: z.string().optional()
});
var i18nLoaderCollectionSchema = z.array(i18nLoaderEntrySchema);
function checkI18nLoaderCollection(obj) {
const result = i18nLoaderCollectionSchema.safeParse(obj);
if (!result.success) throw new Error(`Invalid collection entry was provided to astro-i18n-loader. Did you forget to use "extendI18nLoaderSchema" to extend the schema in your "content.config.js" definition? Validation failed with:\n\n${result.error}`);
}
//#endregion
//#region libs/astro-loader-i18n/src/props-and-params/i18n-props-and-params.ts
var defaultConfig = {
localeParamName: "locale",
prefixDefaultLocale: false,
generateSegments: () => ({})
};
function getSegmentTranslations(entry, c) {
const { data } = entry;
if (!c.segmentTranslations[data.locale]) throw new Error(`No slugs found for locale ${data.locale}`);
const currentLocale = !c.prefixDefaultLocale && data.locale === c.defaultLocale ? void 0 : data.locale;
return {
[c.localeParamName]: currentLocale,
...c.segmentTranslations[data.locale],
...c.generateSegments(entry)
};
}
function calculatePropsAndParams(collection, config) {
checkI18nLoaderCollection(collection);
const { routePattern, ...c } = {
...defaultConfig,
...config
};
const route = parseRoutePattern(routePattern);
return collection.map((entry) => {
const { translationId } = entry.data;
const translations = collection.filter((e) => e.data.translationId === translationId).reduce((previous, current) => {
return {
...previous,
[current.data.locale]: buildPath(route, getSegmentTranslations(current, c), current.data.basePath)
};
}, {});
const params = getSegmentTranslations(entry, c);
const translatedPath = buildPath(route, params, entry.data.basePath);
return {
params,
props: {
...entry,
translations,
translatedPath
}
};
});
}
/**
* Processes a collection of entries and generates i18n-related properties and parameters.
*
* @template C - The type of the collection entries.
* @param collection - The collection of entries or an i18n collection.
* @param config - The configuration object for i18n processing.
* @returns An array of objects containing `params` and `props` for each entry.
* @throws {Error} If route segment translations or slug parameters are invalid.
*/
function i18nPropsAndParams(collection, config) {
return calculatePropsAndParams(collection, config);
}
/**
* Processes a collection of entries and generates i18n-related properties.
*
* @template C - The type of the collection entries.
* @param collection - The collection of entries or an i18n collection.
* @param config - The configuration object for i18n processing.
* @returns An array of objects containing the `props` for each entry.
* @throws {Error} If route segment translations or slug parameters are invalid.
*/
function i18nProps(collection, config) {
return calculatePropsAndParams(collection, config).map(({ props }) => {
return props;
});
}
//#endregion
//#region libs/astro-loader-i18n/src/collections/create-i18n-collection.ts
/**
* Creates an internationalization (i18n) collection based on the provided options.
*
* @param options - Configuration options for the i18n collection.
* @returns An array of objects representing the i18n collection for each locale.
*/
function createI18nCollection(options) {
const { locales, routePattern, basePath } = options;
return locales.map((locale) => ({ data: {
locale,
translationId: routePattern,
contentPath: "",
basePath: basePath || ""
} }));
}
//#endregion
//#region libs/astro-loader-i18n/src/astro-loader-i18n.ts
var resolvePath = resolvePath$1;
//#endregion
export { createI18nCollection, extendI18nLoaderSchema, i18nContentLoader, i18nFileLoader, i18nLoader, i18nLoaderSchema, i18nProps, i18nPropsAndParams, localized, resolvePath };
//# sourceMappingURL=astro-loader-i18n.js.map