diff --git a/.changeset/admin-locale-allowlist.md b/.changeset/admin-locale-allowlist.md new file mode 100644 index 0000000000..c1b492a46d --- /dev/null +++ b/.changeset/admin-locale-allowlist.md @@ -0,0 +1,20 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Add a build-time `admin.locales` allowlist for shipping only the admin UI locales a site uses. + +Configure it in your Astro config: + +```js +emdash({ + admin: { locales: ["en"] }, +}); +``` + +- Only the listed locales are bundled into the production build. On a default EmDash install this drops 27 admin message catalogs (≈3 MB) when only English is needed. +- The admin locale switcher and settings panel are filtered to show only the listed locales, so users cannot select a locale whose catalog was excluded. +- Requests for an unlisted locale fall back to the source catalog (English) instead of failing. +- The source locale (`en`) must be included so a fallback catalog is always available. +- Pseudo-localization support is filtered by the allowlist the same way as real locales; add `"pseudo"` only in development when `EMDASH_PSEUDO_LOCALE=1` is set. diff --git a/packages/admin/src/locales/config.ts b/packages/admin/src/locales/config.ts index b5946e5b85..23d9c41fca 100644 --- a/packages/admin/src/locales/config.ts +++ b/packages/admin/src/locales/config.ts @@ -25,6 +25,36 @@ function isValidLocale(code: string): boolean { // Only true in dev when EMDASH_PSEUDO_LOCALE=1 is set. declare const __EMDASH_PSEUDO_LOCALE__: boolean; +// Injected by the EmDash Vite integration from config.admin.locales. +// Undefined when no allowlist is configured. +declare const __EMDASH_ADMIN_LOCALES__: string[] | undefined; + +declare global { + // eslint-disable-next-line no-var -- globalThis augmentation + var __EMDASH_ADMIN_LOCALES__: string[] | undefined; +} + +/** + * Read the configured locale allowlist. + * + * In production the admin package is consumed pre-built, so the Vite + * `__EMDASH_ADMIN_LOCALES__` define is no longer present. The host shell sets + * the same value on `globalThis` (via an inline script in `admin.astro`) so the + * runtime can still filter the locale switcher. In dev the Vite define also + * serves as a fallback. + */ +function getRuntimeLocaleAllowlist(): string[] | undefined { + const globalAllowlist = + typeof globalThis !== "undefined" ? globalThis.__EMDASH_ADMIN_LOCALES__ : undefined; + if (Array.isArray(globalAllowlist) && globalAllowlist.length > 0) { + return globalAllowlist; + } + if (typeof __EMDASH_ADMIN_LOCALES__ !== "undefined" && Array.isArray(__EMDASH_ADMIN_LOCALES__)) { + return __EMDASH_ADMIN_LOCALES__; + } + return undefined; +} + /** * The pseudo locale, injected into the supported list only when * EMDASH_PSEUDO_LOCALE=1 is set. Never available in production. @@ -34,10 +64,20 @@ const PSEUDO_LOCALE = ? LOCALES.find((l) => l.code === "pseudo") : undefined; +const ADMIN_LOCALE_ALLOWLIST = getRuntimeLocaleAllowlist(); +const ACTIVE_ADMIN_LOCALES = + Array.isArray(ADMIN_LOCALE_ALLOWLIST) && ADMIN_LOCALE_ALLOWLIST.length > 0 + ? new Set(ADMIN_LOCALE_ALLOWLIST) + : undefined; + +function isActiveAdminLocale(code: string): boolean { + return ACTIVE_ADMIN_LOCALES === undefined || ACTIVE_ADMIN_LOCALES.has(code); +} + /** Available locales at runtime, validated against BCP 47. */ export const SUPPORTED_LOCALES = [ - ...ENABLED_LOCALES.filter((l) => isValidLocale(l.code)), - ...(PSEUDO_LOCALE ? [PSEUDO_LOCALE] : []), + ...ENABLED_LOCALES.filter((l) => isValidLocale(l.code) && isActiveAdminLocale(l.code)), + ...(PSEUDO_LOCALE && isActiveAdminLocale("pseudo") ? [PSEUDO_LOCALE] : []), ]; export const SUPPORTED_LOCALE_CODES = new Set(SUPPORTED_LOCALES.map((l) => l.code)); diff --git a/packages/core/src/astro/integration/admin-locales.ts b/packages/core/src/astro/integration/admin-locales.ts new file mode 100644 index 0000000000..58e69328b8 --- /dev/null +++ b/packages/core/src/astro/integration/admin-locales.ts @@ -0,0 +1,247 @@ +/** + * Admin locale allowlist helpers. + * + * Build-time utilities for validating a user-supplied list of admin UI + * locales and rewiring locale catalog imports so only the listed locales + * are bundled. Unlisted locales fall back to the source (English) catalog. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; + +import type { Plugin } from "vite"; + +const DEFAULT_LOCALE = "en"; +const SOURCE_MESSAGES_RE = /^\.\/([a-zA-Z0-9-]+)\/messages\.mjs$/; +const DIST_CHUNK_RE = /^\.\/messages-([A-Za-z0-9_-]+)\.js$/; +const LOCALE_LOADERS_ENTRY_RE = + /"\.\/([a-zA-Z0-9-]+)\/messages\.mjs":\s*\(\)\s*=>\s*import\("\.\/messages-([A-Za-z0-9_-]+)\.js"\)/g; + +/** + * Resolve path to the admin package dist directory. + * Used for Vite alias to ensure the package is found in pnpm's isolated node_modules. + */ +export function resolveAdminDist(): string { + const require = createRequire(import.meta.url); + const adminPath = require.resolve("@emdash-cms/admin"); + return dirname(adminPath); +} + +/** + * Resolve path to the admin package source directory. + * In dev mode inside this repo, we alias @emdash-cms/admin to the source so + * Vite processes it directly — giving instant HMR instead of requiring a + * rebuild + restart. External apps should use the built package surface. + */ +export function resolveAdminSource(projectRoot: string): string | undefined { + const require = createRequire(import.meta.url); + const adminPath = require.resolve("@emdash-cms/admin"); + const packageRoot = resolve(dirname(adminPath), ".."); + const repoRoot = resolve(packageRoot, "..", ".."); + const srcEntry = resolve(packageRoot, "src", "index.ts"); + + try { + if (existsSync(srcEntry) && isInside(repoRoot, projectRoot)) { + return resolve(packageRoot, "src"); + } + } catch { + // Not in local repo — fall back to dist + } + return undefined; +} + +/** + * Check whether child is inside parent without relying on simple prefix checks. + */ +function isInside(parent: string, child: string): boolean { + const relativePath = relative(parent, child); + return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); +} + +/** + * Read the locale codes that actually have compiled catalogs in the admin + * package. The returned set drives config-time validation and runtime + * filtering; keeping it filesystem-based means the allowlist automatically + * matches whatever locales the installed admin version ships. + */ +export function getAdminLocaleCodes(adminDistPath: string): Set { + const codes = new Set(); + const localesDir = resolve(adminDistPath, "locales"); + + try { + for (const entry of readdirSync(localesDir)) { + const entryPath = resolve(localesDir, entry); + if (statSync(entryPath).isDirectory() && existsSync(resolve(entryPath, "messages.mjs"))) { + codes.add(entry); + } + } + } catch { + // If the admin package has no compiled locales (shouldn't happen in a + // real install), fall through to an empty set and let validation fail + // clearly if the user supplied an allowlist. + } + + return codes; +} + +/** + * Validate and canonicalize a user-supplied admin locale allowlist. + * + * - Each entry must be a non-empty BCP 47 tag that names a locale the admin + * actually ships. + * - The source locale (English) must be present so the fallback catalog is + * always available. + * - Entries are de-duplicated and canonicalized via Intl.Locale. + */ +export function validateAdminLocales( + input: unknown, + knownCodes: Iterable, + sourceLocale = DEFAULT_LOCALE, +): string[] | undefined { + if (input === undefined) return undefined; + + if (!Array.isArray(input)) { + throw new Error("`admin.locales` must be an array of locale codes."); + } + if (input.length === 0) { + throw new Error("`admin.locales` cannot be empty."); + } + + const known = new Set(knownCodes); + const result: string[] = []; + const seen = new Set(); + + for (const raw of input) { + if (typeof raw !== "string" || raw.trim() === "") { + throw new Error("`admin.locales` entries must be non-empty locale codes."); + } + + let canonical: string; + try { + canonical = new Intl.Locale(raw.trim()).baseName; + } catch { + throw new Error(`Invalid locale code in \`admin.locales\`: "${raw}".`); + } + + if (!known.has(canonical)) { + throw new Error( + `Unknown admin locale: "${canonical}". ` + + `Supported locales are: ${[...known].join(", ")}.`, + ); + } + + if (!seen.has(canonical)) { + seen.add(canonical); + result.push(canonical); + } + } + + if (!seen.has(sourceLocale)) { + throw new Error( + `\`admin.locales\` must include the source locale "${sourceLocale}" ` + + `so the admin has a fallback catalog.`, + ); + } + + return result; +} + +interface LocaleChunkInfo { + /** Map from chunk filename hash (e.g. "gTCuzb6s") to locale code. */ + hashToLocale: Map; + /** Filename of the source (English) chunk, e.g. "messages-gTCuzb6s.js". */ + defaultChunk: string | undefined; +} + +/** + * Parse the admin dist chunk containing `LOCALE_LOADERS` so we know which + * hashed chunk belongs to which locale. This is the only reliable way to map + * the pre-hashed filenames in the built admin back to locale codes. + */ +function buildLocaleChunkMap(adminDistPath: string, defaultLocale: string): LocaleChunkInfo { + const hashToLocale = new Map(); + let defaultChunk: string | undefined; + + try { + for (const file of readdirSync(adminDistPath)) { + if (!file.endsWith(".js") || file.endsWith(".map")) continue; + + const content = readFileSync(resolve(adminDistPath, file), "utf8"); + if (!content.includes("LOCALE_LOADERS")) continue; + + for (const match of content.matchAll(LOCALE_LOADERS_ENTRY_RE)) { + const locale = match[1]!; + const hash = match[2]!; + hashToLocale.set(hash, locale); + if (locale === defaultLocale) { + defaultChunk = `messages-${hash}.js`; + } + } + + // Only one chunk contains the loader map. + break; + } + } catch { + // Leave the map empty; resolution will fall back to Rollup defaults. + } + + return { hashToLocale, defaultChunk }; +} + +interface AdminLocaleResolverOptions { + adminDistPath: string; + adminSourcePath?: string; + locales: string[]; + defaultLocale?: string; +} + +/** + * Vite plugin that redirects admin locale imports and hashed locale chunks + * for locales outside the allowlist back to the source (default) locale. + * + * In dev mode with the admin package aliased to source, imports look like + * `./de/messages.mjs`. In production builds that consume the pre-built admin + * dist, imports look like `./messages-.js`. Both forms are handled. + */ +export function createAdminLocaleResolverPlugin(options: AdminLocaleResolverOptions): Plugin { + const { adminDistPath, adminSourcePath, locales, defaultLocale = DEFAULT_LOCALE } = options; + const allowed = new Set(locales); + const { hashToLocale, defaultChunk } = buildLocaleChunkMap(adminDistPath, defaultLocale); + + const defaultMessagesPath = resolve(adminDistPath, "locales", defaultLocale, "messages.mjs"); + + return { + name: "emdash-admin-locales", + enforce: "pre", + resolveId(source, importer) { + if (!importer) return; + + const isSourceImporter = + adminSourcePath !== undefined && importer.startsWith(adminSourcePath); + const isDistImporter = importer.startsWith(adminDistPath); + if (!isSourceImporter && !isDistImporter) return; + + // Dev-mode import from admin source: ./de/messages.mjs + const sourceMatch = SOURCE_MESSAGES_RE.exec(source); + if (sourceMatch) { + const locale = sourceMatch[1]!; + if (locale === defaultLocale || allowed.has(locale)) { + // Let the Lingui macro plugin (dev source mode) or Rollup handle allowed locales. + return; + } + return defaultMessagesPath; + } + + // Production import from admin dist: ./messages-.js + const chunkMatch = DIST_CHUNK_RE.exec(source); + if (chunkMatch && defaultChunk) { + const hash = chunkMatch[1]!; + const locale = hashToLocale.get(hash); + if (!locale) return; + if (locale === defaultLocale || allowed.has(locale)) return; + return resolve(adminDistPath, defaultChunk); + } + }, + }; +} diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 6ab8564883..53d775d6d3 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -30,6 +30,7 @@ import type { ResolvedPlugin } from "../../plugins/types.js"; import { VERSION } from "../../version.js"; import { setDevTypegenRefresh } from "../dev-typegen.js"; import { local } from "../storage/adapters.js"; +import { getAdminLocaleCodes, resolveAdminDist, validateAdminLocales } from "./admin-locales.js"; import { createDebouncedTypegenRefresh } from "./dev-typegen.js"; import { notoSans } from "./font-provider.js"; import { @@ -427,6 +428,14 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { } } + // Validate and canonicalize the admin locale allowlist against the + // locales actually shipped by the installed admin package. + const adminLocaleCodes = getAdminLocaleCodes(resolveAdminDist()); + resolvedConfig.admin = { + ...config.admin, + locales: validateAdminLocales(config.admin?.locales, adminLocaleCodes), + }; + // Resolved plugins (populated at build time by importing entrypoints) let _resolvedPlugins: ResolvedPlugin[] = []; @@ -578,6 +587,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { resolvedConfig, pluginDescriptors, astroConfig, + adminLocales: resolvedConfig.admin?.locales, }, command, ), diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index da546bef6c..9725f2b9f2 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -631,6 +631,21 @@ export interface EmDashConfig { siteName?: string; /** URL or path to a custom favicon for the admin panel. */ favicon?: string; + /** + * Build-time allowlist of admin UI locales to ship. + * + * Only the listed locales are bundled; requests for other locales fall + * back to the source locale (English). The source locale must always be + * included. + * + * @example + * ```ts + * emdash({ + * admin: { locales: ["en", "de"] }, + * }) + * ``` + */ + locales?: string[]; }; /** diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 98a0b7d90f..568c7d8379 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -7,13 +7,18 @@ import { existsSync } from "node:fs"; import { createRequire } from "node:module"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { AstroConfig } from "astro"; import type { Plugin } from "vite"; import { COMMIT, VERSION } from "../../version.js"; +import { + createAdminLocaleResolverPlugin, + resolveAdminDist, + resolveAdminSource, +} from "./admin-locales.js"; import type { EmDashConfig, PluginDescriptor } from "./runtime.js"; import { VIRTUAL_CONFIG_ID, @@ -108,49 +113,6 @@ function linguiMacroPlugin(adminSourcePath: string, adminDistPath: string): Plug }; } -/** - * Resolve path to the admin package dist directory. - * Used for Vite alias to ensure the package is found in pnpm's isolated node_modules. - */ -function resolveAdminDist(): string { - const require = createRequire(import.meta.url); - const adminPath = require.resolve("@emdash-cms/admin"); - // Return the directory containing the built package (dist/) - return dirname(adminPath); -} - -/** - * Check whether child is inside parent without relying on simple prefix checks. - */ -function isInside(parent: string, child: string): boolean { - const relativePath = relative(parent, child); - return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath)); -} - -/** - * Resolve path to the admin package source directory. - * In dev mode inside this repo, we alias @emdash-cms/admin to the source so - * Vite processes it directly — giving instant HMR instead of requiring a - * rebuild + restart. External apps should use the built package surface. - */ -function resolveAdminSource(projectRoot: string): string | undefined { - const require = createRequire(import.meta.url); - const adminPath = require.resolve("@emdash-cms/admin"); - // dist/index.js -> go up to package root, then into src/ - const packageRoot = resolve(dirname(adminPath), ".."); - const repoRoot = resolve(packageRoot, "..", ".."); - const srcEntry = resolve(packageRoot, "src", "index.ts"); - - try { - if (existsSync(srcEntry) && isInside(repoRoot, projectRoot)) { - return resolve(packageRoot, "src"); - } - } catch { - // Not in local repo — fall back to dist - } - return undefined; -} - function resolveIntegrationShim(fileName: string): string { const currentDir = dirname(fileURLToPath(import.meta.url)); const sourceShimPath = resolve(currentDir, "shims", fileName); @@ -169,6 +131,8 @@ export interface VitePluginOptions { pluginDescriptors: PluginDescriptor[]; /** Astro config */ astroConfig: AstroConfig; + /** Allowed admin UI locales (undefined ships all enabled locales) */ + adminLocales?: string[]; } /** @@ -410,6 +374,7 @@ export function createViteConfig( __EMDASH_PSEUDO_LOCALE__: JSON.stringify( isDev && process.env["EMDASH_PSEUDO_LOCALE"] === "1", ), + __EMDASH_ADMIN_LOCALES__: JSON.stringify(options.adminLocales ?? null), }, resolve: { dedupe: ["@emdash-cms/admin", "react", "react-dom"], @@ -458,6 +423,18 @@ export function createViteConfig( // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Monorepo has both vite 6 (docs) and vite 7 (core). tsgo resolves correctly. plugins: [ createVirtualModulesPlugin(options, command), + // When an allowlist is set, redirect locale imports/chunks for + // excluded locales back to the source (English) catalog. This keeps + // the excluded locale chunks out of the production bundle. + ...(options.adminLocales + ? [ + createAdminLocaleResolverPlugin({ + adminDistPath, + adminSourcePath, + locales: options.adminLocales, + }), + ] + : []), // In dev mode with source alias, compile Lingui macros on the fly // and redirect locale .mjs imports to dist/. // In production, macros are pre-compiled by tsdown in the admin package. diff --git a/packages/core/src/astro/routes/admin.astro b/packages/core/src/astro/routes/admin.astro index 06a6655d0b..16019912ca 100644 --- a/packages/core/src/astro/routes/admin.astro +++ b/packages/core/src/astro/routes/admin.astro @@ -16,14 +16,27 @@ import { Font } from "astro:assets"; export const prerender = false; -import { resolveLocale, loadMessages, getLocaleDir } from "@emdash-cms/admin/locales"; +import { + resolveLocale, + loadMessages, + getLocaleDir, + DEFAULT_LOCALE, +} from "@emdash-cms/admin/locales"; import { getSiteSettingsWithDb } from "#settings/index.js"; -const resolvedLocale = resolveLocale(Astro.request); +const adminConfig = Astro.locals.emdash?.config?.admin; + +// Honour the build-time locale allowlist when deciding the shell's SSR +// direction and language so it matches what the client will render after the +// admin package filters its supported locales at runtime. +const rawLocale = resolveLocale(Astro.request); +const allowedLocales = adminConfig?.locales; +const resolvedLocale = + allowedLocales && allowedLocales.length > 0 && !allowedLocales.includes(rawLocale) + ? DEFAULT_LOCALE + : rawLocale; const resolvedDir = getLocaleDir(resolvedLocale); const messages = await loadMessages(resolvedLocale); - -const adminConfig = Astro.locals.emdash?.config?.admin; const pageTitle = adminConfig?.siteName ? `${adminConfig.siteName} Admin` : "EmDash Admin"; // The admin shell must never be stored by a shared cache. Without an explicit @@ -83,6 +96,7 @@ const faviconType = adminConfig?.favicon ? undefined : siteFaviconType; document.documentElement.setAttribute("data-mode", mode); })(); + {favicon ? ( diff --git a/packages/core/tests/unit/astro/integration/admin-locales.test.ts b/packages/core/tests/unit/astro/integration/admin-locales.test.ts new file mode 100644 index 0000000000..3202598029 --- /dev/null +++ b/packages/core/tests/unit/astro/integration/admin-locales.test.ts @@ -0,0 +1,181 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + createAdminLocaleResolverPlugin, + getAdminLocaleCodes, + resolveAdminDist, + resolveAdminSource, + validateAdminLocales, +} from "../../../../src/astro/integration/admin-locales.js"; + +const adminDistPath = resolveAdminDist(); +const projectRoot = resolve(import.meta.dirname, "../../../../../demos/simple/"); +const adminSourcePath = resolveAdminSource(projectRoot); +const knownCodes = getAdminLocaleCodes(adminDistPath); + +const LOCALE_LOADERS_ENTRY_RE = + /"\.\/([a-zA-Z0-9-]+)\/messages\.mjs":\s*\(\)\s*=>\s*import\("\.\/messages-([A-Za-z0-9_-]+)\.js"\)/g; + +function findNonDefaultChunk(): { hash: string; locale: string; defaultChunk: string } | undefined { + let defaultChunk: string | undefined; + let firstNonDefault: { hash: string; locale: string } | undefined; + + for (const file of readdirSync(adminDistPath)) { + if (!file.endsWith(".js") || file.endsWith(".map")) continue; + const content = readFileSync(resolve(adminDistPath, file), "utf8"); + if (!content.includes("LOCALE_LOADERS")) continue; + + for (const match of content.matchAll(LOCALE_LOADERS_ENTRY_RE)) { + const locale = match[1]!; + const hash = match[2]!; + if (locale === "en") { + defaultChunk = `messages-${hash}.js`; + } else if (!firstNonDefault) { + firstNonDefault = { hash, locale }; + } + } + + // Only one chunk contains the loader map. + break; + } + + if (!defaultChunk || !firstNonDefault) return undefined; + return { ...firstNonDefault, defaultChunk }; +} + +describe("getAdminLocaleCodes", () => { + it("includes the source locale and known enabled locales", () => { + expect(knownCodes.has("en")).toBe(true); + expect(knownCodes.has("de")).toBe(true); + expect(knownCodes.size).toBeGreaterThan(1); + }); +}); + +describe("validateAdminLocales", () => { + it("returns undefined when no allowlist is configured", () => { + expect(validateAdminLocales(undefined, knownCodes)).toBeUndefined(); + }); + + it("returns canonicalized locale codes", () => { + expect(validateAdminLocales(["en", "de"], knownCodes)).toEqual(["en", "de"]); + }); + + it("canonicalizes case", () => { + expect(validateAdminLocales(["en", "en-gb"], knownCodes)).toEqual(["en", "en-GB"]); + }); + + it("rejects unknown codes", () => { + expect(() => validateAdminLocales(["en", "xx"], knownCodes)).toThrow("Unknown admin locale"); + }); + + it("rejects empty entries", () => { + expect(() => validateAdminLocales(["en", ""], knownCodes)).toThrow("non-empty"); + }); + + it("rejects invalid BCP 47 tags", () => { + expect(() => validateAdminLocales(["en", "!!!"], knownCodes)).toThrow("Invalid locale code"); + }); + + it("rejects non-arrays", () => { + expect(() => validateAdminLocales("en", knownCodes)).toThrow("must be an array"); + }); + + it("rejects an empty array", () => { + expect(() => validateAdminLocales([], knownCodes)).toThrow("cannot be empty"); + }); + + it("requires the source locale", () => { + expect(() => validateAdminLocales(["de"], knownCodes)).toThrow( + "must include the source locale", + ); + }); + + it("deduplicates entries", () => { + expect(validateAdminLocales(["en", "de", "en"], knownCodes)).toEqual(["en", "de"]); + }); +}); + +describe("createAdminLocaleResolverPlugin source-mode resolution", () => { + it("returns null for the default locale", () => { + const plugin = createAdminLocaleResolverPlugin({ + adminDistPath, + adminSourcePath, + locales: ["en"], + }); + + expect(plugin.resolveId!("./en/messages.mjs", adminSourcePath!)).toBeUndefined(); + }); + + it("returns null for allowed source locales", () => { + const plugin = createAdminLocaleResolverPlugin({ + adminDistPath, + adminSourcePath, + locales: ["en", "de"], + }); + + expect(plugin.resolveId!("./de/messages.mjs", adminSourcePath!)).toBeUndefined(); + }); + + it("redirects disallowed source locales to the default catalog", () => { + const plugin = createAdminLocaleResolverPlugin({ + adminDistPath, + adminSourcePath, + locales: ["en"], + }); + + expect(plugin.resolveId!("./de/messages.mjs", adminSourcePath!)).toBe( + resolve(adminDistPath, "locales", "en", "messages.mjs"), + ); + }); +}); + +describe("createAdminLocaleResolverPlugin dist-mode resolution", () => { + const found = findNonDefaultChunk(); + const itIfChunk = found ? it : it.skip; + + itIfChunk("redirects disallowed hashed chunks to the default chunk", () => { + const plugin = createAdminLocaleResolverPlugin({ + adminDistPath, + locales: ["en"], + }); + + const importer = resolve(adminDistPath, "LocaleDirectionProvider-PLACEHOLDER.js"); + expect(plugin.resolveId!(`./messages-${found!.hash}.js`, importer)).toBe( + resolve(adminDistPath, found!.defaultChunk), + ); + }); + + itIfChunk("returns null for allowed hashed chunks", () => { + const plugin = createAdminLocaleResolverPlugin({ + adminDistPath, + locales: ["en", found!.locale], + }); + + const importer = resolve(adminDistPath, "LocaleDirectionProvider-PLACEHOLDER.js"); + expect(plugin.resolveId!(`./messages-${found!.hash}.js`, importer)).toBeUndefined(); + }); +}); + +describe("runtime locale allowlist", () => { + it("filters the admin supported locales from globalThis.__EMDASH_ADMIN_LOCALES__", async () => { + const previous = globalThis.__EMDASH_ADMIN_LOCALES__; + globalThis.__EMDASH_ADMIN_LOCALES__ = ["en"]; + try { + // The admin locale config computes its lists at module load time, so + // re-import after mutating the runtime global. + // oxlint-disable-next-line typescript/await-thenable -- vi.resetModules returns Promise + await vi.resetModules(); + const adminLocales = await import("@emdash-cms/admin/locales"); + expect(adminLocales.SUPPORTED_LOCALES.map((l) => l.code)).toEqual(["en"]); + expect(adminLocales.SUPPORTED_LOCALE_CODES.has("en")).toBe(true); + expect(adminLocales.SUPPORTED_LOCALE_CODES.has("de")).toBe(false); + } finally { + globalThis.__EMDASH_ADMIN_LOCALES__ = previous; + // oxlint-disable-next-line typescript/await-thenable -- vi.resetModules returns Promise + await vi.resetModules(); + } + }); +});