emdash-schema-i18n 0.0.0-stage → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Serhii Parshyn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,177 @@
1
- # Temporary Holding Version
1
+ # emdash-schema-i18n
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Dynamic schema, collections, and block types localization for **EmDash CMS** Admin UI.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/emdash-schema-i18n.svg)](https://www.npmjs.com/package/emdash-schema-i18n)
6
+ [![npm downloads](https://img.shields.io/npm/dm/emdash-schema-i18n.svg)](https://www.npmjs.com/package/emdash-schema-i18n)
7
+ [![license](https://img.shields.io/npm/l/emdash-schema-i18n.svg)](LICENSE)
8
+
9
+ **Author:** Serhii Parshyn (<serhii@parshyn.com>)
10
+
11
+ ---
12
+
13
+ ## Overview
14
+
15
+ In EmDash CMS, metadata for collections, fields, and block types (`label`, `labelSingular`, `description`, `category`) are stored statically in the database schema. When building multilingual admin sites, developers previously had to combine languages with slashes (e.g. `"Кейси / Case studies"` or `"Заголовок / Title"`), which cluttered the interface.
16
+
17
+ `emdash-schema-i18n` solves this seamlessly by dynamically localizing schema metadata based on the administrator's active session language (`emdash-locale` cookie or `Accept-Language` header).
18
+
19
+ ---
20
+
21
+ ## Features
22
+
23
+ - **Direct English-first schema:** Keep all your database models and `seed.json` schemas in clean English without slashes.
24
+ - **Dictionary phrase lookup:** Easily map English phrases to any target language (`"Case studies": "Кейси"`, `"Chapter": "Розділ"`).
25
+ - **Exact path overrides:** Override specific fields when needed (e.g. `collections.case_study.fields.title.label`).
26
+ - **Comprehensive coverage:** Localizes collections, singular labels, field inputs, dropdown options, and block types with all their nested version fields.
27
+ - **Automatic Scaffolding:** Scans schemas and block types to scaffold untranslated strings via CLI.
28
+ - **Zero-config slash fallback:** Still supports automatic splitting of legacy bilingual strings (`"Українська / English"`).
29
+
30
+ ---
31
+
32
+ ## Compatibility & Requirements
33
+
34
+ | Component | Supported Version | Notes |
35
+ | :--- | :--- | :--- |
36
+ | **EmDash CMS (`emdash`)** | `>= 1.0.1` | Tested up to `1.3.0+` |
37
+ | **EmDash Admin (`@emdash-cms/admin`)** | `>= 1.2.0` | Required for admin UI |
38
+ | **Astro** | `>= 6.0.0` | Supports Astro 6 and Astro 7 |
39
+ | **Node.js** | `>= 22.16` | Required by EmDash runtime |
40
+ | **React / React DOM** | `>= 18.0.0` | Fully supports React 18 & 19 |
41
+
42
+ ---
43
+
44
+ ## Installation
45
+
46
+ Install the package from npm using your package manager of choice:
47
+
48
+ ```bash
49
+ # npm
50
+ npm install emdash-schema-i18n
51
+
52
+ # pnpm
53
+ pnpm add emdash-schema-i18n
54
+
55
+ # yarn
56
+ yarn add emdash-schema-i18n
57
+
58
+ # bun
59
+ bun add emdash-schema-i18n
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Setup & Configuration
65
+
66
+ ### 1. In `astro.config.mjs`
67
+
68
+ ```javascript
69
+ import { defineConfig } from "astro/config";
70
+ import emdash from "emdash/astro";
71
+ import { schemaLocalization } from "emdash-schema-i18n";
72
+ import { adminDictionary } from "./src/i18n/admin";
73
+
74
+ export default defineConfig({
75
+ integrations: [
76
+ emdash({
77
+ plugins: [
78
+ schemaLocalization({
79
+ dictionary: adminDictionary,
80
+ }),
81
+ ],
82
+ middleware: {
83
+ outer: "./src/lib/private-access-middleware.mjs",
84
+ },
85
+ }),
86
+ ],
87
+ });
88
+ ```
89
+
90
+ ### 2. In Middleware (`src/lib/private-access-middleware.mjs`)
91
+
92
+ ```javascript
93
+ import {
94
+ resolveAdminLocale,
95
+ localizeManifest,
96
+ localizeBlockTypesResponse,
97
+ localizeCollectionsResponse,
98
+ } from "emdash-schema-i18n";
99
+ import { adminDictionary } from "../i18n/admin.ts";
100
+
101
+ export async function onRequest(context, next) {
102
+ const response = await next();
103
+
104
+ if (response?.status >= 200 && response?.status < 300) {
105
+ const pathname = context.url.pathname;
106
+ const isManifest = pathname === "/_emdash/api/manifest";
107
+ const isBlockTypes = pathname === "/_emdash/api/schema/block-types";
108
+ const isCollections = pathname.startsWith("/_emdash/api/schema/collections");
109
+
110
+ if (isManifest || isBlockTypes || isCollections) {
111
+ const locale = resolveAdminLocale(context.request);
112
+ const data = await response.clone().json();
113
+ let localized = data;
114
+
115
+ if (isManifest) {
116
+ localized = localizeManifest(data, locale, { dictionary: adminDictionary });
117
+ } else if (isBlockTypes) {
118
+ localized = localizeBlockTypesResponse(data, locale, { dictionary: adminDictionary });
119
+ } else if (isCollections) {
120
+ localized = localizeCollectionsResponse(data, locale, { dictionary: adminDictionary });
121
+ }
122
+
123
+ const headers = new Headers(response.headers);
124
+ headers.set("Content-Type", "application/json; charset=utf-8");
125
+ headers.set("Vary", "Cookie, Accept-Language");
126
+ return new Response(JSON.stringify(localized), {
127
+ status: response.status,
128
+ statusText: response.statusText,
129
+ headers,
130
+ });
131
+ }
132
+ }
133
+
134
+ return response;
135
+ }
136
+ ```
137
+
138
+ ### 3. Translation Dictionary (`src/i18n/admin.ts`)
139
+
140
+ ```typescript
141
+ export const adminDictionary = {
142
+ uk: {
143
+ "Case studies": "Кейси",
144
+ "Case study": "Кейс",
145
+ "Disclaimer": "Дисклеймер",
146
+ "Chapter": "Розділ",
147
+ "Before and after": "До/після",
148
+ "Title (H1 and card)": "Заголовок (H1 і картка)",
149
+ "Visibility": "Видимість",
150
+ },
151
+ };
152
+
153
+ export default adminDictionary;
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Scaffolding & CLI
159
+
160
+ To extract all schema and block type strings into a translation dictionary automatically:
161
+
162
+ ```bash
163
+ # Run directly via npx
164
+ npx emdash-schema-i18n extract
165
+
166
+ # Or add to your package.json scripts:
167
+ # "i18n:extract": "emdash-schema-i18n extract"
168
+ npm run i18n:extract
169
+ ```
170
+
171
+ The CLI scans `seed/seed.json` and block types, preserves all existing translations, and appends any newly created untranslated fields.
172
+
173
+ ---
174
+
175
+ ## License
176
+
177
+ [MIT](LICENSE) © Serhii Parshyn
package/bin/cli.mjs ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI for emdash-schema-i18n.
4
+ *
5
+ * Scaffolds and synchronizes admin localization strings from EmDash seeds and schemas.
6
+ *
7
+ * Usage:
8
+ * npx emdash-schema-i18n extract
9
+ * npx emdash-schema-i18n extract --clean-seed
10
+ * npx emdash-schema-i18n extract --seed=seed/seed.json --out=src/i18n/admin.ts
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const args = process.argv.slice(2);
17
+
18
+ if (args.includes("--help") || args.includes("-h")) {
19
+ console.log(`
20
+ Usage: emdash-schema-i18n [command] [options]
21
+
22
+ Commands:
23
+ extract Scaffold/update translation dictionary from schema (default)
24
+
25
+ Options:
26
+ --seed=<path> Path to seed file (default: seed/seed.json or .emdash/seed.json)
27
+ --out=<path> Path to output TS/JS dictionary (default: src/i18n/admin.ts)
28
+ --clean-seed Remove bilingual slashes from the seed file, keeping English
29
+ -h, --help Show help
30
+ `);
31
+ process.exit(0);
32
+ }
33
+
34
+ const cwd = process.cwd();
35
+
36
+ // Resolve seed path
37
+ let seedPath = "";
38
+ for (const arg of args) {
39
+ if (arg.startsWith("--seed=")) seedPath = path.resolve(cwd, arg.split("=")[1]);
40
+ }
41
+ if (!seedPath) {
42
+ const candidates = [
43
+ path.join(cwd, "seed", "seed.json"),
44
+ path.join(cwd, ".emdash", "seed.json"),
45
+ path.join(cwd, "seed.json"),
46
+ ];
47
+ seedPath = candidates.find((p) => fs.existsSync(p)) || candidates[0];
48
+ }
49
+
50
+ // Resolve output path
51
+ let outPath = "";
52
+ for (const arg of args) {
53
+ if (arg.startsWith("--out=")) outPath = path.resolve(cwd, arg.split("=")[1]);
54
+ }
55
+ if (!outPath) {
56
+ outPath = path.join(cwd, "src", "i18n", "admin.ts");
57
+ }
58
+
59
+ const cleanSeed = args.includes("--clean-seed");
60
+
61
+ if (!fs.existsSync(seedPath)) {
62
+ console.error(`Error: Seed file not found at ${seedPath}`);
63
+ process.exit(1);
64
+ }
65
+
66
+ console.log(`[emdash-schema-i18n] Reading schema from: ${seedPath}`);
67
+
68
+ const seedContent = fs.readFileSync(seedPath, "utf-8");
69
+ const seed = JSON.parse(seedContent);
70
+
71
+ // Load existing translations if present
72
+ let existingTranslations = {};
73
+ if (fs.existsSync(outPath)) {
74
+ try {
75
+ const existingContent = fs.readFileSync(outPath, "utf-8");
76
+ const matches = existingContent.matchAll(/"((?:[^"\\]|\\.)+)"\s*:\s*"((?:[^"\\]|\\.)*)"/g);
77
+ for (const match of matches) {
78
+ const key = match[1].replace(/\\"/g, '"');
79
+ const val = match[2].replace(/\\"/g, '"');
80
+ existingTranslations[key] = val;
81
+ }
82
+ } catch (err) {
83
+ console.warn("Notice: Could not parse existing translation file, starting fresh:", err.message);
84
+ }
85
+ }
86
+
87
+ const extracted = new Map();
88
+
89
+ function processString(str, parentObj, key) {
90
+ if (typeof str !== "string" || !str.trim()) return str;
91
+
92
+ if (str.includes(" / ")) {
93
+ const parts = str.split(" / ");
94
+ const uk = parts[0].trim();
95
+ const en = parts.slice(1).join(" / ").trim();
96
+
97
+ if (!extracted.has(en) || !extracted.get(en)) {
98
+ extracted.set(en, existingTranslations[en] || uk);
99
+ }
100
+
101
+ if (cleanSeed && parentObj && key) {
102
+ parentObj[key] = en;
103
+ }
104
+ return en;
105
+ } else {
106
+ const trimmed = str.trim();
107
+ if (!extracted.has(trimmed)) {
108
+ extracted.set(trimmed, existingTranslations[trimmed] || "");
109
+ }
110
+ return trimmed;
111
+ }
112
+ }
113
+
114
+ const TARGET_KEYS = new Set([
115
+ "label",
116
+ "labelSingular",
117
+ "description",
118
+ "category",
119
+ "placeholder",
120
+ "hint",
121
+ ]);
122
+
123
+ function deepWalk(val) {
124
+ if (!val) return;
125
+ if (Array.isArray(val)) {
126
+ for (let i = 0; i < val.length; i++) {
127
+ const item = val[i];
128
+ if (typeof item === "string" && item.includes(" / ")) {
129
+ val[i] = processString(item, val, i);
130
+ } else if (item && typeof item === "object") {
131
+ deepWalk(item);
132
+ }
133
+ }
134
+ } else if (typeof val === "object") {
135
+ for (const [k, v] of Object.entries(val)) {
136
+ if (TARGET_KEYS.has(k) && typeof v === "string") {
137
+ processString(v, val, k);
138
+ } else if (typeof v === "object") {
139
+ deepWalk(v);
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ // 1. Process Block Types
146
+ if (Array.isArray(seed.blockTypes)) {
147
+ deepWalk(seed.blockTypes);
148
+ }
149
+
150
+ // 2. Process Collections
151
+ if (Array.isArray(seed.collections)) {
152
+ deepWalk(seed.collections);
153
+ } else if (seed.collections && typeof seed.collections === "object") {
154
+ deepWalk(seed.collections);
155
+ }
156
+
157
+ // Save cleaned seed if requested
158
+ if (cleanSeed) {
159
+ fs.writeFileSync(seedPath, JSON.stringify(seed, null, 2) + "\n", "utf-8");
160
+ console.log(`[emdash-schema-i18n] Cleaned seed file at ${seedPath} (removed bilingual slashes)`);
161
+ }
162
+
163
+ // Generate output file
164
+ const sortedKeys = Array.from(extracted.keys()).sort((a, b) => a.localeCompare(b));
165
+ let translatedCount = 0;
166
+ let untranslatedCount = 0;
167
+
168
+ const dictLines = sortedKeys.map((enKey) => {
169
+ const ukVal = extracted.get(enKey);
170
+ if (ukVal) {
171
+ translatedCount++;
172
+ return `\t\t${JSON.stringify(enKey)}: ${JSON.stringify(ukVal)},`;
173
+ } else {
174
+ untranslatedCount++;
175
+ return `\t\t${JSON.stringify(enKey)}: "", // TODO: translate`;
176
+ }
177
+ });
178
+
179
+ const isJson = outPath.endsWith(".json");
180
+ let fileContent = "";
181
+
182
+ if (isJson) {
183
+ const jsonObj = { uk: {} };
184
+ for (const key of sortedKeys) {
185
+ jsonObj.uk[key] = extracted.get(key) || "";
186
+ }
187
+ fileContent = JSON.stringify(jsonObj, null, 2) + "\n";
188
+ } else {
189
+ fileContent = `// Generated and managed by emdash-schema-i18n CLI
190
+ // Run "npx emdash-schema-i18n extract" after updating schema or block types.
191
+
192
+ export const adminDictionary: Record<string, Record<string, string>> = {
193
+ uk: {
194
+ ${dictLines.join("\n")}
195
+ },
196
+ };
197
+
198
+ export type AdminLocale = keyof typeof adminDictionary;
199
+ export default adminDictionary;
200
+ `;
201
+ }
202
+
203
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
204
+ fs.writeFileSync(outPath, fileContent, "utf-8");
205
+
206
+ console.log(`[emdash-schema-i18n] Updated: ${outPath}`);
207
+ console.log(`[emdash-schema-i18n] Total strings: ${sortedKeys.length}`);
208
+ console.log(`[emdash-schema-i18n] Translated: ${translatedCount}`);
209
+ if (untranslatedCount > 0) {
210
+ console.log(`[emdash-schema-i18n] Untranslated: ${untranslatedCount} (marked with TODO)`);
211
+ } else {
212
+ console.log(`[emdash-schema-i18n] All strings are translated!`);
213
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "slug": "schema-i18n",
3
+ "name": "Schema & Blocks i18n",
4
+ "version": "1.1.2",
5
+ "description": "Dynamic schema, collections, and block types localization for EmDash CMS Admin UI",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "Serhii Parshyn",
9
+ "email": "serhii@parshyn.com"
10
+ },
11
+ "capabilities": [],
12
+ "allowedHosts": [],
13
+ "storage": {},
14
+ "release": {
15
+ "requires": {
16
+ "env:emdash": ">=1.0.1"
17
+ }
18
+ }
19
+ }
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "emdash-schema-i18n",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.2",
4
+ "description": "Dynamic schema, collections, and block types localization for EmDash CMS Admin UI",
5
+ "author": {
6
+ "name": "Serhii Parshyn",
7
+ "email": "serhii@parshyn.com"
8
+ },
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/NimitzUA/emdash-schema-i18n.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/NimitzUA/emdash-schema-i18n/issues"
16
+ },
17
+ "homepage": "https://github.com/NimitzUA/emdash-schema-i18n#readme",
18
+ "keywords": [
19
+ "emdash",
20
+ "emdash-plugin",
21
+ "i18n",
22
+ "localization",
23
+ "schema",
24
+ "admin-ui"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "bin": {
30
+ "emdash-schema-i18n": "./bin/cli.mjs"
31
+ },
32
+ "type": "module",
33
+ "main": "./src/index.mjs",
34
+ "types": "./src/index.d.mts",
35
+ "exports": {
36
+ ".": {
37
+ "types": "./src/index.d.mts",
38
+ "import": "./src/index.mjs"
39
+ },
40
+ "./middleware": {
41
+ "import": "./src/middleware.mjs"
42
+ },
43
+ "./admin": "./src/admin/index.tsx"
44
+ },
45
+ "files": [
46
+ "src",
47
+ "bin",
48
+ "emdash-plugin.jsonc",
49
+ "README.md",
50
+ "LICENSE"
51
+ ],
52
+ "scripts": {
53
+ "test": "node --test test/*.test.mjs",
54
+ "test:live": "node --test test/e2e-live.test.mjs",
55
+ "example:init": "cd example && npx emdash secrets generate --write .env && npx emdash init && npx emdash seed seed/seed.json --on-conflict=update",
56
+ "example:dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev --root example --host 127.0.0.1 --port 4321",
57
+ "typecheck": "tsc --noEmit"
58
+ },
59
+ "engines": {
60
+ "node": ">=22.16"
61
+ },
62
+ "peerDependencies": {
63
+ "@emdash-cms/admin": ">=1.2.0",
64
+ "emdash": ">=1.0.1",
65
+ "react": ">=18.0.0",
66
+ "react-dom": ">=18.0.0"
67
+ },
68
+ "devDependencies": {
69
+ "@astrojs/node": "^11.1.8",
70
+ "@types/react": "^19.0.0",
71
+ "typescript": "^5.0.0"
72
+ }
73
+ }
@@ -0,0 +1,85 @@
1
+ import * as React from "react";
2
+ import { useLingui } from "@lingui/react";
3
+ import { localizeString } from "../localizer.mjs";
4
+
5
+ const DEFAULT_SEPARATOR = " / ";
6
+
7
+ export interface LocalizeTextOptions {
8
+ separator?: string;
9
+ key?: string;
10
+ dictionary?: Record<string, Record<string, string>>;
11
+ }
12
+
13
+ /**
14
+ * Hook to retrieve the active admin UI locale.
15
+ */
16
+ export function useAdminLocale(): string {
17
+ try {
18
+ const { i18n } = useLingui();
19
+ return i18n.locale || "en";
20
+ } catch {
21
+ return "en";
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Hook to localize a string, phrase or delimited label using the active admin locale.
27
+ */
28
+ export function useLocalizedText(
29
+ text: unknown,
30
+ options: LocalizeTextOptions | string = DEFAULT_SEPARATOR,
31
+ ): string {
32
+ const locale = useAdminLocale();
33
+
34
+ return React.useMemo(() => {
35
+ const opts: LocalizeTextOptions =
36
+ typeof options === "string" ? { separator: options } : options;
37
+ return String(localizeString(text, locale, opts) ?? "");
38
+ }, [text, locale, options]);
39
+ }
40
+
41
+ /**
42
+ * Component that renders localized text for the active admin locale.
43
+ */
44
+ export function LocalizedText({
45
+ children,
46
+ separator = DEFAULT_SEPARATOR,
47
+ dictionary,
48
+ translationKey,
49
+ }: {
50
+ children: string;
51
+ separator?: string;
52
+ dictionary?: Record<string, Record<string, string>>;
53
+ translationKey?: string;
54
+ }) {
55
+ const localized = useLocalizedText(children, {
56
+ separator,
57
+ dictionary,
58
+ key: translationKey,
59
+ });
60
+ return <>{localized}</>;
61
+ }
62
+
63
+ // Auto-reload watcher on admin UI locale change
64
+ if (
65
+ typeof window !== "undefined" &&
66
+ typeof window.MutationObserver !== "undefined" &&
67
+ !(window as any).__emdash_i18n_lang_watcher__
68
+ ) {
69
+ (window as any).__emdash_i18n_lang_watcher__ = true;
70
+ let currentLang = document.documentElement.getAttribute("lang") || "";
71
+ const observer = new MutationObserver(() => {
72
+ const newLang = document.documentElement.getAttribute("lang") || "";
73
+ if (newLang && currentLang && newLang !== currentLang) {
74
+ currentLang = newLang;
75
+ setTimeout(() => {
76
+ window.location.reload();
77
+ }, 60);
78
+ }
79
+ });
80
+ observer.observe(document.documentElement, {
81
+ attributes: true,
82
+ attributeFilter: ["lang"],
83
+ });
84
+ }
85
+
@@ -0,0 +1,73 @@
1
+ import type { PluginDescriptor } from "emdash";
2
+
3
+ export interface SchemaI18nOptions {
4
+ separator?: string;
5
+ defaultLocale?: string;
6
+ dictionary?: Record<string, Record<string, string>>;
7
+ autoReloadOnLocaleChange?: boolean;
8
+ }
9
+
10
+ export type CustomLocalizationOptions = SchemaI18nOptions;
11
+
12
+ export declare const RELOAD_WATCHER_SCRIPT: string;
13
+
14
+ export declare function schemaLocalization(
15
+ options?: SchemaI18nOptions,
16
+ ): PluginDescriptor;
17
+
18
+ export declare const customLocalization: typeof schemaLocalization;
19
+ export declare const schemaI18n: typeof schemaLocalization;
20
+
21
+ export declare function createPlugin(
22
+ options?: SchemaI18nOptions,
23
+ ): PluginDescriptor;
24
+
25
+ export declare function createLocalizationMiddleware(
26
+ options?: SchemaI18nOptions,
27
+ ): (context: any, next: () => Promise<Response>) => Promise<Response>;
28
+
29
+ export declare function resolveAdminLocale(
30
+ request: Request,
31
+ defaultLocale?: string,
32
+ ): string;
33
+
34
+ export declare function normalizeLocale(code: string): string;
35
+
36
+ export declare function localizeString(
37
+ value: unknown,
38
+ locale: string,
39
+ options?: {
40
+ key?: string;
41
+ dictionary?: Record<string, Record<string, string>>;
42
+ separator?: string;
43
+ },
44
+ ): unknown;
45
+
46
+ export declare function localizeManifest<T = any>(
47
+ manifest: T,
48
+ locale: string,
49
+ options?: {
50
+ dictionary?: Record<string, Record<string, string>>;
51
+ separator?: string;
52
+ },
53
+ ): T;
54
+
55
+ export declare function localizeBlockTypesResponse<T = any>(
56
+ data: T,
57
+ locale: string,
58
+ options?: {
59
+ dictionary?: Record<string, Record<string, string>>;
60
+ separator?: string;
61
+ },
62
+ ): T;
63
+
64
+ export declare function localizeCollectionsResponse<T = any>(
65
+ data: T,
66
+ locale: string,
67
+ options?: {
68
+ dictionary?: Record<string, Record<string, string>>;
69
+ separator?: string;
70
+ },
71
+ ): T;
72
+
73
+ export default schemaLocalization;
package/src/index.mjs ADDED
@@ -0,0 +1,66 @@
1
+ import { definePlugin } from "emdash";
2
+ import { createLocalizationMiddleware, RELOAD_WATCHER_SCRIPT } from "./middleware.mjs";
3
+ import {
4
+ localizeManifest,
5
+ localizeBlockTypesResponse,
6
+ localizeCollectionsResponse,
7
+ localizeString,
8
+ resolveAdminLocale,
9
+ } from "./localizer.mjs";
10
+
11
+ const pluginId = "schema-i18n";
12
+ const pluginName = "Schema & Blocks i18n";
13
+ const version = "1.1.1";
14
+ const description = "Dynamic schema, collections, and block types localization for EmDash CMS Admin UI";
15
+ const author = "Serhii Parshyn";
16
+ const packageEntrypoint = "emdash-schema-i18n";
17
+ const adminEntrypoint = "emdash-schema-i18n/admin";
18
+
19
+ /**
20
+ * Native plugin descriptor factory for EmDash.
21
+ *
22
+ * @param {object} [options]
23
+ * @returns {import("emdash").PluginDescriptor}
24
+ */
25
+ export function schemaLocalization(options = {}) {
26
+ return {
27
+ id: pluginId,
28
+ name: pluginName,
29
+ version,
30
+ description,
31
+ author,
32
+ format: "native",
33
+ capabilities: [],
34
+ entrypoint: packageEntrypoint,
35
+ adminEntry: adminEntrypoint,
36
+ options,
37
+ };
38
+ }
39
+
40
+ // Backwards-compatibility and alternative aliases
41
+ export const customLocalization = schemaLocalization;
42
+ export const schemaI18n = schemaLocalization;
43
+
44
+ export function createPlugin(options = {}) {
45
+ return definePlugin({
46
+ id: pluginId,
47
+ name: pluginName,
48
+ version,
49
+ capabilities: [],
50
+ admin: {
51
+ entry: adminEntrypoint,
52
+ },
53
+ });
54
+ }
55
+
56
+ export {
57
+ createLocalizationMiddleware,
58
+ RELOAD_WATCHER_SCRIPT,
59
+ localizeManifest,
60
+ localizeBlockTypesResponse,
61
+ localizeCollectionsResponse,
62
+ localizeString,
63
+ resolveAdminLocale,
64
+ };
65
+
66
+ export default schemaLocalization;
@@ -0,0 +1,36 @@
1
+ export interface LocalizeOptions {
2
+ key?: string;
3
+ dictionary?: Record<string, Record<string, string>>;
4
+ separator?: string;
5
+ }
6
+
7
+ export declare function normalizeLocale(code: string): string;
8
+
9
+ export declare function resolveAdminLocale(
10
+ request: Request,
11
+ defaultLocale?: string,
12
+ ): string;
13
+
14
+ export declare function localizeString(
15
+ value: unknown,
16
+ locale: string,
17
+ options?: LocalizeOptions,
18
+ ): unknown;
19
+
20
+ export declare function localizeManifest<T = any>(
21
+ manifest: T,
22
+ locale: string,
23
+ options?: LocalizeOptions,
24
+ ): T;
25
+
26
+ export declare function localizeBlockTypesResponse<T = any>(
27
+ data: T,
28
+ locale: string,
29
+ options?: LocalizeOptions,
30
+ ): T;
31
+
32
+ export declare function localizeCollectionsResponse<T = any>(
33
+ data: T,
34
+ locale: string,
35
+ options?: LocalizeOptions,
36
+ ): T;
@@ -0,0 +1,450 @@
1
+ /**
2
+ * String and Manifest localization engine for EmDash.
3
+ */
4
+
5
+ const DEFAULT_SEPARATOR = " / ";
6
+
7
+ /**
8
+ * Resolves the admin UI locale from an incoming HTTP Request.
9
+ *
10
+ * Precedence:
11
+ * 1. Query parameter `locale`
12
+ * 2. Cookie `emdash-locale` (standard EmDash admin locale cookie)
13
+ * 3. Header `Accept-Language`
14
+ * 4. Fallback default (`en`)
15
+ *
16
+ * @param {Request} request
17
+ * @param {string} [defaultLocale="en"]
18
+ * @returns {string}
19
+ */
20
+ export function resolveAdminLocale(request, defaultLocale = "en") {
21
+ if (!request) return defaultLocale;
22
+
23
+ try {
24
+ const url = new URL(request.url);
25
+ const queryLocale = url.searchParams.get("locale")?.trim();
26
+ if (queryLocale) return normalizeLocale(queryLocale);
27
+ } catch {
28
+ // Ignore invalid URLs
29
+ }
30
+
31
+ const cookieHeader = request.headers.get("cookie") || "";
32
+ const cookieMatch = cookieHeader.match(/(?:^|;\s*)emdash-locale=([^;]+)/);
33
+ if (cookieMatch?.[1]) {
34
+ return normalizeLocale(decodeURIComponent(cookieMatch[1].trim()));
35
+ }
36
+
37
+ const acceptLanguage = request.headers.get("accept-language");
38
+ if (acceptLanguage) {
39
+ const firstLang = acceptLanguage.split(",")[0]?.split(";")[0]?.trim();
40
+ if (firstLang) return normalizeLocale(firstLang);
41
+ }
42
+
43
+ return defaultLocale;
44
+ }
45
+
46
+ /**
47
+ * Normalizes locale code to base language (e.g. "uk-UA" -> "uk", "en-US" -> "en").
48
+ *
49
+ * @param {string} code
50
+ * @returns {string}
51
+ */
52
+ export function normalizeLocale(code) {
53
+ if (!code) return "en";
54
+ return code.toLowerCase().split("-")[0] || code.toLowerCase();
55
+ }
56
+
57
+ /**
58
+ * Localizes a single string or object value.
59
+ *
60
+ * Supported formats:
61
+ * 1. Delimited string: `"Українська / English"`
62
+ * 2. Multi-language object: `{ uk: "Українська", en: "English" }`
63
+ * 3. Explicit dictionary override: `options.dictionary[locale][key]`
64
+ *
65
+ * @param {unknown} value
66
+ * @param {string} locale
67
+ * @param {object} [options]
68
+ * @param {string} [options.key]
69
+ * @param {Record<string, Record<string, string>>} [options.dictionary]
70
+ * @param {string} [options.separator=" / "]
71
+ * @returns {unknown}
72
+ */
73
+ export function localizeString(value, locale, options = {}) {
74
+ const currentLocale = normalizeLocale(locale);
75
+ const { key, dictionary, separator = DEFAULT_SEPARATOR } = options;
76
+
77
+ // 1. Check explicit dictionary by key (e.g. "collections.case_study.label")
78
+ if (key && dictionary?.[currentLocale]?.[key]) {
79
+ return dictionary[currentLocale][key];
80
+ }
81
+
82
+ // 2. Check dictionary by string value (e.g. "Case studies" -> "Кейси")
83
+ if (typeof value === "string" && dictionary?.[currentLocale]?.[value]) {
84
+ return dictionary[currentLocale][value];
85
+ }
86
+
87
+ // 2. Object with language keys: { uk: "...", en: "..." }
88
+ if (value && typeof value === "object" && !Array.isArray(value)) {
89
+ const obj = /** @type {Record<string, any>} */ (value);
90
+ if (obj[currentLocale] !== undefined) return obj[currentLocale];
91
+ if (obj.en !== undefined) return obj.en;
92
+ if (obj.uk !== undefined) return obj.uk;
93
+ return value;
94
+ }
95
+
96
+ if (typeof value !== "string") return value;
97
+
98
+ // 3. Delimited string: "Українська / English"
99
+ if (value.includes(separator)) {
100
+ const parts = value.split(separator);
101
+ if (parts.length === 2) {
102
+ const [uk, en] = parts;
103
+ return currentLocale.startsWith("uk") ? uk.trim() : en.trim();
104
+ }
105
+ // If more than 2 parts, uk is 0, en is 1, etc.
106
+ if (currentLocale.startsWith("uk")) return parts[0].trim();
107
+ if (parts.length > 1) return parts[1].trim();
108
+ }
109
+
110
+ return value;
111
+ }
112
+
113
+ /**
114
+ * Recursively localizes EmDash Manifest collections, fields, and block types.
115
+ *
116
+ * @param {any} manifest
117
+ * @param {string} locale
118
+ * @param {object} [options]
119
+ * @param {Record<string, Record<string, string>>} [options.dictionary]
120
+ * @param {string} [options.separator=" / "]
121
+ * @returns {any}
122
+ */
123
+ export function localizeManifest(manifest, locale, options = {}) {
124
+ if (!manifest || typeof manifest !== "object") return manifest;
125
+
126
+ // Handle wrapped API envelope: { success: true, data: { collections: ... } }
127
+ if (manifest.data && typeof manifest.data === "object" && !Array.isArray(manifest.data)) {
128
+ return {
129
+ ...manifest,
130
+ data: localizeManifest(manifest.data, locale, options),
131
+ };
132
+ }
133
+
134
+ // Shallow clone top-level
135
+ const localized = { ...manifest };
136
+
137
+ // 1. Localize Collections
138
+ if (localized.collections && typeof localized.collections === "object") {
139
+ const newCollections = {};
140
+ for (const [colSlug, colDef] of Object.entries(localized.collections)) {
141
+ if (!colDef || typeof colDef !== "object") {
142
+ newCollections[colSlug] = colDef;
143
+ continue;
144
+ }
145
+
146
+ const col = { ...colDef };
147
+ col.label = localizeString(col.label, locale, {
148
+ key: `collections.${colSlug}.label`,
149
+ ...options,
150
+ });
151
+ if (col.labelSingular) {
152
+ col.labelSingular = localizeString(col.labelSingular, locale, {
153
+ key: `collections.${colSlug}.labelSingular`,
154
+ ...options,
155
+ });
156
+ }
157
+ if (col.description) {
158
+ col.description = localizeString(col.description, locale, {
159
+ key: `collections.${colSlug}.description`,
160
+ ...options,
161
+ });
162
+ }
163
+
164
+ // Localize collection fields
165
+ if (col.fields && typeof col.fields === "object" && !Array.isArray(col.fields)) {
166
+ const newFields = {};
167
+ for (const [fSlug, fieldDef] of Object.entries(col.fields)) {
168
+ if (!fieldDef || typeof fieldDef !== "object") {
169
+ newFields[fSlug] = fieldDef;
170
+ continue;
171
+ }
172
+ newFields[fSlug] = localizeSingleField(fieldDef, `collections.${colSlug}`, locale, options);
173
+ }
174
+ col.fields = newFields;
175
+ } else if (Array.isArray(col.fields)) {
176
+ col.fields = col.fields.map((fld) => localizeSingleField(fld, `collections.${colSlug}`, locale, options));
177
+ }
178
+
179
+ newCollections[colSlug] = col;
180
+ }
181
+ localized.collections = newCollections;
182
+ }
183
+
184
+ // 2. Localize Block Types
185
+ if (Array.isArray(localized.blockTypes)) {
186
+ localized.blockTypes = localized.blockTypes.map((bt) => localizeSingleBlockType(bt, locale, options));
187
+ }
188
+
189
+ // 3. Localize Menus
190
+ if (Array.isArray(localized.menus)) {
191
+ localized.menus = localized.menus.map((menu) => {
192
+ if (!menu || typeof menu !== "object") return menu;
193
+ const m = { ...menu };
194
+ m.name = localizeString(m.name, locale, {
195
+ key: `menus.${m.slug || m.id}.name`,
196
+ ...options,
197
+ });
198
+ return m;
199
+ });
200
+ }
201
+
202
+ return localized;
203
+ }
204
+
205
+ /**
206
+ * Localizes block types list / response (GET /_emdash/api/schema/block-types).
207
+ *
208
+ * @param {any} data
209
+ * @param {string} locale
210
+ * @param {object} [options]
211
+ * @returns {any}
212
+ */
213
+ export function localizeBlockTypesResponse(data, locale, options = {}) {
214
+ if (!data || typeof data !== "object") return data;
215
+
216
+ if (data.data && typeof data.data === "object" && !Array.isArray(data.data)) {
217
+ return {
218
+ ...data,
219
+ data: localizeBlockTypesResponse(data.data, locale, options),
220
+ };
221
+ }
222
+
223
+ if (Array.isArray(data.items)) {
224
+ return {
225
+ ...data,
226
+ items: data.items.map((bt) => localizeSingleBlockType(bt, locale, options)),
227
+ };
228
+ }
229
+
230
+ if (Array.isArray(data)) {
231
+ return data.map((bt) => localizeSingleBlockType(bt, locale, options));
232
+ }
233
+
234
+ return data;
235
+ }
236
+
237
+ function localizeSingleBlockType(bt, locale, options = {}) {
238
+ if (!bt || typeof bt !== "object") return bt;
239
+ const block = { ...bt };
240
+ const btSlug = block.slug || block.type || block.name || "unknown";
241
+
242
+ block.label = localizeString(block.label, locale, {
243
+ key: `blockTypes.${btSlug}.label`,
244
+ ...options,
245
+ });
246
+ if (block.description) {
247
+ block.description = localizeString(block.description, locale, {
248
+ key: `blockTypes.${btSlug}.description`,
249
+ ...options,
250
+ });
251
+ }
252
+ if (block.category) {
253
+ block.category = localizeString(block.category, locale, {
254
+ key: `blockTypes.${btSlug}.category`,
255
+ ...options,
256
+ });
257
+ }
258
+
259
+ if (Array.isArray(block.fields)) {
260
+ block.fields = block.fields.map((fld) => localizeSingleField(fld, `blockTypes.${btSlug}`, locale, options));
261
+ }
262
+
263
+ if (Array.isArray(block.versions)) {
264
+ block.versions = block.versions.map((ver) => {
265
+ if (!ver || !Array.isArray(ver.fields)) return ver;
266
+ return {
267
+ ...ver,
268
+ fields: ver.fields.map((fld) => localizeSingleField(fld, `blockTypes.${btSlug}`, locale, options)),
269
+ };
270
+ });
271
+ }
272
+
273
+ return block;
274
+ }
275
+
276
+ function localizeSingleField(fld, parentKey, locale, options = {}) {
277
+ if (!fld || typeof fld !== "object") return fld;
278
+ const field = { ...fld };
279
+ const fSlug = field.slug || "unknown";
280
+
281
+ if (field.label) {
282
+ field.label = localizeString(field.label, locale, {
283
+ key: `${parentKey}.fields.${fSlug}.label`,
284
+ ...options,
285
+ });
286
+ }
287
+ if (field.labelSingular) {
288
+ field.labelSingular = localizeString(field.labelSingular, locale, {
289
+ key: `${parentKey}.fields.${fSlug}.labelSingular`,
290
+ ...options,
291
+ });
292
+ }
293
+ if (field.description) {
294
+ field.description = localizeString(field.description, locale, {
295
+ key: `${parentKey}.fields.${fSlug}.description`,
296
+ ...options,
297
+ });
298
+ }
299
+ if (field.placeholder) {
300
+ field.placeholder = localizeString(field.placeholder, locale, {
301
+ key: `${parentKey}.fields.${fSlug}.placeholder`,
302
+ ...options,
303
+ });
304
+ }
305
+ if (field.hint) {
306
+ field.hint = localizeString(field.hint, locale, {
307
+ key: `${parentKey}.fields.${fSlug}.hint`,
308
+ ...options,
309
+ });
310
+ }
311
+
312
+ // Options for select / multiSelect
313
+ if (Array.isArray(field.options)) {
314
+ field.options = field.options.map((opt, idx) => {
315
+ if (typeof opt === "string") {
316
+ return localizeString(opt, locale, {
317
+ key: `${parentKey}.fields.${fSlug}.options.${idx}`,
318
+ ...options,
319
+ });
320
+ }
321
+ if (opt && typeof opt === "object") {
322
+ return {
323
+ ...opt,
324
+ label: opt.label
325
+ ? localizeString(opt.label, locale, {
326
+ key: `${parentKey}.fields.${fSlug}.options.${opt.value || idx}.label`,
327
+ ...options,
328
+ })
329
+ : opt.label,
330
+ };
331
+ }
332
+ return opt;
333
+ });
334
+ }
335
+
336
+ if (field.validation && Array.isArray(field.validation.options)) {
337
+ field.validation = {
338
+ ...field.validation,
339
+ options: field.validation.options.map((opt, idx) => {
340
+ if (typeof opt === "string") {
341
+ return localizeString(opt, locale, {
342
+ key: `${parentKey}.fields.${fSlug}.options.${idx}`,
343
+ ...options,
344
+ });
345
+ }
346
+ if (opt && typeof opt === "object" && opt.label) {
347
+ return {
348
+ ...opt,
349
+ label: localizeString(opt.label, locale, {
350
+ key: `${parentKey}.fields.${fSlug}.options.${opt.value || idx}.label`,
351
+ ...options,
352
+ }),
353
+ };
354
+ }
355
+ return opt;
356
+ }),
357
+ };
358
+ }
359
+
360
+ // Subfields (repeaters, groups)
361
+ if (Array.isArray(field.fields)) {
362
+ field.fields = field.fields.map((sub) => localizeSingleField(sub, `${parentKey}.fields.${fSlug}`, locale, options));
363
+ }
364
+ if (field.validation && Array.isArray(field.validation.subFields)) {
365
+ field.validation = {
366
+ ...field.validation,
367
+ subFields: field.validation.subFields.map((sub) =>
368
+ localizeSingleField(sub, `${parentKey}.fields.${fSlug}`, locale, options),
369
+ ),
370
+ };
371
+ }
372
+
373
+ // Block types inside blocks fields (e.g. case_study.fields.blocks.blockTypes)
374
+ if (Array.isArray(field.blockTypes)) {
375
+ field.blockTypes = field.blockTypes.map((bt) => localizeSingleBlockType(bt, locale, options));
376
+ }
377
+
378
+ return field;
379
+ }
380
+
381
+ /**
382
+ * Localizes collections list / response (GET /_emdash/api/schema/collections).
383
+ *
384
+ * @param {any} data
385
+ * @param {string} locale
386
+ * @param {object} [options]
387
+ * @returns {any}
388
+ */
389
+ export function localizeCollectionsResponse(data, locale, options = {}) {
390
+ if (!data || typeof data !== "object") return data;
391
+
392
+ if (data.data && typeof data.data === "object" && !Array.isArray(data.data)) {
393
+ return {
394
+ ...data,
395
+ data: localizeCollectionsResponse(data.data, locale, options),
396
+ };
397
+ }
398
+
399
+ if (Array.isArray(data.items)) {
400
+ return {
401
+ ...data,
402
+ items: data.items.map((col) => localizeSingleCollection(col, locale, options)),
403
+ };
404
+ }
405
+
406
+ if (Array.isArray(data)) {
407
+ return data.map((col) => localizeSingleCollection(col, locale, options));
408
+ }
409
+
410
+ if (data.slug && data.label) {
411
+ return localizeSingleCollection(data, locale, options);
412
+ }
413
+
414
+ return data;
415
+ }
416
+
417
+ function localizeSingleCollection(col, locale, options = {}) {
418
+ if (!col || typeof col !== "object") return col;
419
+ const c = { ...col };
420
+ const cSlug = c.slug || "unknown";
421
+
422
+ c.label = localizeString(c.label, locale, {
423
+ key: `collections.${cSlug}.label`,
424
+ ...options,
425
+ });
426
+ if (c.labelSingular) {
427
+ c.labelSingular = localizeString(c.labelSingular, locale, {
428
+ key: `collections.${cSlug}.labelSingular`,
429
+ ...options,
430
+ });
431
+ }
432
+ if (c.description) {
433
+ c.description = localizeString(c.description, locale, {
434
+ key: `collections.${cSlug}.description`,
435
+ ...options,
436
+ });
437
+ }
438
+
439
+ if (c.fields && typeof c.fields === "object" && !Array.isArray(c.fields)) {
440
+ const newFields = {};
441
+ for (const [fSlug, fDef] of Object.entries(c.fields)) {
442
+ newFields[fSlug] = localizeSingleField(fDef, `collections.${cSlug}`, locale, options);
443
+ }
444
+ c.fields = newFields;
445
+ } else if (Array.isArray(c.fields)) {
446
+ c.fields = c.fields.map((fld) => localizeSingleField(fld, `collections.${cSlug}`, locale, options));
447
+ }
448
+
449
+ return c;
450
+ }
@@ -0,0 +1,109 @@
1
+ import {
2
+ resolveAdminLocale,
3
+ localizeManifest,
4
+ localizeBlockTypesResponse,
5
+ localizeCollectionsResponse,
6
+ } from "./localizer.mjs";
7
+
8
+ export const RELOAD_WATCHER_SCRIPT = `<script id="emdash-i18n-watcher">
9
+ (() => {
10
+ if (typeof window === "undefined" || !window.MutationObserver || window.__emdash_i18n_lang_watcher__) return;
11
+ window.__emdash_i18n_lang_watcher__ = true;
12
+ let currentLang = document.documentElement.getAttribute("lang") || "";
13
+ const observer = new MutationObserver(() => {
14
+ const newLang = document.documentElement.getAttribute("lang") || "";
15
+ if (newLang && currentLang && newLang !== currentLang) {
16
+ currentLang = newLang;
17
+ setTimeout(() => { window.location.reload(); }, 60);
18
+ }
19
+ });
20
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] });
21
+ })();
22
+ </script>`;
23
+
24
+ /**
25
+ * Creates an Astro middleware handler that intercepts `/_emdash/api/manifest`,
26
+ * `/_emdash/api/schema/block-types`, and `/_emdash/api/schema/collections`
27
+ * and dynamically localizes the schema JSON according to the admin session language,
28
+ * and injects an auto-reload watcher on admin HTML pages so switching languages
29
+ * immediately refreshes schema and block types.
30
+ *
31
+ * @param {object} [options]
32
+ * @param {Record<string, Record<string, string>>} [options.dictionary]
33
+ * @param {string} [options.separator=" / "]
34
+ * @param {string} [options.defaultLocale="en"]
35
+ * @param {boolean} [options.autoReloadOnLocaleChange=true]
36
+ * @returns {(context: any, next: () => Promise<Response>) => Promise<Response>}
37
+ */
38
+ export function createLocalizationMiddleware(options = {}) {
39
+ const { defaultLocale = "en", autoReloadOnLocaleChange = true } = options;
40
+
41
+ return async function localizationMiddleware(context, next) {
42
+ const response = await next();
43
+
44
+ if (response?.status >= 200 && response?.status < 300) {
45
+ const pathname = context.url.pathname;
46
+ const isManifest = pathname === "/_emdash/api/manifest";
47
+ const isBlockTypes = pathname === "/_emdash/api/schema/block-types";
48
+ const isCollections = pathname.startsWith("/_emdash/api/schema/collections");
49
+
50
+ if (isManifest || isBlockTypes || isCollections) {
51
+ try {
52
+ const locale = resolveAdminLocale(context.request, defaultLocale);
53
+ const data = await response.clone().json();
54
+ let localized = data;
55
+
56
+ if (isManifest) {
57
+ localized = localizeManifest(data, locale, options);
58
+ } else if (isBlockTypes) {
59
+ localized = localizeBlockTypesResponse(data, locale, options);
60
+ } else if (isCollections) {
61
+ localized = localizeCollectionsResponse(data, locale, options);
62
+ }
63
+
64
+ const headers = new Headers(response.headers);
65
+ headers.set("Content-Type", "application/json; charset=utf-8");
66
+ headers.set("Vary", "Cookie, Accept-Language");
67
+
68
+ return new Response(JSON.stringify(localized), {
69
+ status: response.status,
70
+ statusText: response.statusText,
71
+ headers,
72
+ });
73
+ } catch (err) {
74
+ console.error("[emdash-schema-i18n] Failed to localize schema response:", err);
75
+ return response;
76
+ }
77
+ }
78
+
79
+ if (
80
+ autoReloadOnLocaleChange &&
81
+ pathname.startsWith("/_emdash/admin") &&
82
+ response.headers.get("content-type")?.includes("text/html")
83
+ ) {
84
+ try {
85
+ const resClone = response.clone();
86
+ const html = await resClone.text();
87
+ if (!html.includes('id="emdash-i18n-watcher"')) {
88
+ const injectedHtml = html.includes("</body>")
89
+ ? html.replace("</body>", `${RELOAD_WATCHER_SCRIPT}</body>`)
90
+ : `${html}${RELOAD_WATCHER_SCRIPT}`;
91
+ const headers = new Headers(response.headers);
92
+ headers.delete("content-length");
93
+ return new Response(injectedHtml, {
94
+ status: response.status,
95
+ statusText: response.statusText,
96
+ headers,
97
+ });
98
+ }
99
+ } catch (err) {
100
+ console.error("[emdash-schema-i18n] Failed to inject auto-reload watcher:", err);
101
+ }
102
+ }
103
+ }
104
+
105
+ return response;
106
+ };
107
+ }
108
+
109
+ export default createLocalizationMiddleware;