Skip to content

Commit 7d14918

Browse files
fix(admin): apply admin.locales allowlist at runtime so the locale switcher filters in production
EmDash-Run: 5679ccd2-1b27-4891-b6cc-8826e732bae9
1 parent 006d50c commit 7d14918

8 files changed

Lines changed: 554 additions & 50 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/admin": minor
4+
---
5+
6+
Add a build-time `admin.locales` allowlist for shipping only the admin UI locales a site uses.
7+
8+
Configure it in your Astro config:
9+
10+
```js
11+
emdash({
12+
admin: { locales: ["en"] },
13+
});
14+
```
15+
16+
- 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.
17+
- 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.
18+
- Requests for an unlisted locale fall back to the source catalog (English) instead of failing.
19+
- The source locale (`en`) must be included so a fallback catalog is always available.
20+
- 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.

packages/admin/src/locales/config.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,36 @@ function isValidLocale(code: string): boolean {
2525
// Only true in dev when EMDASH_PSEUDO_LOCALE=1 is set.
2626
declare const __EMDASH_PSEUDO_LOCALE__: boolean;
2727

28+
// Injected by the EmDash Vite integration from config.admin.locales.
29+
// Undefined when no allowlist is configured.
30+
declare const __EMDASH_ADMIN_LOCALES__: string[] | undefined;
31+
32+
declare global {
33+
// eslint-disable-next-line no-var -- globalThis augmentation
34+
var __EMDASH_ADMIN_LOCALES__: string[] | undefined;
35+
}
36+
37+
/**
38+
* Read the configured locale allowlist.
39+
*
40+
* In production the admin package is consumed pre-built, so the Vite
41+
* `__EMDASH_ADMIN_LOCALES__` define is no longer present. The host shell sets
42+
* the same value on `globalThis` (via an inline script in `admin.astro`) so the
43+
* runtime can still filter the locale switcher. In dev the Vite define also
44+
* serves as a fallback.
45+
*/
46+
function getRuntimeLocaleAllowlist(): string[] | undefined {
47+
const globalAllowlist =
48+
typeof globalThis !== "undefined" ? globalThis.__EMDASH_ADMIN_LOCALES__ : undefined;
49+
if (Array.isArray(globalAllowlist) && globalAllowlist.length > 0) {
50+
return globalAllowlist;
51+
}
52+
if (typeof __EMDASH_ADMIN_LOCALES__ !== "undefined" && Array.isArray(__EMDASH_ADMIN_LOCALES__)) {
53+
return __EMDASH_ADMIN_LOCALES__;
54+
}
55+
return undefined;
56+
}
57+
2858
/**
2959
* The pseudo locale, injected into the supported list only when
3060
* EMDASH_PSEUDO_LOCALE=1 is set. Never available in production.
@@ -34,10 +64,20 @@ const PSEUDO_LOCALE =
3464
? LOCALES.find((l) => l.code === "pseudo")
3565
: undefined;
3666

67+
const ADMIN_LOCALE_ALLOWLIST = getRuntimeLocaleAllowlist();
68+
const ACTIVE_ADMIN_LOCALES =
69+
Array.isArray(ADMIN_LOCALE_ALLOWLIST) && ADMIN_LOCALE_ALLOWLIST.length > 0
70+
? new Set(ADMIN_LOCALE_ALLOWLIST)
71+
: undefined;
72+
73+
function isActiveAdminLocale(code: string): boolean {
74+
return ACTIVE_ADMIN_LOCALES === undefined || ACTIVE_ADMIN_LOCALES.has(code);
75+
}
76+
3777
/** Available locales at runtime, validated against BCP 47. */
3878
export const SUPPORTED_LOCALES = [
39-
...ENABLED_LOCALES.filter((l) => isValidLocale(l.code)),
40-
...(PSEUDO_LOCALE ? [PSEUDO_LOCALE] : []),
79+
...ENABLED_LOCALES.filter((l) => isValidLocale(l.code) && isActiveAdminLocale(l.code)),
80+
...(PSEUDO_LOCALE && isActiveAdminLocale("pseudo") ? [PSEUDO_LOCALE] : []),
4181
];
4282

4383
export const SUPPORTED_LOCALE_CODES = new Set(SUPPORTED_LOCALES.map((l) => l.code));
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/**
2+
* Admin locale allowlist helpers.
3+
*
4+
* Build-time utilities for validating a user-supplied list of admin UI
5+
* locales and rewiring locale catalog imports so only the listed locales
6+
* are bundled. Unlisted locales fall back to the source (English) catalog.
7+
*/
8+
9+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10+
import { createRequire } from "node:module";
11+
import { dirname, isAbsolute, relative, resolve } from "node:path";
12+
13+
import type { Plugin } from "vite";
14+
15+
const DEFAULT_LOCALE = "en";
16+
const SOURCE_MESSAGES_RE = /^\.\/([a-zA-Z0-9-]+)\/messages\.mjs$/;
17+
const DIST_CHUNK_RE = /^\.\/messages-([A-Za-z0-9_-]+)\.js$/;
18+
const LOCALE_LOADERS_ENTRY_RE =
19+
/"\.\/([a-zA-Z0-9-]+)\/messages\.mjs":\s*\(\)\s*=>\s*import\("\.\/messages-([A-Za-z0-9_-]+)\.js"\)/g;
20+
21+
/**
22+
* Resolve path to the admin package dist directory.
23+
* Used for Vite alias to ensure the package is found in pnpm's isolated node_modules.
24+
*/
25+
export function resolveAdminDist(): string {
26+
const require = createRequire(import.meta.url);
27+
const adminPath = require.resolve("@emdash-cms/admin");
28+
return dirname(adminPath);
29+
}
30+
31+
/**
32+
* Resolve path to the admin package source directory.
33+
* In dev mode inside this repo, we alias @emdash-cms/admin to the source so
34+
* Vite processes it directly — giving instant HMR instead of requiring a
35+
* rebuild + restart. External apps should use the built package surface.
36+
*/
37+
export function resolveAdminSource(projectRoot: string): string | undefined {
38+
const require = createRequire(import.meta.url);
39+
const adminPath = require.resolve("@emdash-cms/admin");
40+
const packageRoot = resolve(dirname(adminPath), "..");
41+
const repoRoot = resolve(packageRoot, "..", "..");
42+
const srcEntry = resolve(packageRoot, "src", "index.ts");
43+
44+
try {
45+
if (existsSync(srcEntry) && isInside(repoRoot, projectRoot)) {
46+
return resolve(packageRoot, "src");
47+
}
48+
} catch {
49+
// Not in local repo — fall back to dist
50+
}
51+
return undefined;
52+
}
53+
54+
/**
55+
* Check whether child is inside parent without relying on simple prefix checks.
56+
*/
57+
function isInside(parent: string, child: string): boolean {
58+
const relativePath = relative(parent, child);
59+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
60+
}
61+
62+
/**
63+
* Read the locale codes that actually have compiled catalogs in the admin
64+
* package. The returned set drives config-time validation and runtime
65+
* filtering; keeping it filesystem-based means the allowlist automatically
66+
* matches whatever locales the installed admin version ships.
67+
*/
68+
export function getAdminLocaleCodes(adminDistPath: string): Set<string> {
69+
const codes = new Set<string>();
70+
const localesDir = resolve(adminDistPath, "locales");
71+
72+
try {
73+
for (const entry of readdirSync(localesDir)) {
74+
const entryPath = resolve(localesDir, entry);
75+
if (statSync(entryPath).isDirectory() && existsSync(resolve(entryPath, "messages.mjs"))) {
76+
codes.add(entry);
77+
}
78+
}
79+
} catch {
80+
// If the admin package has no compiled locales (shouldn't happen in a
81+
// real install), fall through to an empty set and let validation fail
82+
// clearly if the user supplied an allowlist.
83+
}
84+
85+
return codes;
86+
}
87+
88+
/**
89+
* Validate and canonicalize a user-supplied admin locale allowlist.
90+
*
91+
* - Each entry must be a non-empty BCP 47 tag that names a locale the admin
92+
* actually ships.
93+
* - The source locale (English) must be present so the fallback catalog is
94+
* always available.
95+
* - Entries are de-duplicated and canonicalized via Intl.Locale.
96+
*/
97+
export function validateAdminLocales(
98+
input: unknown,
99+
knownCodes: Iterable<string>,
100+
sourceLocale = DEFAULT_LOCALE,
101+
): string[] | undefined {
102+
if (input === undefined) return undefined;
103+
104+
if (!Array.isArray(input)) {
105+
throw new Error("`admin.locales` must be an array of locale codes.");
106+
}
107+
if (input.length === 0) {
108+
throw new Error("`admin.locales` cannot be empty.");
109+
}
110+
111+
const known = new Set(knownCodes);
112+
const result: string[] = [];
113+
const seen = new Set<string>();
114+
115+
for (const raw of input) {
116+
if (typeof raw !== "string" || raw.trim() === "") {
117+
throw new Error("`admin.locales` entries must be non-empty locale codes.");
118+
}
119+
120+
let canonical: string;
121+
try {
122+
canonical = new Intl.Locale(raw.trim()).baseName;
123+
} catch {
124+
throw new Error(`Invalid locale code in \`admin.locales\`: "${raw}".`);
125+
}
126+
127+
if (!known.has(canonical)) {
128+
throw new Error(
129+
`Unknown admin locale: "${canonical}". ` +
130+
`Supported locales are: ${[...known].join(", ")}.`,
131+
);
132+
}
133+
134+
if (!seen.has(canonical)) {
135+
seen.add(canonical);
136+
result.push(canonical);
137+
}
138+
}
139+
140+
if (!seen.has(sourceLocale)) {
141+
throw new Error(
142+
`\`admin.locales\` must include the source locale "${sourceLocale}" ` +
143+
`so the admin has a fallback catalog.`,
144+
);
145+
}
146+
147+
return result;
148+
}
149+
150+
interface LocaleChunkInfo {
151+
/** Map from chunk filename hash (e.g. "gTCuzb6s") to locale code. */
152+
hashToLocale: Map<string, string>;
153+
/** Filename of the source (English) chunk, e.g. "messages-gTCuzb6s.js". */
154+
defaultChunk: string | undefined;
155+
}
156+
157+
/**
158+
* Parse the admin dist chunk containing `LOCALE_LOADERS` so we know which
159+
* hashed chunk belongs to which locale. This is the only reliable way to map
160+
* the pre-hashed filenames in the built admin back to locale codes.
161+
*/
162+
function buildLocaleChunkMap(adminDistPath: string, defaultLocale: string): LocaleChunkInfo {
163+
const hashToLocale = new Map<string, string>();
164+
let defaultChunk: string | undefined;
165+
166+
try {
167+
for (const file of readdirSync(adminDistPath)) {
168+
if (!file.endsWith(".js") || file.endsWith(".map")) continue;
169+
170+
const content = readFileSync(resolve(adminDistPath, file), "utf8");
171+
if (!content.includes("LOCALE_LOADERS")) continue;
172+
173+
for (const match of content.matchAll(LOCALE_LOADERS_ENTRY_RE)) {
174+
const locale = match[1]!;
175+
const hash = match[2]!;
176+
hashToLocale.set(hash, locale);
177+
if (locale === defaultLocale) {
178+
defaultChunk = `messages-${hash}.js`;
179+
}
180+
}
181+
182+
// Only one chunk contains the loader map.
183+
break;
184+
}
185+
} catch {
186+
// Leave the map empty; resolution will fall back to Rollup defaults.
187+
}
188+
189+
return { hashToLocale, defaultChunk };
190+
}
191+
192+
interface AdminLocaleResolverOptions {
193+
adminDistPath: string;
194+
adminSourcePath?: string;
195+
locales: string[];
196+
defaultLocale?: string;
197+
}
198+
199+
/**
200+
* Vite plugin that redirects admin locale imports and hashed locale chunks
201+
* for locales outside the allowlist back to the source (default) locale.
202+
*
203+
* In dev mode with the admin package aliased to source, imports look like
204+
* `./de/messages.mjs`. In production builds that consume the pre-built admin
205+
* dist, imports look like `./messages-<hash>.js`. Both forms are handled.
206+
*/
207+
export function createAdminLocaleResolverPlugin(options: AdminLocaleResolverOptions): Plugin {
208+
const { adminDistPath, adminSourcePath, locales, defaultLocale = DEFAULT_LOCALE } = options;
209+
const allowed = new Set(locales);
210+
const { hashToLocale, defaultChunk } = buildLocaleChunkMap(adminDistPath, defaultLocale);
211+
212+
const defaultMessagesPath = resolve(adminDistPath, "locales", defaultLocale, "messages.mjs");
213+
214+
return {
215+
name: "emdash-admin-locales",
216+
enforce: "pre",
217+
resolveId(source, importer) {
218+
if (!importer) return;
219+
220+
const isSourceImporter =
221+
adminSourcePath !== undefined && importer.startsWith(adminSourcePath);
222+
const isDistImporter = importer.startsWith(adminDistPath);
223+
if (!isSourceImporter && !isDistImporter) return;
224+
225+
// Dev-mode import from admin source: ./de/messages.mjs
226+
const sourceMatch = SOURCE_MESSAGES_RE.exec(source);
227+
if (sourceMatch) {
228+
const locale = sourceMatch[1]!;
229+
if (locale === defaultLocale || allowed.has(locale)) {
230+
// Let the Lingui macro plugin (dev source mode) or Rollup handle allowed locales.
231+
return;
232+
}
233+
return defaultMessagesPath;
234+
}
235+
236+
// Production import from admin dist: ./messages-<hash>.js
237+
const chunkMatch = DIST_CHUNK_RE.exec(source);
238+
if (chunkMatch && defaultChunk) {
239+
const hash = chunkMatch[1]!;
240+
const locale = hashToLocale.get(hash);
241+
if (!locale) return;
242+
if (locale === defaultLocale || allowed.has(locale)) return;
243+
return resolve(adminDistPath, defaultChunk);
244+
}
245+
},
246+
};
247+
}

packages/core/src/astro/integration/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { ResolvedPlugin } from "../../plugins/types.js";
3030
import { VERSION } from "../../version.js";
3131
import { setDevTypegenRefresh } from "../dev-typegen.js";
3232
import { local } from "../storage/adapters.js";
33+
import { getAdminLocaleCodes, resolveAdminDist, validateAdminLocales } from "./admin-locales.js";
3334
import { createDebouncedTypegenRefresh } from "./dev-typegen.js";
3435
import { notoSans } from "./font-provider.js";
3536
import {
@@ -427,6 +428,14 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
427428
}
428429
}
429430

431+
// Validate and canonicalize the admin locale allowlist against the
432+
// locales actually shipped by the installed admin package.
433+
const adminLocaleCodes = getAdminLocaleCodes(resolveAdminDist());
434+
resolvedConfig.admin = {
435+
...config.admin,
436+
locales: validateAdminLocales(config.admin?.locales, adminLocaleCodes),
437+
};
438+
430439
// Resolved plugins (populated at build time by importing entrypoints)
431440
let _resolvedPlugins: ResolvedPlugin[] = [];
432441

@@ -578,6 +587,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
578587
resolvedConfig,
579588
pluginDescriptors,
580589
astroConfig,
590+
adminLocales: resolvedConfig.admin?.locales,
581591
},
582592
command,
583593
),

packages/core/src/astro/integration/runtime.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,21 @@ export interface EmDashConfig {
631631
siteName?: string;
632632
/** URL or path to a custom favicon for the admin panel. */
633633
favicon?: string;
634+
/**
635+
* Build-time allowlist of admin UI locales to ship.
636+
*
637+
* Only the listed locales are bundled; requests for other locales fall
638+
* back to the source locale (English). The source locale must always be
639+
* included.
640+
*
641+
* @example
642+
* ```ts
643+
* emdash({
644+
* admin: { locales: ["en", "de"] },
645+
* })
646+
* ```
647+
*/
648+
locales?: string[];
634649
};
635650

636651
/**

0 commit comments

Comments
 (0)