@tandem-language-exchange/content-store 1.3.2 → 1.3.3
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/dist/{chunk-Y6HC4NYU.js → chunk-OCAIIQZW.js} +2 -2
- package/dist/chunk-OCAIIQZW.js.map +1 -0
- package/dist/{chunk-SF7FCBR2.js → chunk-OWL72OTS.js} +3 -3
- package/dist/chunk-OWL72OTS.js.map +1 -0
- package/dist/chunk-POJRKC4G.js +1015 -0
- package/dist/chunk-POJRKC4G.js.map +1 -0
- package/dist/{chunk-D2F7FQEM.js → chunk-QQDU3TVQ.js} +3 -3
- package/dist/{chunk-MOGVAQ2N.js → chunk-SDEERVPV.js} +2 -2
- package/dist/{chunk-YWUFALDR.js → chunk-UQX4THTY.js} +75 -5
- package/dist/chunk-UQX4THTY.js.map +1 -0
- package/dist/{chunk-LZHYKLAU.js → chunk-VBJ6LVMY.js} +2 -4
- package/dist/chunk-VBJ6LVMY.js.map +1 -0
- package/dist/client/fetch-content-bundles.js +5 -5
- package/dist/client/fetch-merged-translation-bundles.js +5 -5
- package/dist/client/fetch-translation-bundles.js +5 -5
- package/dist/client/list-projects.js +1 -1
- package/dist/client/list-resources.js +1 -1
- package/dist/client/query-cms.js +3 -3
- package/dist/node.d.ts +96 -2
- package/dist/node.js +217 -5
- package/dist/node.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-LZHYKLAU.js.map +0 -1
- package/dist/chunk-NQHWG4XM.js +0 -472
- package/dist/chunk-NQHWG4XM.js.map +0 -1
- package/dist/chunk-SF7FCBR2.js.map +0 -1
- package/dist/chunk-Y6HC4NYU.js.map +0 -1
- package/dist/chunk-YWUFALDR.js.map +0 -1
- /package/dist/{chunk-D2F7FQEM.js.map → chunk-QQDU3TVQ.js.map} +0 -0
- /package/dist/{chunk-MOGVAQ2N.js.map → chunk-SDEERVPV.js.map} +0 -0
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
config
|
|
4
|
+
} from "./chunk-OCAIIQZW.js";
|
|
5
|
+
import {
|
|
6
|
+
ContentStore,
|
|
7
|
+
buildCmsObjectKey,
|
|
8
|
+
buildTranslationObjectKey,
|
|
9
|
+
contentTypeForTranslationKey
|
|
10
|
+
} from "./chunk-VBJ6LVMY.js";
|
|
11
|
+
import {
|
|
12
|
+
allProjects,
|
|
13
|
+
defaultLocales
|
|
14
|
+
} from "./chunk-OWL72OTS.js";
|
|
15
|
+
|
|
16
|
+
// src/server/adapters/azure/types.ts
|
|
17
|
+
var AZURE_DEVOPS_PROJECT_KEYS = [
|
|
18
|
+
"web-site",
|
|
19
|
+
"web-app",
|
|
20
|
+
"web-invites"
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// src/server/adapters/azure/client.ts
|
|
24
|
+
var AzureDevOpsClient = class {
|
|
25
|
+
constructor(config3) {
|
|
26
|
+
this.config = config3;
|
|
27
|
+
const org = config3.organization.replace(/^\/+|\/+$/g, "");
|
|
28
|
+
const project = encodeURIComponent(config3.project);
|
|
29
|
+
this.baseUrl = `https://dev.azure.com/${org}/${project}`;
|
|
30
|
+
}
|
|
31
|
+
baseUrl;
|
|
32
|
+
/**
|
|
33
|
+
* Call any Azure DevOps REST endpoint under the configured organization and project.
|
|
34
|
+
*/
|
|
35
|
+
async request(options) {
|
|
36
|
+
const {
|
|
37
|
+
method = "GET",
|
|
38
|
+
path,
|
|
39
|
+
query = {},
|
|
40
|
+
body,
|
|
41
|
+
apiVersion = this.config.apiVersion,
|
|
42
|
+
headers: extraHeaders = {}
|
|
43
|
+
} = options;
|
|
44
|
+
if (!this.config.pat) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"Azure DevOps is not configured (set AZURE_DEVOPS_ACCESS_TOKEN)"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const url = this.buildUrl(path, { ...query, "api-version": apiVersion });
|
|
50
|
+
const headers = {
|
|
51
|
+
Authorization: this.basicAuthHeader(),
|
|
52
|
+
Accept: "application/json",
|
|
53
|
+
...extraHeaders
|
|
54
|
+
};
|
|
55
|
+
const init = { method, headers };
|
|
56
|
+
if (body !== void 0) {
|
|
57
|
+
headers["Content-Type"] = "application/json";
|
|
58
|
+
init.body = JSON.stringify(body);
|
|
59
|
+
}
|
|
60
|
+
const response = await fetch(url, init);
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
let parsed;
|
|
63
|
+
if (text) {
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(text);
|
|
66
|
+
} catch {
|
|
67
|
+
parsed = text;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const errBody = parsed;
|
|
72
|
+
const detail = errBody?.message ?? (typeof parsed === "string" ? parsed : JSON.stringify(parsed));
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Azure DevOps ${method} ${path} failed (${response.status}): ${detail}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
buildUrl(path, query) {
|
|
80
|
+
const base = /^https?:\/\//i.test(path) ? path : `${this.baseUrl}${path}`;
|
|
81
|
+
const url = new URL(base);
|
|
82
|
+
for (const [key, value] of Object.entries(query)) {
|
|
83
|
+
if (value !== void 0) {
|
|
84
|
+
url.searchParams.set(key, String(value));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return url.toString();
|
|
88
|
+
}
|
|
89
|
+
basicAuthHeader() {
|
|
90
|
+
const encoded = Buffer.from(`:${this.config.pat}`).toString("base64");
|
|
91
|
+
return `Basic ${encoded}`;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
function createAzureDevOpsClient(config3) {
|
|
95
|
+
return new AzureDevOpsClient(config3);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/server/adapters/azure/pipelines.ts
|
|
99
|
+
function isAzureDevOpsProjectKey(value) {
|
|
100
|
+
return AZURE_DEVOPS_PROJECT_KEYS.includes(value);
|
|
101
|
+
}
|
|
102
|
+
function resolveProjectConfig(azureConfig, project) {
|
|
103
|
+
const projectConfig = azureConfig.projects[project];
|
|
104
|
+
if (!projectConfig) {
|
|
105
|
+
throw new Error(`Unknown Azure DevOps project "${project}"`);
|
|
106
|
+
}
|
|
107
|
+
return projectConfig;
|
|
108
|
+
}
|
|
109
|
+
async function triggerPipelineBuild(azureConfig, project, variables = {}) {
|
|
110
|
+
const { pipeline } = resolveProjectConfig(azureConfig, project);
|
|
111
|
+
if (!pipeline?.id) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`No pipeline id configured for project "${project}" (instance ENVIRONMENT=${azureConfig.instanceEnvironment}, Azure=${azureConfig.pipelineEnvironment})`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const client = createAzureDevOpsClient({
|
|
117
|
+
organization: azureConfig.organization,
|
|
118
|
+
project,
|
|
119
|
+
pat: azureConfig.pat,
|
|
120
|
+
apiVersion: azureConfig.apiVersion
|
|
121
|
+
});
|
|
122
|
+
const run = await client.request({
|
|
123
|
+
method: "POST",
|
|
124
|
+
path: `/_apis/pipelines/${pipeline.id}/runs`,
|
|
125
|
+
body: {
|
|
126
|
+
resources: {
|
|
127
|
+
repositories: {
|
|
128
|
+
self: {
|
|
129
|
+
refName: pipeline.refName
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
variables
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
project,
|
|
138
|
+
instanceEnvironment: azureConfig.instanceEnvironment,
|
|
139
|
+
pipelineEnvironment: azureConfig.pipelineEnvironment,
|
|
140
|
+
pipelineId: pipeline.id,
|
|
141
|
+
refName: pipeline.refName,
|
|
142
|
+
run
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function formatPipelineRunSummary(result) {
|
|
146
|
+
const {
|
|
147
|
+
run,
|
|
148
|
+
project,
|
|
149
|
+
instanceEnvironment,
|
|
150
|
+
pipelineEnvironment,
|
|
151
|
+
pipelineId,
|
|
152
|
+
refName
|
|
153
|
+
} = result;
|
|
154
|
+
const webUrl = run._links?.web?.href ?? run.url;
|
|
155
|
+
const envLabel = instanceEnvironment === pipelineEnvironment ? `\`${pipelineEnvironment}\`` : `\`${instanceEnvironment}\` \u2192 Azure \`${pipelineEnvironment}\``;
|
|
156
|
+
const lines = [
|
|
157
|
+
`Environment ${envLabel} \u2014 project \`${project}\`, pipeline id \`${pipelineId}\`, ref \`${refName}\``,
|
|
158
|
+
`Run #${run.id}${run.state ? ` \u2014 state: \`${run.state}\`` : ""}`
|
|
159
|
+
];
|
|
160
|
+
if (webUrl) {
|
|
161
|
+
lines.push(`<${webUrl}|Open run in Azure DevOps>`);
|
|
162
|
+
}
|
|
163
|
+
return lines.join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/server/adapters/azure/environment.ts
|
|
167
|
+
var PRODUCTION_ALIASES = /* @__PURE__ */ new Set(["production", "live", "prod"]);
|
|
168
|
+
var STAGING_ALIASES = /* @__PURE__ */ new Set(["staging", "beta", "development", "dev", "local"]);
|
|
169
|
+
function normalizeToAzureEnvironment(instanceEnvironment) {
|
|
170
|
+
const key = instanceEnvironment.trim().toLowerCase();
|
|
171
|
+
if (!key) {
|
|
172
|
+
console.warn(
|
|
173
|
+
"[azure] Empty ENVIRONMENT; defaulting Azure pipeline environment to staging"
|
|
174
|
+
);
|
|
175
|
+
return "staging";
|
|
176
|
+
}
|
|
177
|
+
if (PRODUCTION_ALIASES.has(key)) {
|
|
178
|
+
return "production";
|
|
179
|
+
}
|
|
180
|
+
if (STAGING_ALIASES.has(key)) {
|
|
181
|
+
return "staging";
|
|
182
|
+
}
|
|
183
|
+
console.warn(
|
|
184
|
+
`[azure] Unrecognized ENVIRONMENT="${instanceEnvironment}"; defaulting Azure pipeline environment to staging`
|
|
185
|
+
);
|
|
186
|
+
return "staging";
|
|
187
|
+
}
|
|
188
|
+
function pipelineRefForAzure(pipelineEnvironment) {
|
|
189
|
+
return `refs/heads/${pipelineEnvironment}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/server/config.ts
|
|
193
|
+
import dotenv from "dotenv";
|
|
194
|
+
|
|
195
|
+
// src/server/restrictedCron.ts
|
|
196
|
+
var MAX_SEARCH_MINUTES = 366 * 24 * 60;
|
|
197
|
+
function tokenize(expr) {
|
|
198
|
+
return expr.trim().split(/\s+/).map((s) => s.trim()).filter(Boolean);
|
|
199
|
+
}
|
|
200
|
+
function validateScheduleCronExpression(expr) {
|
|
201
|
+
const parts = tokenize(expr);
|
|
202
|
+
if (parts.length !== 2) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Schedule cron must be exactly two fields (minute hour), whitespace-separated; got ${parts.length} field(s): "${expr}"`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const minuteSpec = parts[0];
|
|
208
|
+
const hourSpec = parts[1];
|
|
209
|
+
parseField(minuteSpec, 0, 59, "minute");
|
|
210
|
+
parseField(hourSpec, 0, 23, "hour");
|
|
211
|
+
return `${minuteSpec} ${hourSpec}`;
|
|
212
|
+
}
|
|
213
|
+
function parseField(spec, lo, hi, fieldName) {
|
|
214
|
+
const subs = spec.split(",").map((s) => s.trim()).filter(Boolean);
|
|
215
|
+
if (subs.length === 0) {
|
|
216
|
+
throw new Error(`Empty ${fieldName} field in cron`);
|
|
217
|
+
}
|
|
218
|
+
const preds = subs.map((sub) => parseSubfield(sub, lo, hi, fieldName));
|
|
219
|
+
return (n) => preds.some((p) => p(n));
|
|
220
|
+
}
|
|
221
|
+
function parseSubfield(sub, lo, hi, fieldName) {
|
|
222
|
+
if (sub === "*") {
|
|
223
|
+
return () => true;
|
|
224
|
+
}
|
|
225
|
+
if (sub.startsWith("*/")) {
|
|
226
|
+
const step = parseInt(sub.slice(2), 10);
|
|
227
|
+
if (!Number.isFinite(step) || step < 1) {
|
|
228
|
+
throw new Error(`Invalid step in ${fieldName} field: "${sub}"`);
|
|
229
|
+
}
|
|
230
|
+
return (n) => n >= lo && n <= hi && n % step === 0;
|
|
231
|
+
}
|
|
232
|
+
if (sub.includes("-")) {
|
|
233
|
+
const [a, b] = sub.split("-").map((x) => parseInt(x.trim(), 10));
|
|
234
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
235
|
+
throw new Error(`Invalid range in ${fieldName} field: "${sub}"`);
|
|
236
|
+
}
|
|
237
|
+
if (a < lo || b > hi || a > b) {
|
|
238
|
+
throw new Error(`Range out of bounds in ${fieldName} field: "${sub}"`);
|
|
239
|
+
}
|
|
240
|
+
return (n) => n >= a && n <= b;
|
|
241
|
+
}
|
|
242
|
+
const v = parseInt(sub, 10);
|
|
243
|
+
if (!Number.isFinite(v) || v < lo || v > hi) {
|
|
244
|
+
throw new Error(`Invalid value in ${fieldName} field: "${sub}"`);
|
|
245
|
+
}
|
|
246
|
+
return (n) => n === v;
|
|
247
|
+
}
|
|
248
|
+
function floorToMinuteStart(d) {
|
|
249
|
+
const t = new Date(d.getTime());
|
|
250
|
+
t.setSeconds(0, 0);
|
|
251
|
+
return t;
|
|
252
|
+
}
|
|
253
|
+
function addOneMinute(d) {
|
|
254
|
+
const t = new Date(d.getTime());
|
|
255
|
+
t.setMinutes(t.getMinutes() + 1, 0, 0);
|
|
256
|
+
return t;
|
|
257
|
+
}
|
|
258
|
+
function matchesAtMinuteStart(minutePred, hourPred, d) {
|
|
259
|
+
return minutePred(d.getMinutes()) && hourPred(d.getHours());
|
|
260
|
+
}
|
|
261
|
+
function nextCronFireAfter(expr, after) {
|
|
262
|
+
const parts = tokenize(expr);
|
|
263
|
+
if (parts.length !== 2) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`nextCronFireAfter expects exactly two fields (minute hour); got ${parts.length}: "${expr}"`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const minutePred = parseField(parts[0], 0, 59, "minute");
|
|
269
|
+
const hourPred = parseField(parts[1], 0, 23, "hour");
|
|
270
|
+
let d = floorToMinuteStart(after);
|
|
271
|
+
if (d.getTime() <= after.getTime()) {
|
|
272
|
+
d = addOneMinute(d);
|
|
273
|
+
}
|
|
274
|
+
for (let i = 0; i < MAX_SEARCH_MINUTES; i++) {
|
|
275
|
+
if (matchesAtMinuteStart(minutePred, hourPred, d)) {
|
|
276
|
+
return d;
|
|
277
|
+
}
|
|
278
|
+
d = addOneMinute(d);
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`No cron match within ${MAX_SEARCH_MINUTES} minutes for "${expr}"`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/server/config.ts
|
|
284
|
+
dotenv.config({ path: ".env.local" });
|
|
285
|
+
dotenv.config();
|
|
286
|
+
function readScheduleCronEnv() {
|
|
287
|
+
return {
|
|
288
|
+
scheduleCron: (process.env.SCHEDULE_CRON ?? "").trim(),
|
|
289
|
+
cmsCronRaw: (process.env.SCHEDULE_CMS_CRON ?? "").trim(),
|
|
290
|
+
translationCronRaw: (process.env.SCHEDULE_TRANSLATION_CRON ?? "").trim()
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function resolveScheduleCron(jobSpecific, globalCron, jobLabel) {
|
|
294
|
+
const raw = jobSpecific || globalCron;
|
|
295
|
+
if (!raw) return null;
|
|
296
|
+
try {
|
|
297
|
+
return validateScheduleCronExpression(raw);
|
|
298
|
+
} catch (e) {
|
|
299
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
300
|
+
console.error(`[config] Invalid ${jobLabel} schedule cron: ${msg}`);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function parseScheduledCmsJobConfig() {
|
|
305
|
+
const { scheduleCron, cmsCronRaw } = readScheduleCronEnv();
|
|
306
|
+
const runOnStart = (process.env.SCHEDULE_RUN_ON_START ?? "true").toLowerCase() !== "false";
|
|
307
|
+
const syncCms = (process.env.SCHEDULE_SYNC_CMS ?? "").trim();
|
|
308
|
+
const rawTypes = process.env.SCHEDULE_SYNC_CONTENT_TYPES?.trim();
|
|
309
|
+
const syncTypes = rawTypes ? rawTypes.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
310
|
+
const resolved = resolveScheduleCron(cmsCronRaw, scheduleCron, "CMS");
|
|
311
|
+
const cmsOk = !!syncCms && (syncCms === "contentful" || syncCms === "sanity");
|
|
312
|
+
if (resolved !== null && !cmsOk) {
|
|
313
|
+
console.warn(
|
|
314
|
+
'[config] CMS schedule cron is set but SCHEDULE_SYNC_CMS is missing or not "contentful"|"sanity"; scheduled CMS sync is disabled.'
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const enabled = resolved !== null && cmsOk;
|
|
318
|
+
return {
|
|
319
|
+
enabled,
|
|
320
|
+
cronExpression: resolved ?? "",
|
|
321
|
+
runOnStart,
|
|
322
|
+
syncCms: syncCms || void 0,
|
|
323
|
+
syncTypes
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function parseScheduledTranslationJobConfig() {
|
|
327
|
+
const { scheduleCron, translationCronRaw } = readScheduleCronEnv();
|
|
328
|
+
const runOnStart = (process.env.SCHEDULE_RUN_ON_START ?? "true").toLowerCase() !== "false";
|
|
329
|
+
const rawProjects = process.env.SCHEDULE_SYNC_TRANSLATION_PROJECTS?.trim();
|
|
330
|
+
const syncProjects = rawProjects ? rawProjects.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
331
|
+
const resolved = resolveScheduleCron(
|
|
332
|
+
translationCronRaw,
|
|
333
|
+
scheduleCron,
|
|
334
|
+
"translation"
|
|
335
|
+
);
|
|
336
|
+
const enabled = resolved !== null;
|
|
337
|
+
return {
|
|
338
|
+
enabled,
|
|
339
|
+
cronExpression: resolved ?? "",
|
|
340
|
+
runOnStart,
|
|
341
|
+
syncProjects
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function projectEnvSlug(project) {
|
|
345
|
+
return project.toUpperCase().replace(/-/g, "_");
|
|
346
|
+
}
|
|
347
|
+
function readProjectPipeline(project, pipelineEnvironment) {
|
|
348
|
+
const slug = projectEnvSlug(project);
|
|
349
|
+
const id = parseInt(process.env[`AZURE_${slug}_PIPELINE_ID`]?.trim() ?? "0", 10);
|
|
350
|
+
return { id, refName: pipelineRefForAzure(pipelineEnvironment) };
|
|
351
|
+
}
|
|
352
|
+
function parseAzureDevOpsConfig(instanceEnvironment) {
|
|
353
|
+
const pipelineEnvironment = normalizeToAzureEnvironment(instanceEnvironment);
|
|
354
|
+
const pat = process.env.AZURE_DEVOPS_ACCESS_TOKEN?.trim() ?? "";
|
|
355
|
+
const defaultProjectRaw = (process.env.AZURE_DEVOPS_DEFAULT_PROJECT ?? "web-site").trim();
|
|
356
|
+
const defaultProject = AZURE_DEVOPS_PROJECT_KEYS.includes(
|
|
357
|
+
defaultProjectRaw
|
|
358
|
+
) ? defaultProjectRaw : "web-site";
|
|
359
|
+
const projects = Object.fromEntries(
|
|
360
|
+
AZURE_DEVOPS_PROJECT_KEYS.map((project) => [
|
|
361
|
+
project,
|
|
362
|
+
{ pipeline: readProjectPipeline(project, pipelineEnvironment) }
|
|
363
|
+
])
|
|
364
|
+
);
|
|
365
|
+
return {
|
|
366
|
+
enabled: pat.length > 0,
|
|
367
|
+
organization: process.env.AZURE_DEVOPS_ORGANIZATION?.trim() || "tripod-technology",
|
|
368
|
+
pat,
|
|
369
|
+
apiVersion: process.env.AZURE_DEVOPS_API_VERSION?.trim() || "7.1",
|
|
370
|
+
defaultProject,
|
|
371
|
+
instanceEnvironment,
|
|
372
|
+
pipelineEnvironment,
|
|
373
|
+
projects
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function readContentRefreshUrl(project) {
|
|
377
|
+
const slug = projectEnvSlug(project);
|
|
378
|
+
return process.env[`CONTENT_REFRESH_${slug}_URL`]?.trim() || void 0;
|
|
379
|
+
}
|
|
380
|
+
function parseContentRefreshConfig(instanceEnvironment) {
|
|
381
|
+
const targets = Object.fromEntries(
|
|
382
|
+
AZURE_DEVOPS_PROJECT_KEYS.map((project) => [
|
|
383
|
+
project,
|
|
384
|
+
{ url: readContentRefreshUrl(project) }
|
|
385
|
+
])
|
|
386
|
+
);
|
|
387
|
+
return {
|
|
388
|
+
enabled: isContentRefreshEnabledForInstance(instanceEnvironment),
|
|
389
|
+
basicAuth: process.env.CONTENT_REFRESH_BASIC_AUTH?.trim() ?? "",
|
|
390
|
+
targets
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
var config2 = {
|
|
394
|
+
...config,
|
|
395
|
+
contentful: {
|
|
396
|
+
spaceId: process.env.CONTENTFUL_SPACE_ID ?? "",
|
|
397
|
+
accessToken: process.env.CONTENTFUL_WEBSITE_TOKEN ?? "",
|
|
398
|
+
host: process.env.CONTENTFUL_HOST ?? "preview.contentful.com",
|
|
399
|
+
batchSize: 1e3,
|
|
400
|
+
maxDepth: 4,
|
|
401
|
+
contentTypes: [
|
|
402
|
+
"asset",
|
|
403
|
+
"page",
|
|
404
|
+
"longtailPage",
|
|
405
|
+
"customJson",
|
|
406
|
+
"banner",
|
|
407
|
+
"cookieBanner",
|
|
408
|
+
"downloadPage"
|
|
409
|
+
// Add Contentful content type IDs here to limit sync scope.
|
|
410
|
+
]
|
|
411
|
+
},
|
|
412
|
+
sanity: {
|
|
413
|
+
projectId: process.env.SANITY_PROJECT_ID ?? "",
|
|
414
|
+
dataset: process.env.SANITY_DATASET ?? "main",
|
|
415
|
+
token: process.env.SANITY_API_TOKEN ?? "",
|
|
416
|
+
apiVersion: "2024-01-01"
|
|
417
|
+
},
|
|
418
|
+
lingohub: {
|
|
419
|
+
authToken: process.env.LINGOHUB_AUTH_TOKEN ?? "",
|
|
420
|
+
workspace: process.env.LINGOHUB_WORKSPACE ?? ""
|
|
421
|
+
},
|
|
422
|
+
retry: {
|
|
423
|
+
maxRetries: parseInt(process.env.RETRY_MAX_RETRIES ?? "5", 10),
|
|
424
|
+
baseDelayMs: parseInt(process.env.RETRY_BASE_DELAY_MS ?? "1000", 10),
|
|
425
|
+
maxDelayMs: parseInt(process.env.RETRY_MAX_DELAY_MS ?? "60000", 10)
|
|
426
|
+
},
|
|
427
|
+
api: {
|
|
428
|
+
port: parseInt(process.env.PORT ?? "3030", 10),
|
|
429
|
+
apiToken: process.env.CONTENT_STORE_API_TOKEN ?? ""
|
|
430
|
+
},
|
|
431
|
+
scheduledCmsJob: parseScheduledCmsJobConfig(),
|
|
432
|
+
scheduledTranslationJob: parseScheduledTranslationJobConfig(),
|
|
433
|
+
azure: parseAzureDevOpsConfig(config.environment),
|
|
434
|
+
contentRefresh: parseContentRefreshConfig(config.environment),
|
|
435
|
+
slack: {
|
|
436
|
+
enabled: (process.env.SLACK_BOT_TOKEN ?? "").length > 0,
|
|
437
|
+
botToken: process.env.SLACK_BOT_TOKEN ?? "",
|
|
438
|
+
signingSecret: process.env.SLACK_SIGNING_SECRET ?? "",
|
|
439
|
+
appToken: process.env.SLACK_APP_TOKEN ?? "",
|
|
440
|
+
notifyChannel: process.env.SLACK_NOTIFY_CHANNEL ?? "",
|
|
441
|
+
cmdSyncContent: process.env.SLACK_CMD_SYNC_CONTENT ?? "/sync-content",
|
|
442
|
+
cmdSyncTranslations: process.env.SLACK_CMD_SYNC_TRANSLATIONS ?? "/sync-translations",
|
|
443
|
+
cmdTriggerBuild: process.env.SLACK_CMD_TRIGGER_BUILD ?? "/trigger-build"
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// src/shared/content-refresh.ts
|
|
448
|
+
import { timingSafeEqual } from "crypto";
|
|
449
|
+
async function postContentRefresh(target, url, basicAuth, request) {
|
|
450
|
+
try {
|
|
451
|
+
const response = await fetch(url, {
|
|
452
|
+
method: "POST",
|
|
453
|
+
headers: {
|
|
454
|
+
Authorization: `Basic ${basicAuth}`,
|
|
455
|
+
"Content-Type": "application/json",
|
|
456
|
+
Accept: "application/json"
|
|
457
|
+
},
|
|
458
|
+
body: JSON.stringify(request)
|
|
459
|
+
});
|
|
460
|
+
const text = await response.text();
|
|
461
|
+
let body;
|
|
462
|
+
if (text) {
|
|
463
|
+
try {
|
|
464
|
+
body = JSON.parse(text);
|
|
465
|
+
} catch {
|
|
466
|
+
body = text;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
target,
|
|
471
|
+
ok: response.ok,
|
|
472
|
+
status: response.status,
|
|
473
|
+
body,
|
|
474
|
+
error: response.ok ? void 0 : typeof body === "object" && body !== null && "error" in body ? String(body.error) : `HTTP ${response.status}`
|
|
475
|
+
};
|
|
476
|
+
} catch (err) {
|
|
477
|
+
return {
|
|
478
|
+
target,
|
|
479
|
+
ok: false,
|
|
480
|
+
status: 0,
|
|
481
|
+
error: err instanceof Error ? err.message : String(err)
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// src/server/content-refresh-notify.ts
|
|
487
|
+
function isContentRefreshEnabledForInstance(instanceEnvironment) {
|
|
488
|
+
return normalizeToAzureEnvironment(instanceEnvironment) === "staging";
|
|
489
|
+
}
|
|
490
|
+
async function notifyContentRefreshTargets(notifyConfig, request, projects) {
|
|
491
|
+
if (!notifyConfig.enabled || !notifyConfig.basicAuth) {
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
const keys = projects ?? AZURE_DEVOPS_PROJECT_KEYS.filter(
|
|
495
|
+
(p) => notifyConfig.targets[p]?.url
|
|
496
|
+
);
|
|
497
|
+
const results = [];
|
|
498
|
+
for (const project of keys) {
|
|
499
|
+
const url = notifyConfig.targets[project]?.url;
|
|
500
|
+
if (!url) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const result = await postContentRefresh(
|
|
504
|
+
project,
|
|
505
|
+
url,
|
|
506
|
+
notifyConfig.basicAuth,
|
|
507
|
+
request
|
|
508
|
+
);
|
|
509
|
+
results.push(result);
|
|
510
|
+
if (result.ok) {
|
|
511
|
+
console.log(
|
|
512
|
+
`[content-refresh] Notified ${project} (${result.status})`
|
|
513
|
+
);
|
|
514
|
+
} else {
|
|
515
|
+
console.error(
|
|
516
|
+
`[content-refresh] Failed to notify ${project}: ${result.error ?? result.status}`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return results;
|
|
521
|
+
}
|
|
522
|
+
async function notifyClientsAfterCmsSync(result, requestedContentTypes, projects) {
|
|
523
|
+
const { contentRefresh } = config2;
|
|
524
|
+
if (!contentRefresh.enabled) {
|
|
525
|
+
console.log(
|
|
526
|
+
"[content-refresh] Skipped after CMS sync (disabled on this instance; staging/beta only)"
|
|
527
|
+
);
|
|
528
|
+
return [];
|
|
529
|
+
}
|
|
530
|
+
if (!contentRefresh.basicAuth) {
|
|
531
|
+
console.warn(
|
|
532
|
+
"[content-refresh] Skipped after CMS sync (set CONTENT_REFRESH_BASIC_AUTH)"
|
|
533
|
+
);
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
if (result.errors.length > 0) {
|
|
537
|
+
console.log("[content-refresh] Skipped after CMS sync (sync had errors)");
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
const types = requestedContentTypes?.length ? requestedContentTypes : result.entries.map((e) => e.contentType);
|
|
541
|
+
if (types.length === 0) {
|
|
542
|
+
console.warn("[content-refresh] Skipped after CMS sync (no content types)");
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
545
|
+
const targets = projects ?? AZURE_DEVOPS_PROJECT_KEYS.filter(
|
|
546
|
+
(p) => contentRefresh.targets[p]?.url
|
|
547
|
+
);
|
|
548
|
+
if (targets.length === 0) {
|
|
549
|
+
console.warn(
|
|
550
|
+
"[content-refresh] Skipped after CMS sync (no CONTENT_REFRESH_*_URL configured)"
|
|
551
|
+
);
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
console.log(
|
|
555
|
+
`[content-refresh] Notifying after CMS sync (${result.cms}): ${targets.join(", ")}`
|
|
556
|
+
);
|
|
557
|
+
return notifyContentRefreshTargets(
|
|
558
|
+
contentRefresh,
|
|
559
|
+
{
|
|
560
|
+
scope: "cms",
|
|
561
|
+
cms: result.cms,
|
|
562
|
+
content_types: types
|
|
563
|
+
},
|
|
564
|
+
projects
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/server/adapters/contentful-api-usage.ts
|
|
569
|
+
function emptyContentfulApiUsage() {
|
|
570
|
+
return { cda: 0, cma: 0, cpa: 0 };
|
|
571
|
+
}
|
|
572
|
+
function contentfulApiKindFromHost(host) {
|
|
573
|
+
const h = host.toLowerCase();
|
|
574
|
+
if (h.includes("preview")) {
|
|
575
|
+
return "cpa";
|
|
576
|
+
}
|
|
577
|
+
if (h.includes("api.contentful") || h === "api.contentful.com") {
|
|
578
|
+
return "cma";
|
|
579
|
+
}
|
|
580
|
+
return "cda";
|
|
581
|
+
}
|
|
582
|
+
function totalContentfulApiCalls(usage) {
|
|
583
|
+
return usage.cda + usage.cma + usage.cpa;
|
|
584
|
+
}
|
|
585
|
+
function summariseContentfulApiUsage(usage) {
|
|
586
|
+
const lines = [];
|
|
587
|
+
if (usage.cda > 0) {
|
|
588
|
+
lines.push(`CDA requests: ${usage.cda}`);
|
|
589
|
+
}
|
|
590
|
+
if (usage.cpa > 0) {
|
|
591
|
+
lines.push(`CPA requests: ${usage.cpa}`);
|
|
592
|
+
}
|
|
593
|
+
if (usage.cma > 0) {
|
|
594
|
+
lines.push(`CMA requests: ${usage.cma}`);
|
|
595
|
+
}
|
|
596
|
+
lines.push(`Total contentful API calls: ${totalContentfulApiCalls(usage)}`);
|
|
597
|
+
return lines;
|
|
598
|
+
}
|
|
599
|
+
var ContentfulApiUsageTracker = class {
|
|
600
|
+
kind;
|
|
601
|
+
counts = emptyContentfulApiUsage();
|
|
602
|
+
constructor(host) {
|
|
603
|
+
this.kind = contentfulApiKindFromHost(host);
|
|
604
|
+
}
|
|
605
|
+
recordCall() {
|
|
606
|
+
this.counts[this.kind] += 1;
|
|
607
|
+
}
|
|
608
|
+
snapshot() {
|
|
609
|
+
return { ...this.counts };
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// src/server/adapters/contentful.ts
|
|
614
|
+
import {
|
|
615
|
+
createClient
|
|
616
|
+
} from "contentful";
|
|
617
|
+
|
|
618
|
+
// src/server/sync/retry.ts
|
|
619
|
+
function rateLimitDelayMs(err) {
|
|
620
|
+
const e = err;
|
|
621
|
+
if (e?.status === 429 || e?.statusCode === 429) {
|
|
622
|
+
const reset = e?.headers?.["x-contentful-ratelimit-reset"];
|
|
623
|
+
return reset ? parseFloat(reset) * 1e3 : 0;
|
|
624
|
+
}
|
|
625
|
+
const resp = e?.response;
|
|
626
|
+
if (resp?.status === 429) {
|
|
627
|
+
const headers = resp?.headers;
|
|
628
|
+
const reset = headers?.["x-contentful-ratelimit-reset"];
|
|
629
|
+
return reset ? parseFloat(reset) * 1e3 : 0;
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
function computeDelay(attempt, baseDelayMs, maxDelayMs) {
|
|
634
|
+
const exponential = baseDelayMs * Math.pow(2, attempt);
|
|
635
|
+
const jitter = Math.random() * baseDelayMs;
|
|
636
|
+
return Math.min(exponential + jitter, maxDelayMs);
|
|
637
|
+
}
|
|
638
|
+
async function withRetry(fn, { maxRetries, baseDelayMs, maxDelayMs }) {
|
|
639
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
640
|
+
try {
|
|
641
|
+
return await fn();
|
|
642
|
+
} catch (err) {
|
|
643
|
+
if (attempt === maxRetries) throw err;
|
|
644
|
+
const rlDelay = rateLimitDelayMs(err);
|
|
645
|
+
let delay;
|
|
646
|
+
if (rlDelay !== null) {
|
|
647
|
+
delay = rlDelay > 0 ? rlDelay : computeDelay(attempt, baseDelayMs, maxDelayMs);
|
|
648
|
+
console.warn(
|
|
649
|
+
` Rate limited (attempt ${attempt + 1}/${maxRetries}). Waiting ${Math.round(delay)}ms\u2026`
|
|
650
|
+
);
|
|
651
|
+
} else {
|
|
652
|
+
delay = computeDelay(attempt, baseDelayMs, maxDelayMs);
|
|
653
|
+
console.warn(
|
|
654
|
+
` Request failed (attempt ${attempt + 1}/${maxRetries}): ${err.message}. Retrying in ${Math.round(delay)}ms\u2026`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
throw new Error("withRetry: unreachable");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/server/adapters/contentful.ts
|
|
664
|
+
function stripEnvelope(value, maxDepth, depth = 0, path = /* @__PURE__ */ new WeakSet()) {
|
|
665
|
+
if (value === null || typeof value !== "object") return value;
|
|
666
|
+
const obj = value;
|
|
667
|
+
if (path.has(obj)) return void 0;
|
|
668
|
+
path.add(obj);
|
|
669
|
+
try {
|
|
670
|
+
if (Array.isArray(value)) {
|
|
671
|
+
return value.map((item) => stripEnvelope(item, maxDepth, depth, path));
|
|
672
|
+
}
|
|
673
|
+
const isEnvelope = "sys" in obj && "fields" in obj && typeof obj.fields === "object";
|
|
674
|
+
if (isEnvelope) {
|
|
675
|
+
if (depth >= maxDepth) return void 0;
|
|
676
|
+
const fields = obj.fields;
|
|
677
|
+
const result2 = {};
|
|
678
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
679
|
+
result2[k] = stripEnvelope(v, maxDepth, depth + 1, path);
|
|
680
|
+
}
|
|
681
|
+
const sys = obj.sys;
|
|
682
|
+
if (sys) {
|
|
683
|
+
const existingMeta = result2.meta;
|
|
684
|
+
const metaBase = typeof existingMeta === "object" && existingMeta !== null && !Array.isArray(existingMeta) ? existingMeta : {};
|
|
685
|
+
const _contentType = sys.contentType ? sys.contentType.sys.id : "Asset";
|
|
686
|
+
result2.meta = { ...metaBase, _id: sys.id, _contentType, _updatedAt: sys.updatedAt };
|
|
687
|
+
}
|
|
688
|
+
return result2;
|
|
689
|
+
}
|
|
690
|
+
const result = {};
|
|
691
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
692
|
+
result[k] = stripEnvelope(v, maxDepth, depth, path);
|
|
693
|
+
}
|
|
694
|
+
return result;
|
|
695
|
+
} finally {
|
|
696
|
+
path.delete(obj);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
var ContentfulAdapter = class {
|
|
700
|
+
name = "contentful";
|
|
701
|
+
client;
|
|
702
|
+
batchSize;
|
|
703
|
+
maxDepth;
|
|
704
|
+
allowedTypes;
|
|
705
|
+
retryConfig;
|
|
706
|
+
apiUsage;
|
|
707
|
+
constructor(cfg2, retryConfig) {
|
|
708
|
+
this.client = createClient({
|
|
709
|
+
space: cfg2.spaceId,
|
|
710
|
+
accessToken: cfg2.accessToken,
|
|
711
|
+
host: cfg2.host
|
|
712
|
+
});
|
|
713
|
+
this.batchSize = cfg2.batchSize;
|
|
714
|
+
this.maxDepth = cfg2.maxDepth;
|
|
715
|
+
this.allowedTypes = cfg2.contentTypes;
|
|
716
|
+
this.retryConfig = retryConfig;
|
|
717
|
+
this.apiUsage = new ContentfulApiUsageTracker(cfg2.host);
|
|
718
|
+
}
|
|
719
|
+
getContentfulApiUsage() {
|
|
720
|
+
return this.apiUsage.snapshot();
|
|
721
|
+
}
|
|
722
|
+
async getContentTypes() {
|
|
723
|
+
const response = await withRetry(
|
|
724
|
+
() => {
|
|
725
|
+
this.apiUsage.recordCall();
|
|
726
|
+
return this.client.getContentTypes();
|
|
727
|
+
},
|
|
728
|
+
this.retryConfig
|
|
729
|
+
);
|
|
730
|
+
const allTypes = response.items.map((ct) => ct.sys.id);
|
|
731
|
+
if (this.allowedTypes.length > 0) {
|
|
732
|
+
return allTypes.filter((t) => this.allowedTypes.includes(t));
|
|
733
|
+
}
|
|
734
|
+
return allTypes;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Fetches every entry for a content type using batched pagination.
|
|
738
|
+
* Contentful caps `getEntries` at 1 000 items per call, so we page through
|
|
739
|
+
* with `skip` until all items are collected.
|
|
740
|
+
*
|
|
741
|
+
* The reserved content type `"asset"` fetches from `getAssets()` instead.
|
|
742
|
+
*/
|
|
743
|
+
async fetchAll(contentType, includeLevels = 4) {
|
|
744
|
+
if (contentType === "asset") {
|
|
745
|
+
return this.fetchAllAssets();
|
|
746
|
+
}
|
|
747
|
+
const allItems = [];
|
|
748
|
+
let skip = 0;
|
|
749
|
+
let total = 0;
|
|
750
|
+
do {
|
|
751
|
+
const payload = {
|
|
752
|
+
content_type: contentType,
|
|
753
|
+
limit: this.batchSize,
|
|
754
|
+
skip,
|
|
755
|
+
include: includeLevels
|
|
756
|
+
};
|
|
757
|
+
this.apiUsage.recordCall();
|
|
758
|
+
const response = await this.client.getEntries(payload);
|
|
759
|
+
total = response.total;
|
|
760
|
+
allItems.push(...response.items);
|
|
761
|
+
skip += response.items.length;
|
|
762
|
+
if (total > this.batchSize) {
|
|
763
|
+
console.log(
|
|
764
|
+
` [contentful] ${contentType}: fetched ${allItems.length}/${total}`
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
} while (skip < total);
|
|
768
|
+
return {
|
|
769
|
+
contentType,
|
|
770
|
+
items: allItems.map((item) => stripEnvelope(item, this.maxDepth)),
|
|
771
|
+
total
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
async fetchAllAssets() {
|
|
775
|
+
const allItems = [];
|
|
776
|
+
let skip = 0;
|
|
777
|
+
let total = 0;
|
|
778
|
+
do {
|
|
779
|
+
const response = await withRetry(
|
|
780
|
+
() => {
|
|
781
|
+
this.apiUsage.recordCall();
|
|
782
|
+
return this.client.getAssets({ limit: this.batchSize, skip });
|
|
783
|
+
},
|
|
784
|
+
this.retryConfig
|
|
785
|
+
);
|
|
786
|
+
total = response.total;
|
|
787
|
+
allItems.push(...response.items);
|
|
788
|
+
skip += response.items.length;
|
|
789
|
+
if (total > this.batchSize) {
|
|
790
|
+
console.log(
|
|
791
|
+
` [contentful] asset: fetched ${allItems.length}/${total}`
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
} while (skip < total);
|
|
795
|
+
return {
|
|
796
|
+
contentType: "asset",
|
|
797
|
+
items: allItems.map((item) => stripEnvelope(item, this.maxDepth)),
|
|
798
|
+
total
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
// src/server/adapters/sanity.ts
|
|
804
|
+
import { createClient as createClient2 } from "@sanity/client";
|
|
805
|
+
var SanityAdapter = class {
|
|
806
|
+
name = "sanity";
|
|
807
|
+
client;
|
|
808
|
+
retryConfig;
|
|
809
|
+
constructor(cfg2, retryConfig) {
|
|
810
|
+
this.client = createClient2({
|
|
811
|
+
projectId: cfg2.projectId,
|
|
812
|
+
dataset: cfg2.dataset,
|
|
813
|
+
token: cfg2.token,
|
|
814
|
+
apiVersion: cfg2.apiVersion,
|
|
815
|
+
useCdn: false
|
|
816
|
+
});
|
|
817
|
+
this.retryConfig = retryConfig;
|
|
818
|
+
}
|
|
819
|
+
async getContentTypes() {
|
|
820
|
+
const types = await withRetry(
|
|
821
|
+
() => this.client.fetch("array::unique(*[]._type)"),
|
|
822
|
+
this.retryConfig
|
|
823
|
+
);
|
|
824
|
+
return types.filter(
|
|
825
|
+
(t) => !t.startsWith("system.") && !t.startsWith("sanity.")
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
async fetchAll(contentType) {
|
|
829
|
+
const items = await withRetry(
|
|
830
|
+
() => this.client.fetch("*[_type == $type]", { type: contentType }),
|
|
831
|
+
this.retryConfig
|
|
832
|
+
);
|
|
833
|
+
console.log(` [sanity] ${contentType}: fetched ${items.length} items`);
|
|
834
|
+
return { contentType, items, total: items.length };
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
// src/server/adapters/index.ts
|
|
839
|
+
function createAdapter(cms) {
|
|
840
|
+
switch (cms) {
|
|
841
|
+
case "contentful":
|
|
842
|
+
return new ContentfulAdapter(config2.contentful, config2.retry);
|
|
843
|
+
case "sanity":
|
|
844
|
+
return new SanityAdapter(config2.sanity, config2.retry);
|
|
845
|
+
default:
|
|
846
|
+
throw new Error(`Unknown CMS provider: ${cms}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/server/adapters/lingohub.ts
|
|
851
|
+
var cfg = config2.lingohub;
|
|
852
|
+
var apiUrl = "https://api.lingohub.com/v1/" + cfg.workspace + "/projects/";
|
|
853
|
+
async function fetchLingohubResourceRaw(project, resource, locale) {
|
|
854
|
+
const urlForResourceLocalised = `${apiUrl}${project}/resources/${resource.fileName}?auth_token=${cfg.authToken}`.replace(
|
|
855
|
+
"[locale]",
|
|
856
|
+
locale
|
|
857
|
+
);
|
|
858
|
+
const res = await fetch(urlForResourceLocalised, { method: "GET" });
|
|
859
|
+
if (!res.ok) {
|
|
860
|
+
throw new Error(`Failed to fetch resource \`${resource.fileName}\` (${locale}): ${res.status} - ${res.statusText}`);
|
|
861
|
+
}
|
|
862
|
+
return await res.text();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// src/server/sync/engine.ts
|
|
866
|
+
function summariseCmsEntries(result) {
|
|
867
|
+
return result.entries.map(
|
|
868
|
+
(e) => `\u2022 \`${e.contentType}\` \u2014 ${e.itemCount} items`
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
function summariseCmsErrors(result) {
|
|
872
|
+
return result.errors.map(
|
|
873
|
+
(e) => `\u2022 \`${e.contentType}\` \u2014 ${e.error}`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
function summariseCmsContentfulApiUsage(result) {
|
|
877
|
+
if (!result.contentfulApiUsage) {
|
|
878
|
+
return [];
|
|
879
|
+
}
|
|
880
|
+
return summariseContentfulApiUsage(result.contentfulApiUsage);
|
|
881
|
+
}
|
|
882
|
+
function summariseTranslationEntries(entries) {
|
|
883
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
884
|
+
for (const e of entries) {
|
|
885
|
+
const key = `${e.project} / ${e.resource}`;
|
|
886
|
+
const existing = grouped.get(key);
|
|
887
|
+
if (existing) {
|
|
888
|
+
existing.locales += 1;
|
|
889
|
+
existing.bytes += e.itemCount;
|
|
890
|
+
} else {
|
|
891
|
+
grouped.set(key, { locales: 1, bytes: e.itemCount });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return [...grouped.entries()].map(
|
|
895
|
+
([key, { locales, bytes }]) => `\u2022 \`${key}\` / [${locales} locale${locales === 1 ? "" : "s"}] \u2014 ${bytes} bytes`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
async function syncCmsContent(cms, contentTypes, includeLevels) {
|
|
899
|
+
const adapter = createAdapter(cms);
|
|
900
|
+
const store = new ContentStore(config2.s3);
|
|
901
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
902
|
+
console.log(`
|
|
903
|
+
Starting sync from ${cms} at ${new Date(timestamp * 1e3).toISOString()}`);
|
|
904
|
+
const typesToSync = contentTypes && contentTypes.length > 0 ? contentTypes : await adapter.getContentTypes();
|
|
905
|
+
console.log(`Content types to sync: ${typesToSync.join(", ")}
|
|
906
|
+
`);
|
|
907
|
+
const entries = [];
|
|
908
|
+
const errors = [];
|
|
909
|
+
for (const contentType of typesToSync) {
|
|
910
|
+
try {
|
|
911
|
+
const result = await adapter.fetchAll(contentType, includeLevels);
|
|
912
|
+
const objectKey = buildCmsObjectKey(cms, contentType);
|
|
913
|
+
await store.upload(objectKey, result.items);
|
|
914
|
+
entries.push({
|
|
915
|
+
contentType,
|
|
916
|
+
itemCount: result.total,
|
|
917
|
+
objectKey
|
|
918
|
+
});
|
|
919
|
+
console.log(
|
|
920
|
+
` + ${contentType}: ${result.total} items -> ${objectKey}`
|
|
921
|
+
);
|
|
922
|
+
} catch (err) {
|
|
923
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
924
|
+
errors.push({ contentType, error: message });
|
|
925
|
+
console.error(` x ${contentType}: ${message}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
const contentfulApiUsage = adapter.getContentfulApiUsage?.();
|
|
929
|
+
if (contentfulApiUsage) {
|
|
930
|
+
console.log(
|
|
931
|
+
`
|
|
932
|
+
Contentful API calls: ${summariseContentfulApiUsage(contentfulApiUsage).join(", ")}`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
console.log(
|
|
936
|
+
`
|
|
937
|
+
Sync complete: ${entries.length} succeeded, ${errors.length} failed
|
|
938
|
+
`
|
|
939
|
+
);
|
|
940
|
+
return { cms, timestamp, entries, errors, contentfulApiUsage };
|
|
941
|
+
}
|
|
942
|
+
async function syncTranslations(projects, locales) {
|
|
943
|
+
const store = new ContentStore(config2.s3);
|
|
944
|
+
const entries = [];
|
|
945
|
+
const errors = [];
|
|
946
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
947
|
+
if (!locales) {
|
|
948
|
+
locales = defaultLocales;
|
|
949
|
+
}
|
|
950
|
+
if (!projects) {
|
|
951
|
+
projects = Object.keys(allProjects);
|
|
952
|
+
}
|
|
953
|
+
for (const project of projects) {
|
|
954
|
+
const resources = allProjects[project];
|
|
955
|
+
if (!resources) {
|
|
956
|
+
console.error(`No resources found for ${project}`);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
for (const resource of resources) {
|
|
960
|
+
for (const loc of locales) {
|
|
961
|
+
const locale = resource.localeMapping && resource.localeMapping[loc] ? resource.localeMapping[loc] : loc;
|
|
962
|
+
try {
|
|
963
|
+
const raw = await withRetry(
|
|
964
|
+
() => fetchLingohubResourceRaw(project, resource, locale),
|
|
965
|
+
config2.retry
|
|
966
|
+
);
|
|
967
|
+
const objectKey = buildTranslationObjectKey(
|
|
968
|
+
project,
|
|
969
|
+
resource.fileName,
|
|
970
|
+
locale
|
|
971
|
+
);
|
|
972
|
+
await store.uploadRaw(
|
|
973
|
+
objectKey,
|
|
974
|
+
raw,
|
|
975
|
+
contentTypeForTranslationKey(objectKey)
|
|
976
|
+
);
|
|
977
|
+
const byteLength = Buffer.byteLength(raw, "utf8");
|
|
978
|
+
entries.push({
|
|
979
|
+
project,
|
|
980
|
+
resource: resource.resource,
|
|
981
|
+
locale,
|
|
982
|
+
itemCount: byteLength,
|
|
983
|
+
objectKey
|
|
984
|
+
});
|
|
985
|
+
console.log(
|
|
986
|
+
` + ${project} - ${locale}: ${byteLength} bytes -> ${objectKey}`
|
|
987
|
+
);
|
|
988
|
+
} catch (err) {
|
|
989
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
990
|
+
errors.push({ project, locale, error: message });
|
|
991
|
+
console.error(` x ${project} - ${locale}: ${message}`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return { timestamp, entries, errors };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
export {
|
|
1000
|
+
AZURE_DEVOPS_PROJECT_KEYS,
|
|
1001
|
+
isAzureDevOpsProjectKey,
|
|
1002
|
+
triggerPipelineBuild,
|
|
1003
|
+
formatPipelineRunSummary,
|
|
1004
|
+
notifyContentRefreshTargets,
|
|
1005
|
+
notifyClientsAfterCmsSync,
|
|
1006
|
+
nextCronFireAfter,
|
|
1007
|
+
config2 as config,
|
|
1008
|
+
summariseCmsEntries,
|
|
1009
|
+
summariseCmsErrors,
|
|
1010
|
+
summariseCmsContentfulApiUsage,
|
|
1011
|
+
summariseTranslationEntries,
|
|
1012
|
+
syncCmsContent,
|
|
1013
|
+
syncTranslations
|
|
1014
|
+
};
|
|
1015
|
+
//# sourceMappingURL=chunk-POJRKC4G.js.map
|