@dsi18n/airtable-sync 0.1.0
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/README.md +94 -0
- package/dist/cli.js +537 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +530 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @dsi18n/airtable-sync
|
|
2
|
+
|
|
3
|
+
**Build-time** tool: pull translation rows from [Airtable](https://airtable.com) and write **dsi18n** locale files (`manifest.json`, `en.json`, `es.json`, …).
|
|
4
|
+
|
|
5
|
+
Use with **`@dsi18n/dsi18n`** at runtime (no Airtable calls in production).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -D @dsi18n/airtable-sync
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires **Node.js 18+**.
|
|
14
|
+
|
|
15
|
+
## CLI
|
|
16
|
+
|
|
17
|
+
Create a `.env` in your project root:
|
|
18
|
+
|
|
19
|
+
```env
|
|
20
|
+
AIRTABLE_API_KEY=pat_...
|
|
21
|
+
AIRTABLE_BASE_ID=app...
|
|
22
|
+
AIRTABLE_TABLE=Translations
|
|
23
|
+
I18N_OUT_DIR=./locales
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Run:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx dsi18n-sync
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`dsi18n-sync` loads `.env` from the current directory and parent folders (nested overrides win), then runs the build.
|
|
33
|
+
|
|
34
|
+
### Common env vars
|
|
35
|
+
|
|
36
|
+
| Variable | Description |
|
|
37
|
+
|----------|-------------|
|
|
38
|
+
| `AIRTABLE_API_KEY` | Personal access token |
|
|
39
|
+
| `AIRTABLE_BASE_ID` | Base id (`app…`) |
|
|
40
|
+
| `AIRTABLE_TABLE` | Table name (or use `I18N_TABLES` comma-separated) |
|
|
41
|
+
| `I18N_OUT_DIR` | Output directory (default: `./locales`) |
|
|
42
|
+
| `I18N_KEY_FIELD` | Key column name (default: `key`) |
|
|
43
|
+
| `I18N_DEFAULT_LOCALE` | Default locale tag (default: `en`) |
|
|
44
|
+
| `I18N_LOCALES` | Force locale list, e.g. `en,es` |
|
|
45
|
+
| `I18N_TYPES_OUT_FILE` | Optional path to generate `TranslationKey` TypeScript |
|
|
46
|
+
| `I18N_VERSION` | Bundle version string in manifest |
|
|
47
|
+
| `I18N_DEBUG` | `1` or `true` for verbose logs |
|
|
48
|
+
|
|
49
|
+
## Programmatic API
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import {
|
|
53
|
+
loadBuildConfigFromEnv,
|
|
54
|
+
loadProjectEnv,
|
|
55
|
+
runBuild,
|
|
56
|
+
} from "@dsi18n/airtable-sync";
|
|
57
|
+
|
|
58
|
+
loadProjectEnv();
|
|
59
|
+
process.env.I18N_OUT_DIR ??= "./locales";
|
|
60
|
+
|
|
61
|
+
await runBuild(loadBuildConfigFromEnv());
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## `package.json` script
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"scripts": {
|
|
69
|
+
"i18n:sync": "dsi18n-sync"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@dsi18n/airtable-sync": "^0.1.0"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Commit the generated `locales/` folder (or generate in CI before deploy), then load them with `@dsi18n/dsi18n`.
|
|
78
|
+
|
|
79
|
+
## Output layout
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
locales/
|
|
83
|
+
manifest.json
|
|
84
|
+
en.json
|
|
85
|
+
es.json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Related
|
|
89
|
+
|
|
90
|
+
- Runtime: [`@dsi18n/dsi18n`](https://www.npmjs.com/package/@dsi18n/dsi18n) — `createI18n`, `t()`, `loadLocalesFromDirectory`.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir as mkdir$1, writeFile } from 'fs/promises';
|
|
3
|
+
import path, { resolve, dirname as dirname$1, join } from 'path';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { config } from 'dotenv';
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
function parseBooleanEnv(value) {
|
|
9
|
+
return value === "1" || value === "true";
|
|
10
|
+
}
|
|
11
|
+
function parseNumberEnv(value) {
|
|
12
|
+
if (!value) {
|
|
13
|
+
return void 0;
|
|
14
|
+
}
|
|
15
|
+
const parsed = Number(value);
|
|
16
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
17
|
+
}
|
|
18
|
+
function loadBuildConfigFromEnv() {
|
|
19
|
+
return {
|
|
20
|
+
airtableApiKey: process.env.AIRTABLE_API_KEY,
|
|
21
|
+
baseId: process.env.AIRTABLE_BASE_ID,
|
|
22
|
+
table: process.env.AIRTABLE_TABLE,
|
|
23
|
+
tablesCsv: process.env.I18N_TABLES,
|
|
24
|
+
outDir: process.env.I18N_OUT_DIR,
|
|
25
|
+
version: process.env.I18N_VERSION,
|
|
26
|
+
keyField: process.env.I18N_KEY_FIELD,
|
|
27
|
+
defaultLocale: process.env.I18N_DEFAULT_LOCALE,
|
|
28
|
+
localesCsv: process.env.I18N_LOCALES,
|
|
29
|
+
typesOutFile: process.env.I18N_TYPES_OUT_FILE,
|
|
30
|
+
debug: parseBooleanEnv(process.env.I18N_DEBUG),
|
|
31
|
+
rateLimitDelayMs: parseNumberEnv(process.env.I18N_RATE_LIMIT_DELAY_MS)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function mkdir(pathValue) {
|
|
35
|
+
await mkdir$1(pathValue, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
async function writeUtf8File(pathValue, content) {
|
|
38
|
+
await writeFile(pathValue, content, "utf8");
|
|
39
|
+
}
|
|
40
|
+
function joinPath(...parts) {
|
|
41
|
+
return path.join(...parts);
|
|
42
|
+
}
|
|
43
|
+
function dirname(pathValue) {
|
|
44
|
+
return path.dirname(pathValue);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/core/fetch.ts
|
|
48
|
+
var RETRY_ATTEMPTS = 3;
|
|
49
|
+
var RETRY_BASE_DELAY_MS = 300;
|
|
50
|
+
var DEFAULT_RATE_LIMIT_DELAY_MS = 200;
|
|
51
|
+
function sleep(ms) {
|
|
52
|
+
return new Promise((resolve2) => {
|
|
53
|
+
setTimeout(resolve2, ms);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function isRetryableStatus(status) {
|
|
57
|
+
return status === 429 || status >= 500;
|
|
58
|
+
}
|
|
59
|
+
async function fetchWithRetry(url, init, fetchFn, debug) {
|
|
60
|
+
let lastError = null;
|
|
61
|
+
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
|
|
62
|
+
try {
|
|
63
|
+
if (debug) {
|
|
64
|
+
console.log(`[fetch] attempt=${attempt + 1} url=${url.toString()}`);
|
|
65
|
+
}
|
|
66
|
+
const response = await fetchFn(url, init);
|
|
67
|
+
if (!isRetryableStatus(response.status)) {
|
|
68
|
+
return response;
|
|
69
|
+
}
|
|
70
|
+
if (debug) {
|
|
71
|
+
console.warn(
|
|
72
|
+
`[fetch] retryable_status=${response.status} attempt=${attempt + 1} url=${url.toString()}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const body = await response.text();
|
|
76
|
+
lastError = new Error(
|
|
77
|
+
`Retryable Airtable response (${response.status}): ${response.statusText}
|
|
78
|
+
${body}`
|
|
79
|
+
);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (debug) {
|
|
82
|
+
console.warn(
|
|
83
|
+
`[fetch] network_error attempt=${attempt + 1} url=${url.toString()}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
lastError = error instanceof Error ? error : new Error("Unknown fetch error");
|
|
87
|
+
}
|
|
88
|
+
if (attempt < RETRY_ATTEMPTS - 1) {
|
|
89
|
+
await sleep(RETRY_BASE_DELAY_MS * (attempt + 1));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw lastError ?? new Error("Fetch failed after retries");
|
|
93
|
+
}
|
|
94
|
+
async function fetchAirtablePage(params) {
|
|
95
|
+
const encodedTable = encodeURIComponent(params.table);
|
|
96
|
+
const url = new URL(
|
|
97
|
+
`https://api.airtable.com/v0/${params.baseId}/${encodedTable}`
|
|
98
|
+
);
|
|
99
|
+
if (params.offset) {
|
|
100
|
+
url.searchParams.set("offset", params.offset);
|
|
101
|
+
}
|
|
102
|
+
const headers = {};
|
|
103
|
+
if (params.apiKey) {
|
|
104
|
+
headers.Authorization = `Bearer ${params.apiKey}`;
|
|
105
|
+
}
|
|
106
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
107
|
+
if (params.debug) {
|
|
108
|
+
const offsetValue = params.offset ?? "none";
|
|
109
|
+
console.log(`[fetch] table=${params.table} offset=${offsetValue}`);
|
|
110
|
+
}
|
|
111
|
+
const response = await fetchWithRetry(url, { headers }, fetchFn, params.debug);
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const body = await response.text();
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Airtable request failed (${response.status}): ${response.statusText}
|
|
116
|
+
${body}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return await response.json();
|
|
120
|
+
}
|
|
121
|
+
async function fetchAllRecords(params) {
|
|
122
|
+
const records = [];
|
|
123
|
+
let offset;
|
|
124
|
+
const rateLimitDelayMs = params.rateLimitDelayMs ?? DEFAULT_RATE_LIMIT_DELAY_MS;
|
|
125
|
+
do {
|
|
126
|
+
const page = await fetchAirtablePage({
|
|
127
|
+
apiKey: params.apiKey,
|
|
128
|
+
baseId: params.baseId,
|
|
129
|
+
table: params.table,
|
|
130
|
+
offset,
|
|
131
|
+
fetchFn: params.fetchFn,
|
|
132
|
+
debug: params.debug
|
|
133
|
+
});
|
|
134
|
+
records.push(...page.records);
|
|
135
|
+
offset = page.offset;
|
|
136
|
+
if (offset) {
|
|
137
|
+
await sleep(rateLimitDelayMs);
|
|
138
|
+
}
|
|
139
|
+
} while (offset);
|
|
140
|
+
return records;
|
|
141
|
+
}
|
|
142
|
+
async function listBaseTables(params) {
|
|
143
|
+
const url = new URL(`https://api.airtable.com/v0/meta/bases/${params.baseId}/tables`);
|
|
144
|
+
const fetchFn = params.fetchFn ?? fetch;
|
|
145
|
+
const response = await fetchWithRetry(
|
|
146
|
+
url,
|
|
147
|
+
{
|
|
148
|
+
headers: {
|
|
149
|
+
Authorization: `Bearer ${params.apiKey}`
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
fetchFn,
|
|
153
|
+
params.debug
|
|
154
|
+
);
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
const body = await response.text();
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Airtable metadata request failed (${response.status}): ${response.statusText}
|
|
159
|
+
${body}`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
const json = await response.json();
|
|
163
|
+
return json.tables.map((table) => table.name);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/core/normalize.ts
|
|
167
|
+
function parseLocalesFromEnv(value) {
|
|
168
|
+
if (!value) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
return value.split(",").map((locale) => locale.trim()).filter(Boolean);
|
|
172
|
+
}
|
|
173
|
+
function inferLocalesFromRecords(records, keyField) {
|
|
174
|
+
const locales = /* @__PURE__ */ new Set();
|
|
175
|
+
for (const record of records) {
|
|
176
|
+
const fields = Object.keys(record.fields);
|
|
177
|
+
for (const fieldName of fields) {
|
|
178
|
+
if (fieldName === keyField) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const value = record.fields[fieldName];
|
|
182
|
+
if (typeof value === "string") {
|
|
183
|
+
locales.add(fieldName);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return [...locales].sort();
|
|
188
|
+
}
|
|
189
|
+
function normalizeRecords(records, locales, keyField, options) {
|
|
190
|
+
const normalizedEntries = records.map((record) => {
|
|
191
|
+
const keyValue = record.fields[keyField];
|
|
192
|
+
if (typeof keyValue !== "string" || keyValue.trim().length === 0) {
|
|
193
|
+
if (options?.debug) {
|
|
194
|
+
console.warn(`Skipping record ${record.id}: invalid key`);
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const key = keyValue.trim();
|
|
199
|
+
const translations = Object.fromEntries(
|
|
200
|
+
locales.map((locale) => {
|
|
201
|
+
const value = record.fields[locale];
|
|
202
|
+
if (typeof value !== "string") {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const trimmed = value.trim();
|
|
206
|
+
return trimmed.length > 0 ? [locale, trimmed] : null;
|
|
207
|
+
}).filter((item) => item !== null)
|
|
208
|
+
);
|
|
209
|
+
return { key, translations };
|
|
210
|
+
});
|
|
211
|
+
return normalizedEntries.filter(
|
|
212
|
+
(entry) => entry !== null
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/core/validate.ts
|
|
217
|
+
function extractPlaceholders(text) {
|
|
218
|
+
const matches = text.match(/\{([a-zA-Z0-9_]+)\}/g) ?? [];
|
|
219
|
+
const cleaned = matches.map((token) => token.replace(/[{}]/g, ""));
|
|
220
|
+
return [...new Set(cleaned)];
|
|
221
|
+
}
|
|
222
|
+
function detectDuplicateKeys(entries) {
|
|
223
|
+
const seen = /* @__PURE__ */ new Set();
|
|
224
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
if (seen.has(entry.key)) {
|
|
227
|
+
duplicates.add(entry.key);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
seen.add(entry.key);
|
|
231
|
+
}
|
|
232
|
+
return [...duplicates].sort();
|
|
233
|
+
}
|
|
234
|
+
function validateMissingTranslations(entries, locales) {
|
|
235
|
+
return entries.flatMap(
|
|
236
|
+
(entry) => locales.map((locale) => ({
|
|
237
|
+
key: entry.key,
|
|
238
|
+
locale,
|
|
239
|
+
value: entry.translations[locale]
|
|
240
|
+
}))
|
|
241
|
+
).filter((item) => item.value == null || item.value === "").map((item) => ({
|
|
242
|
+
type: "missing_translation",
|
|
243
|
+
key: item.key,
|
|
244
|
+
locale: item.locale
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
function validatePlaceholdersConsistency(entries, locales, baseLocale) {
|
|
248
|
+
const otherLocales = locales.filter((locale) => locale !== baseLocale);
|
|
249
|
+
return entries.flatMap((entry) => {
|
|
250
|
+
const baseTranslation = entry.translations[baseLocale];
|
|
251
|
+
if (!baseTranslation) {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
const basePlaceholders = extractPlaceholders(baseTranslation);
|
|
255
|
+
return otherLocales.map((locale) => {
|
|
256
|
+
const translation = entry.translations[locale];
|
|
257
|
+
if (!translation) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const placeholders = extractPlaceholders(translation);
|
|
261
|
+
const missingIssues = basePlaceholders.filter((placeholder) => !placeholders.includes(placeholder)).map((placeholder) => ({
|
|
262
|
+
type: "missing_placeholder",
|
|
263
|
+
key: entry.key,
|
|
264
|
+
locale,
|
|
265
|
+
baseLocale,
|
|
266
|
+
placeholder
|
|
267
|
+
}));
|
|
268
|
+
const extraIssues = placeholders.filter((placeholder) => !basePlaceholders.includes(placeholder)).map((placeholder) => ({
|
|
269
|
+
type: "extra_placeholder",
|
|
270
|
+
key: entry.key,
|
|
271
|
+
locale,
|
|
272
|
+
baseLocale,
|
|
273
|
+
placeholder
|
|
274
|
+
}));
|
|
275
|
+
return [...missingIssues, ...extraIssues];
|
|
276
|
+
}).flat().filter((item) => item !== null);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function validateDefaultLocale(locales, defaultLocale) {
|
|
280
|
+
if (!locales.includes(defaultLocale)) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Default locale "${defaultLocale}" is not present in locales: ${locales.join(", ")}`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/core/run-build.ts
|
|
288
|
+
var DICTIONARY_SCHEMA_REF = "../schema/dictionary.schema.json";
|
|
289
|
+
var MANIFEST_SCHEMA_REF = "../schema/manifest.schema.json";
|
|
290
|
+
var DEFAULT_OUT_DIR = "packages/locales";
|
|
291
|
+
var DEFAULT_VERSION = "0.1.0";
|
|
292
|
+
var DEFAULT_KEY_FIELD = "key";
|
|
293
|
+
var DEFAULT_LOCALE = "en";
|
|
294
|
+
var DEFAULT_TYPES_OUT_FILE = "packages/i18n-core/src/generated/translation-keys.ts";
|
|
295
|
+
var DEFAULT_RATE_LIMIT_DELAY_MS2 = 200;
|
|
296
|
+
function sleep2(ms) {
|
|
297
|
+
return new Promise((resolve2) => {
|
|
298
|
+
setTimeout(resolve2, ms);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
function formatValidationIssue(issue) {
|
|
302
|
+
if (issue.type === "missing_translation") {
|
|
303
|
+
return `Missing ${issue.locale} for key: ${issue.key}`;
|
|
304
|
+
}
|
|
305
|
+
if (issue.type === "missing_placeholder") {
|
|
306
|
+
return `Missing placeholder "${issue.placeholder}" for key "${issue.key}" (${issue.locale}) using base locale "${issue.baseLocale}"`;
|
|
307
|
+
}
|
|
308
|
+
return `Extra placeholder "${issue.placeholder}" for key "${issue.key}" (${issue.locale}) using base locale "${issue.baseLocale}"`;
|
|
309
|
+
}
|
|
310
|
+
function parseTablesFromEnv(value) {
|
|
311
|
+
if (!value) {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
return value.split(",").map((table) => table.trim()).filter(Boolean);
|
|
315
|
+
}
|
|
316
|
+
function extractPlaceholders2(text) {
|
|
317
|
+
const matches = text.match(/\{([a-zA-Z0-9_]+)\}/g) ?? [];
|
|
318
|
+
const cleaned = matches.map((token) => token.replace(/[{}]/g, ""));
|
|
319
|
+
return [...new Set(cleaned)];
|
|
320
|
+
}
|
|
321
|
+
function buildDictionaries(entries, locales, version) {
|
|
322
|
+
const dictionaries = Object.fromEntries(
|
|
323
|
+
locales.map((locale) => [
|
|
324
|
+
locale,
|
|
325
|
+
{
|
|
326
|
+
locale,
|
|
327
|
+
version,
|
|
328
|
+
entries: {}
|
|
329
|
+
}
|
|
330
|
+
])
|
|
331
|
+
);
|
|
332
|
+
entries.forEach((entry) => {
|
|
333
|
+
locales.forEach((locale) => {
|
|
334
|
+
const value = entry.translations[locale];
|
|
335
|
+
if (!value) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
dictionaries[locale].entries[entry.key] = {
|
|
339
|
+
value,
|
|
340
|
+
placeholders: extractPlaceholders2(value)
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
return dictionaries;
|
|
345
|
+
}
|
|
346
|
+
async function writeOutputs(params) {
|
|
347
|
+
await mkdir(params.outDir);
|
|
348
|
+
const dictionaryEntries = Object.entries(params.dictionaries);
|
|
349
|
+
await Promise.all(
|
|
350
|
+
dictionaryEntries.map(async ([locale, dictionary]) => {
|
|
351
|
+
const filePath = joinPath(params.outDir, `${locale}.json`);
|
|
352
|
+
const payload = {
|
|
353
|
+
$schema: DICTIONARY_SCHEMA_REF,
|
|
354
|
+
locale: dictionary.locale,
|
|
355
|
+
version: dictionary.version,
|
|
356
|
+
entries: dictionary.entries
|
|
357
|
+
};
|
|
358
|
+
await writeUtf8File(filePath, `${JSON.stringify(payload, null, 2)}
|
|
359
|
+
`);
|
|
360
|
+
})
|
|
361
|
+
);
|
|
362
|
+
const manifestPath = joinPath(params.outDir, "manifest.json");
|
|
363
|
+
const manifestPayload = {
|
|
364
|
+
...params.manifest,
|
|
365
|
+
$schema: MANIFEST_SCHEMA_REF
|
|
366
|
+
};
|
|
367
|
+
await writeUtf8File(
|
|
368
|
+
manifestPath,
|
|
369
|
+
`${JSON.stringify(manifestPayload, null, 2)}
|
|
370
|
+
`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
function buildTypeDefinitions(keys) {
|
|
374
|
+
const sortedKeys = [...keys].sort();
|
|
375
|
+
const keyUnion = sortedKeys.length > 0 ? sortedKeys.map((key) => ` | "${key}"`).join("\n") : ' | "__no_translation_keys__"';
|
|
376
|
+
return [
|
|
377
|
+
"// AUTO-GENERATED FILE. DO NOT EDIT.",
|
|
378
|
+
"// Generated by packages/airtable-sync/src/core/run-build.ts",
|
|
379
|
+
"",
|
|
380
|
+
"export type TranslationKey =",
|
|
381
|
+
keyUnion + ";",
|
|
382
|
+
"",
|
|
383
|
+
"export const translationKeys = [",
|
|
384
|
+
...sortedKeys.map((key) => ` "${key}",`),
|
|
385
|
+
"] as const;",
|
|
386
|
+
""
|
|
387
|
+
].join("\n");
|
|
388
|
+
}
|
|
389
|
+
async function writeTypeDefinitions(typeOutputPath, entries) {
|
|
390
|
+
const keys = entries.map((entry) => entry.key);
|
|
391
|
+
const typeContent = buildTypeDefinitions(keys);
|
|
392
|
+
await mkdir(dirname(typeOutputPath));
|
|
393
|
+
await writeUtf8File(typeOutputPath, typeContent);
|
|
394
|
+
}
|
|
395
|
+
async function runBuild(config) {
|
|
396
|
+
const baseId = config.baseId;
|
|
397
|
+
const airtableApiKey = config.airtableApiKey;
|
|
398
|
+
if (!baseId) {
|
|
399
|
+
throw new Error("Missing required config: baseId");
|
|
400
|
+
}
|
|
401
|
+
if (!airtableApiKey) {
|
|
402
|
+
throw new Error("Missing airtableApiKey");
|
|
403
|
+
}
|
|
404
|
+
const outDir = config.outDir ?? DEFAULT_OUT_DIR;
|
|
405
|
+
const version = config.version ?? DEFAULT_VERSION;
|
|
406
|
+
const keyField = config.keyField ?? DEFAULT_KEY_FIELD;
|
|
407
|
+
const defaultLocale = config.defaultLocale ?? DEFAULT_LOCALE;
|
|
408
|
+
const typesOutFile = config.typesOutFile ?? DEFAULT_TYPES_OUT_FILE;
|
|
409
|
+
const rateLimitDelayMs = config.rateLimitDelayMs ?? DEFAULT_RATE_LIMIT_DELAY_MS2;
|
|
410
|
+
const tablesFromCsv = parseTablesFromEnv(config.tablesCsv);
|
|
411
|
+
const selectedTables = config.table && config.table.trim().length > 0 ? [config.table.trim()] : tablesFromCsv;
|
|
412
|
+
const tables = selectedTables.length > 0 ? selectedTables : await (async () => {
|
|
413
|
+
if (!config.airtableApiKey) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
"AIRTABLE_TABLE or I18N_TABLES is required unless AIRTABLE_API_KEY can access Airtable metadata."
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return listBaseTables({
|
|
419
|
+
apiKey: airtableApiKey,
|
|
420
|
+
baseId,
|
|
421
|
+
fetchFn: config.fetchFn,
|
|
422
|
+
debug: config.debug
|
|
423
|
+
});
|
|
424
|
+
})();
|
|
425
|
+
if (tables.length === 0) {
|
|
426
|
+
throw new Error("No tables found to read from Airtable base.");
|
|
427
|
+
}
|
|
428
|
+
const records = await tables.reduce(
|
|
429
|
+
async (accPromise, table, index) => {
|
|
430
|
+
const acc = await accPromise;
|
|
431
|
+
if (index > 0) {
|
|
432
|
+
await sleep2(rateLimitDelayMs);
|
|
433
|
+
}
|
|
434
|
+
const tableRecords = await fetchAllRecords({
|
|
435
|
+
apiKey: airtableApiKey,
|
|
436
|
+
baseId,
|
|
437
|
+
table,
|
|
438
|
+
fetchFn: config.fetchFn,
|
|
439
|
+
debug: config.debug,
|
|
440
|
+
rateLimitDelayMs
|
|
441
|
+
});
|
|
442
|
+
return [...acc, ...tableRecords];
|
|
443
|
+
},
|
|
444
|
+
Promise.resolve([])
|
|
445
|
+
);
|
|
446
|
+
const configuredLocales = parseLocalesFromEnv(config.localesCsv);
|
|
447
|
+
const inferredLocales = inferLocalesFromRecords(records, keyField);
|
|
448
|
+
const locales = configuredLocales.length > 0 ? configuredLocales : inferredLocales;
|
|
449
|
+
if (locales.length === 0) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
"No locales found. Set I18N_LOCALES or provide language columns in Airtable."
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const normalized = normalizeRecords(records, locales, keyField, {
|
|
455
|
+
debug: config.debug
|
|
456
|
+
});
|
|
457
|
+
const duplicateKeys = detectDuplicateKeys(normalized);
|
|
458
|
+
if (duplicateKeys.length > 0) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`Duplicate translation keys found: ${duplicateKeys.join(", ")}`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const missingTranslations = validateMissingTranslations(normalized, locales);
|
|
464
|
+
if (missingTranslations.length > 0) {
|
|
465
|
+
console.warn("Missing translations:");
|
|
466
|
+
missingTranslations.forEach(
|
|
467
|
+
(issue) => console.warn(` - ${formatValidationIssue(issue)}`)
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
validateDefaultLocale(locales, defaultLocale);
|
|
471
|
+
const placeholderMismatches = validatePlaceholdersConsistency(
|
|
472
|
+
normalized,
|
|
473
|
+
locales,
|
|
474
|
+
defaultLocale
|
|
475
|
+
);
|
|
476
|
+
if (placeholderMismatches.length > 0) {
|
|
477
|
+
console.warn("Placeholder consistency issues:");
|
|
478
|
+
placeholderMismatches.forEach(
|
|
479
|
+
(issue) => console.warn(` - ${formatValidationIssue(issue)}`)
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const dictionaries = buildDictionaries(normalized, locales, version);
|
|
483
|
+
const manifest = {
|
|
484
|
+
version,
|
|
485
|
+
defaultLocale,
|
|
486
|
+
fallbackChain: [defaultLocale],
|
|
487
|
+
locales,
|
|
488
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
489
|
+
source: {
|
|
490
|
+
type: "airtable",
|
|
491
|
+
baseId,
|
|
492
|
+
tables
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
await writeOutputs({
|
|
496
|
+
outDir,
|
|
497
|
+
dictionaries,
|
|
498
|
+
manifest
|
|
499
|
+
});
|
|
500
|
+
await writeTypeDefinitions(typesOutFile, normalized);
|
|
501
|
+
console.log(
|
|
502
|
+
`Generated locales: ${locales.join(", ")} | records: ${normalized.length} | out: ${outDir} | types: ${typesOutFile}`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
function loadProjectEnv(startDir = process.cwd()) {
|
|
506
|
+
const chain = [];
|
|
507
|
+
let dir = resolve(startDir);
|
|
508
|
+
for (let i = 0; i < 6; i += 1) {
|
|
509
|
+
chain.unshift(dir);
|
|
510
|
+
const parent = dirname$1(dir);
|
|
511
|
+
if (parent === dir) {
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
dir = parent;
|
|
515
|
+
}
|
|
516
|
+
for (const folder of chain) {
|
|
517
|
+
const path2 = join(folder, ".env");
|
|
518
|
+
if (existsSync(path2)) {
|
|
519
|
+
config({ path: path2 });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/index.ts
|
|
525
|
+
async function main() {
|
|
526
|
+
await runBuild(loadBuildConfigFromEnv());
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/cli.ts
|
|
530
|
+
loadProjectEnv();
|
|
531
|
+
main().catch((error) => {
|
|
532
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
533
|
+
console.error(message);
|
|
534
|
+
process.exit(1);
|
|
535
|
+
});
|
|
536
|
+
//# sourceMappingURL=cli.js.map
|
|
537
|
+
//# sourceMappingURL=cli.js.map
|