UNPKG

astro-loader-i18n

Version:

An Astro content loader for i18n files and folder structures.

467 lines (466 loc) 18.5 kB
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