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 +21 -0
- package/README.md +176 -2
- package/bin/cli.mjs +213 -0
- package/emdash-plugin.jsonc +19 -0
- package/package.json +71 -4
- package/src/admin/index.tsx +85 -0
- package/src/index.d.mts +73 -0
- package/src/index.mjs +66 -0
- package/src/localizer.d.mts +36 -0
- package/src/localizer.mjs +450 -0
- package/src/middleware.mjs +109 -0
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
|
-
#
|
|
1
|
+
# emdash-schema-i18n
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Dynamic schema, collections, and block types localization for **EmDash CMS** Admin UI.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/emdash-schema-i18n)
|
|
6
|
+
[](https://www.npmjs.com/package/emdash-schema-i18n)
|
|
7
|
+
[](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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
|
package/src/index.d.mts
ADDED
|
@@ -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;
|