Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/admin-locale-allowlist.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 42 additions & 2 deletions packages/admin/src/locales/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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));
Expand Down
247 changes: 247 additions & 0 deletions packages/core/src/astro/integration/admin-locales.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const codes = new Set<string>();
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<string>,
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<string>();

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<string, string>;
/** 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<string, string>();
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-<hash>.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-<hash>.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);
}
},
};
}
10 changes: 10 additions & 0 deletions packages/core/src/astro/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -578,6 +587,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
resolvedConfig,
pluginDescriptors,
astroConfig,
adminLocales: resolvedConfig.admin?.locales,
},
command,
),
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};

/**
Expand Down
Loading
Loading