@blinkk/root-cms 2.4.9 → 2.5.1

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/functions.js CHANGED
@@ -1,1639 +1,15 @@
1
- // core/functions.ts
2
- import path2 from "path";
3
- import { loadBundledConfig } from "@blinkk/root/node";
4
- import { onSchedule } from "firebase-functions/v2/scheduler";
5
-
6
- // core/client.ts
7
- import crypto from "crypto";
8
- import {
9
- FieldValue as FieldValue2,
10
- Timestamp as Timestamp2
11
- } from "firebase-admin/firestore";
12
-
13
- // core/translations-manager.ts
14
1
  import {
15
- FieldValue,
16
- Timestamp
17
- } from "firebase-admin/firestore";
18
-
19
- // shared/strings.ts
20
- import fnv from "fnv-plus";
21
- function normalizeStr(str) {
22
- const lines = str.trim().split("\n").map((line) => removeTrailingWhitespace(line));
23
- return lines.join("\n");
24
- }
25
- function removeTrailingWhitespace(str) {
26
- return str.trimEnd().replace(/ $/, "");
27
- }
28
- function hashStr(str) {
29
- if (!str || typeof str !== "string") {
30
- throw new Error("input string is invalid");
31
- }
32
- return fnv.fast1a52hex(normalizeStr(str));
33
- }
34
-
35
- // core/translations-manager.ts
36
- var TRANSLATIONS_DB_PATH_FORMAT = "/Projects/{project}/TranslationsManager/{mode}/Translations";
37
- var TRANSLATIONS_LOCALE_DOC_DB_PATH_FORMAT = `${TRANSLATIONS_DB_PATH_FORMAT}/{id}:{locale}`;
38
- var TranslationsManager = class {
39
- constructor(cmsClient) {
40
- this.cmsClient = cmsClient;
41
- }
42
- /**
43
- * Saves draft translations for a translations doc id.
44
- *
45
- * Example:
46
- * ```
47
- * const strings = {
48
- * 'one': {es: 'uno', fr: 'un'},
49
- * 'two': {es: 'dos', fr: 'deux'},
50
- * };
51
- * await tm.saveTranslations('Pages/index', strings);
52
- * ```
53
- */
54
- async saveTranslations(id, strings, options) {
55
- const mode = "draft";
56
- const localesSet = /* @__PURE__ */ new Set();
57
- Object.values(strings).forEach((entry) => {
58
- Object.keys(entry).forEach((locale) => {
59
- if (locale !== "source") {
60
- localesSet.add(locale);
61
- }
62
- });
63
- });
64
- const batch = this.cmsClient.db.batch();
65
- const locales = Array.from(localesSet);
66
- locales.forEach((locale) => {
67
- const updates = {
68
- id,
69
- locale,
70
- sys: {
71
- modifiedAt: Timestamp.now(),
72
- modifiedBy: options?.modifiedBy || "root-cms-client"
73
- },
74
- strings: {}
75
- };
76
- if (options?.tags && options.tags.length > 0) {
77
- updates.tags = FieldValue.arrayUnion(...options.tags);
78
- }
79
- let numUpdates = 0;
80
- const hashMap = this.toLocaleDocHashMap(strings, locale);
81
- Object.entries(hashMap).forEach(([hash, translations]) => {
82
- Object.entries(translations).forEach(([locale2, translation]) => {
83
- if (translation) {
84
- updates.strings[hash] ??= {};
85
- updates.strings[hash][locale2] = translation;
86
- numUpdates += 1;
87
- }
88
- });
89
- });
90
- if (numUpdates > 0) {
91
- const localeDocPath = buildTranslationsLocaleDocDbPath({
92
- project: this.cmsClient.projectId,
93
- mode,
94
- id,
95
- locale
96
- });
97
- const localeDocRef = this.cmsClient.db.doc(localeDocPath);
98
- batch.set(localeDocRef, updates, { merge: true });
99
- }
100
- });
101
- await batch.commit();
102
- }
103
- /**
104
- * Publishes a translations doc.
105
- */
106
- async publishTranslations(id, options) {
107
- const db = this.cmsClient.db;
108
- const project = this.cmsClient.projectId;
109
- const draftPath = buildTranslationsDbPath({ project, mode: "draft" });
110
- const query = db.collection(draftPath).where("id", "==", id);
111
- const res = await query.get();
112
- if (res.size === 0) {
113
- console.warn(`no translations to publish for ${id}`);
114
- return;
115
- }
116
- const batch = options?.batch || db.batch();
117
- res.docs.forEach((doc) => {
118
- const translationsLocaleDoc = doc.data();
119
- const sys = {
120
- ...translationsLocaleDoc.sys,
121
- publishedAt: Timestamp.now(),
122
- publishedBy: options?.publishedBy || "root-cms-client"
123
- };
124
- batch.update(doc.ref, { sys });
125
- const publishedDocPath = buildTranslationsLocaleDocDbPath({
126
- project,
127
- mode: "published",
128
- id: translationsLocaleDoc.id,
129
- locale: translationsLocaleDoc.locale
130
- });
131
- const publishedDocRef = db.doc(publishedDocPath);
132
- batch.set(publishedDocRef, { ...translationsLocaleDoc, sys });
133
- });
134
- const shouldCommitBatch = !options?.batch;
135
- if (shouldCommitBatch) {
136
- await batch.commit();
137
- }
138
- }
139
- /**
140
- * Fetches translations from one or more translations docs in the translations
141
- * manager.
142
- *
143
- * Example:
144
- * ```
145
- * await tm.loadTranslations();
146
- * // =>
147
- * // {
148
- * // "one": {"es": "uno", "fr": "un"},
149
- * // "two": {"es": "dos", "fr": "deux"}
150
- * // }
151
- * ```
152
- *
153
- * To load a specific set of translations docs by id:
154
- * ```
155
- * const translationsToLoad = ['Global/strings', 'Global/header', 'Global/footer', 'Pages/index'];
156
- * await tm.loadTranslations({ids: translationsToLoad});
157
- * // =>
158
- * // {
159
- * // "one": {"es": "uno", "fr": "un"},
160
- * // "two": {"es": "dos", "fr": "deux"}
161
- * // }
162
- * ```
163
- *
164
- * To load a subset of locales (more performant):
165
- * ```
166
- * await tm.loadTranslations({locales: ['es']});
167
- * // =>
168
- * // {
169
- * // "one": {"es": "uno"},
170
- * // "two": {"es": "dos"}
171
- * // }
172
- * ```
173
- */
174
- async loadTranslations(options) {
175
- const mode = options?.mode || "published";
176
- const dbPath = buildTranslationsDbPath({
177
- project: this.cmsClient.projectId,
178
- mode
179
- });
180
- let query = this.cmsClient.db.collection(dbPath);
181
- if (options?.ids && options.ids.length > 0) {
182
- query = query.where("id", "in", options.ids);
183
- }
184
- if (options?.tags && options.tags.length > 0) {
185
- query = query.where("tags", "array-contains", options.tags);
186
- }
187
- if (options?.locales && options.locales.length > 0) {
188
- query = query.where("locale", "in", options.locales);
189
- }
190
- const results = await query.get();
191
- const strings = {};
192
- results.forEach((result) => {
193
- const localeDoc = result.data();
194
- Object.values(localeDoc.strings || {}).forEach((item) => {
195
- strings[item.source] ??= { source: item.source };
196
- strings[item.source][localeDoc.locale] = item.translation;
197
- });
198
- });
199
- return strings;
200
- }
201
- /**
202
- * Fetches translations for a given locale, with optional fallbacks.
203
- * The return value is a map of source string to translated string.
204
- *
205
- * Example:
206
- * ```
207
- * await translationsDoc.loadTranslationsForLocale('es');
208
- * // =>
209
- * // {
210
- * // "one": "uno",
211
- * // "two": "dos",
212
- * // }
213
- * ```
214
- */
215
- async loadTranslationsForLocale(locale, options) {
216
- const localeSet = /* @__PURE__ */ new Set([
217
- locale,
218
- ...options?.fallbackLocales || []
219
- ]);
220
- const fallbackLocales = Array.from(localeSet);
221
- const multiLocaleStrings = await this.loadTranslations({
222
- mode: options?.mode,
223
- locales: fallbackLocales
224
- });
225
- return this.toSingleLocaleMap(multiLocaleStrings, fallbackLocales);
226
- }
227
- /**
228
- * Converts a multi-locale translations map to a flat single-locale map,
229
- * with optional support for fallback locales.
230
- *
231
- * ```
232
- * const multiLocaleStrings = {
233
- * 'one': {es: 'uno', fr: 'un'},
234
- * 'two': {es: 'dos', fr: 'deux'}
235
- * };
236
- * translationsDoc.toSingleLocaleMap(multiLocaleStrings, ['es']);
237
- * // =>
238
- * // {
239
- * // "one": "uno",
240
- * // "two": "dos",
241
- * // }
242
- * ```
243
- */
244
- toSingleLocaleMap(multiLocaleStrings, fallbackLocales) {
245
- const singleLocaleStrings = {};
246
- Object.entries(multiLocaleStrings).forEach(([source, translations]) => {
247
- let translation = source;
248
- for (const locale of fallbackLocales) {
249
- if (translations[locale]) {
250
- translation = translations[locale];
251
- break;
252
- }
253
- }
254
- singleLocaleStrings[source] = translation;
255
- });
256
- return singleLocaleStrings;
257
- }
258
- /**
259
- * Converts a multi-locale translations map to a single-locale hashed version,
260
- * used for storage in in the DB.
261
- *
262
- * ```
263
- * const multiLocaleStrings = {
264
- * 'one': {es: 'uno', fr: 'un'},
265
- * 'two': {es: 'dos', fr: 'deux'}
266
- * };
267
- * translationsDoc.toLocaleDocHashMap(multiLocaleStrings, 'es');
268
- * // =>
269
- * // {
270
- * // "<hash1>": {"source": "one", "translation": "uno"},
271
- * // "<hash2>": {"source": "two", "translation": "dos"},
272
- * // }
273
- * ```
274
- *
275
- * One reason for using hashes is because the DB has limits on the number of
276
- * chars that can be used as the "key" in a object map.
277
- */
278
- toLocaleDocHashMap(multiLocaleStrings, locale) {
279
- const hashMap = {};
280
- Object.entries(multiLocaleStrings).forEach(([source, translations]) => {
281
- const translation = translations[locale];
282
- if (translation) {
283
- const hash = hashStr(source);
284
- hashMap[hash] = { source, translation };
285
- }
286
- });
287
- return hashMap;
288
- }
289
- /**
290
- * Import translations from the v1 system to the TranslationsManager.
291
- */
292
- async importTranslationsFromV1() {
293
- const projectId = this.cmsClient.projectId;
294
- const db = this.cmsClient.db;
295
- const dbPath = `Projects/${projectId}/Translations`;
296
- const query = db.collection(dbPath);
297
- const querySnapshot = await query.get();
298
- if (querySnapshot.size === 0) {
299
- return;
300
- }
301
- console.log(
302
- "[root cms] importing v1 Translations to v2 TranslationsManager"
303
- );
304
- const translationsDocs = {};
305
- querySnapshot.forEach((doc) => {
306
- const translation = doc.data();
307
- const source = this.cmsClient.normalizeString(translation.source);
308
- delete translation.source;
309
- const tags = translation.tags || [];
310
- delete translation.tags;
311
- for (const tag of tags) {
312
- if (tag.includes("/")) {
313
- const translationsId = tag;
314
- translationsDocs[translationsId] ??= {
315
- id: translationsId,
316
- tags,
317
- strings: {}
318
- };
319
- translationsDocs[translationsId].strings[source] = translation;
320
- }
321
- }
322
- });
323
- if (Object.keys(translationsDocs).length === 0) {
324
- console.log("[root cms] no v1 translations to save");
325
- return;
326
- }
327
- for (const docId in translationsDocs) {
328
- const [collection, slug] = docId.split("/");
329
- if (collection && slug) {
330
- const doc = await this.cmsClient.getDoc(collection, slug, {
331
- mode: "draft"
332
- });
333
- const linkedSheet = doc?.sys?.l10nSheet;
334
- if (linkedSheet) {
335
- translationsDocs[docId].sys.linkedSheet = linkedSheet;
336
- }
337
- }
338
- }
339
- Object.entries(translationsDocs).forEach(([translationsId, data]) => {
340
- const len = Object.keys(data.strings).length;
341
- console.log(`[root cms] saving ${len} string(s) to ${translationsId}...`);
342
- this.saveTranslations(translationsId, data.strings, {
343
- tags: data.tags || [translationsId]
344
- });
345
- });
346
- }
347
- };
348
- function buildTranslationsDbPath(options) {
349
- return TRANSLATIONS_DB_PATH_FORMAT.replace(
350
- "{project}",
351
- options.project
352
- ).replace("{mode}", options.mode);
353
- }
354
- function buildTranslationsLocaleDocDbPath(options) {
355
- return TRANSLATIONS_LOCALE_DOC_DB_PATH_FORMAT.replace(
356
- "{project}",
357
- options.project
358
- ).replace("{mode}", options.mode).replace("{id}", options.id.replaceAll("/", "--")).replace("{locale}", options.locale);
359
- }
360
-
361
- // core/client.ts
362
- var RootCMSClient = class {
363
- constructor(rootConfig) {
364
- this.rootConfig = rootConfig;
365
- this.cmsPlugin = getCmsPlugin(this.rootConfig);
366
- const cmsPluginOptions = this.cmsPlugin.getConfig();
367
- this.projectId = cmsPluginOptions.id || "default";
368
- this.app = this.cmsPlugin.getFirebaseApp();
369
- this.db = this.cmsPlugin.getFirestore();
370
- }
371
- /**
372
- * Retrieves doc data from Root.js CMS.
373
- */
374
- async getDoc(collectionId, slug, options) {
375
- const rawData = await this.getRawDoc(collectionId, slug, options);
376
- if (rawData) {
377
- return unmarshalData(rawData);
378
- }
379
- return null;
380
- }
381
- /**
382
- * Retrieves raw doc data as stored in the database. Only use this if you know
383
- * what you are doing.
384
- */
385
- async getRawDoc(collectionId, slug, options) {
386
- const mode = options.mode;
387
- const modeCollection = mode === "draft" ? "Drafts" : "Published";
388
- slug = slug.replaceAll("/", "--");
389
- const dbPath = `Projects/${this.projectId}/Collections/${collectionId}/${modeCollection}/${slug}`;
390
- const docRef = this.db.doc(dbPath);
391
- const doc = await docRef.get();
392
- if (doc.exists) {
393
- return doc.data();
394
- }
395
- return null;
396
- }
397
- /**
398
- * Firestore path for a collection.
399
- */
400
- dbCollectionDocsPath(collectionId, options) {
401
- let modeCollection = "";
402
- if (options.mode === "draft") {
403
- modeCollection = "Drafts";
404
- } else if (options.mode === "published") {
405
- modeCollection = "Published";
406
- } else {
407
- throw new Error(`unknown mode: ${options.mode}`);
408
- }
409
- return `Projects/${this.projectId}/Collections/${collectionId}/${modeCollection}`;
410
- }
411
- /**
412
- * Firestore path for a content doc.
413
- */
414
- dbDocPath(collectionId, slug, options) {
415
- const collectionDocsPath = this.dbCollectionDocsPath(collectionId, options);
416
- const normalizedSlug = slug.replaceAll("/", "--");
417
- return `${collectionDocsPath}/${normalizedSlug}`;
418
- }
419
- /**
420
- * Firestore doc ref for a content doc.
421
- */
422
- dbDocRef(collectionId, slug, options) {
423
- const docPath = this.dbDocPath(collectionId, slug, options);
424
- return this.db.doc(docPath);
425
- }
426
- /**
427
- * Saves draft data to a doc.
428
- *
429
- * Note: this saves data to the "fields" attr of the draft doc. If you need to
430
- * modify the sys-level attributes of the doc, use `setRawDoc()`.
431
- */
432
- async saveDraftData(docId, fieldsData, options) {
433
- const { collection, slug } = parseDocId(docId);
434
- const draftDoc = await this.getRawDoc(collection, slug, { mode: "draft" }) || {};
435
- const draftSys = draftDoc.sys || {};
436
- const modifiedBy = options?.modifiedBy || "root-cms-client";
437
- const fields = marshalData(fieldsData || {});
438
- const data = {
439
- id: docId,
440
- collection,
441
- slug,
442
- sys: {
443
- ...draftSys,
444
- createdAt: draftSys.createdAt ?? Timestamp2.now(),
445
- createdBy: draftSys.createdBy ?? modifiedBy,
446
- modifiedAt: Timestamp2.now(),
447
- modifiedBy,
448
- locales: options?.locales ?? draftSys.locales ?? ["en"]
449
- },
450
- fields
451
- };
452
- await this.setRawDoc(collection, slug, data, { mode: "draft" });
453
- }
454
- /**
455
- * Prefer `saveDraftData('Pages/foo', data)`. Only use this if you know what
456
- * you're doing.
457
- */
458
- async setRawDoc(collectionId, slug, data, options) {
459
- const mode = options.mode;
460
- const modeCollection = mode === "draft" ? "Drafts" : "Published";
461
- const dbPath = `Projects/${this.projectId}/Collections/${collectionId}/${modeCollection}/${slug}`;
462
- const docRef = this.db.doc(dbPath);
463
- await docRef.set(data);
464
- }
465
- /**
466
- * Lists docs from a Root.js CMS collection.
467
- */
468
- async listDocs(collectionId, options) {
469
- const mode = options.mode;
470
- const modeCollection = mode === "draft" ? "Drafts" : "Published";
471
- const dbPath = `Projects/${this.projectId}/Collections/${collectionId}/${modeCollection}`;
472
- let query = this.db.collection(dbPath);
473
- if (options.limit) {
474
- query = query.limit(options.limit);
475
- }
476
- if (options.offset) {
477
- query = query.offset(options.offset);
478
- }
479
- if (options.orderBy) {
480
- query = query.orderBy(options.orderBy, options.orderByDirection);
481
- }
482
- if (options.query) {
483
- query = options.query(query);
484
- }
485
- const results = await query.get();
486
- const docs = [];
487
- results.forEach((result) => {
488
- if (options.raw) {
489
- const rawDoc = result.data();
490
- docs.push(rawDoc);
491
- } else {
492
- const doc = unmarshalData(result.data());
493
- docs.push(doc);
494
- }
495
- });
496
- return { docs };
497
- }
498
- /**
499
- * Returns the number of docs in a Root.js CMS collection.
500
- */
501
- async getDocsCount(collectionId, options) {
502
- const mode = options.mode;
503
- const modeCollection = mode === "draft" ? "Drafts" : "Published";
504
- const dbPath = `Projects/${this.projectId}/Collections/${collectionId}/${modeCollection}`;
505
- let query = this.db.collection(dbPath);
506
- if (options.query) {
507
- query = options.query(query);
508
- }
509
- const results = await query.count().get();
510
- const count = results.data().count;
511
- return count;
512
- }
513
- /**
514
- * Batch publishes a set of docs by id.
515
- */
516
- async publishDocs(docIds, options) {
517
- const projectCollectionsPath = `Projects/${this.projectId}/Collections`;
518
- const publishedBy = options?.publishedBy || "root-cms-client";
519
- const docRefs = docIds.map((docId) => {
520
- const [collection, slug] = docId.split("/");
521
- if (!collection || !slug) {
522
- throw new Error(`invalid doc id: ${docId}`);
523
- }
524
- const docRef = this.db.doc(
525
- `${projectCollectionsPath}/${collection}/Drafts/${slug}`
526
- );
527
- return docRef;
528
- });
529
- const docSnapshots = await this.db.getAll(...docRefs);
530
- const docs = docSnapshots.map((snapshot) => snapshot.data()).filter((d) => !!d);
531
- if (docs.length === 0) {
532
- console.log("no docs to publish");
533
- return [];
534
- }
535
- for (const doc of docs) {
536
- if (this.testPublishingLocked(doc)) {
537
- throw new Error(`publishing is locked for doc: ${doc.id}`);
538
- }
539
- }
540
- let batchCount = 0;
541
- const batch = options?.batch || this.db.batch();
542
- const versionTags = ["published"];
543
- if (options?.releaseId) {
544
- versionTags.push(`release:${options.releaseId}`);
545
- }
546
- const publishedDocs = [];
547
- for (const doc of docs) {
548
- const { id, collection, slug, sys, fields } = doc;
549
- const draftRef = this.db.doc(
550
- `${projectCollectionsPath}/${collection}/Drafts/${slug}`
551
- );
552
- const scheduledRef = this.db.doc(
553
- `${projectCollectionsPath}/${collection}/Scheduled/${slug}`
554
- );
555
- const publishedRef = this.db.doc(
556
- `${projectCollectionsPath}/${collection}/Published/${slug}`
557
- );
558
- const firstPublishedAt = sys.firstPublishedAt ?? FieldValue2.serverTimestamp();
559
- const firstPublishedBy = sys.firstPublishedBy ?? publishedBy;
560
- batch.set(publishedRef, {
561
- id,
562
- collection,
563
- slug,
564
- fields: fields || {},
565
- sys: {
566
- ...sys,
567
- firstPublishedAt,
568
- firstPublishedBy,
569
- publishedAt: FieldValue2.serverTimestamp(),
570
- publishedBy
571
- }
572
- });
573
- batchCount += 1;
574
- const versionRef = this.db.doc(
575
- `${projectCollectionsPath}/${collection}/Drafts/${slug}/Versions/${Date.now()}`
576
- );
577
- const versionData = {
578
- id,
579
- collection,
580
- slug,
581
- fields: fields || {},
582
- sys: {
583
- ...sys,
584
- firstPublishedAt,
585
- firstPublishedBy,
586
- publishedAt: FieldValue2.serverTimestamp(),
587
- publishedBy
588
- }
589
- };
590
- if (versionTags.length) {
591
- versionData.tags = versionTags;
592
- }
593
- batch.set(versionRef, versionData);
594
- batchCount += 1;
595
- batch.delete(scheduledRef);
596
- batchCount += 1;
597
- batch.update(draftRef, {
598
- "sys.scheduledAt": FieldValue2.delete(),
599
- "sys.scheduledBy": FieldValue2.delete(),
600
- "sys.firstPublishedAt": firstPublishedAt,
601
- "sys.firstPublishedBy": firstPublishedBy,
602
- "sys.publishedAt": FieldValue2.serverTimestamp(),
603
- "sys.publishedBy": publishedBy
604
- });
605
- batchCount += 1;
606
- publishedDocs.push(doc);
607
- if (batchCount >= 400) {
608
- await batch.commit();
609
- batchCount = 0;
610
- }
611
- }
612
- if (batchCount > 0) {
613
- await batch.commit();
614
- }
615
- console.log(`published ${publishedDocs.length} docs!`);
616
- return publishedDocs;
617
- }
618
- /**
619
- * Publishes scheduled docs.
620
- */
621
- async publishScheduledDocs() {
622
- const projectCollectionsPath = `Projects/${this.projectId}/Collections`;
623
- const now = Math.ceil((/* @__PURE__ */ new Date()).getTime());
624
- const snapshot = await this.db.collectionGroup("Scheduled").get();
625
- const docs = snapshot.docs.filter((d) => {
626
- if (!d.ref.path.startsWith(projectCollectionsPath)) {
627
- return false;
628
- }
629
- const data = d.data() || {};
630
- const scheduledAt = data.sys?.scheduledAt;
631
- return scheduledAt && scheduledAt.toMillis && scheduledAt.toMillis() <= now;
632
- }).map((d) => {
633
- const dbPath = d.ref.path;
634
- const segments = dbPath.split("/");
635
- const slug = segments.at(-1);
636
- const collection = segments.at(-3);
637
- const id = `${collection}/${slug}`;
638
- return {
639
- data: d.data(),
640
- id,
641
- collection,
642
- slug
643
- };
644
- });
645
- if (docs.length === 0) {
646
- console.log("no docs to schedule");
647
- return [];
648
- }
649
- let batchCount = 0;
650
- const batch = this.db.batch();
651
- const versionTags = ["published"];
652
- const publishedDocs = [];
653
- for (const doc of docs) {
654
- const { id, collection, slug, data } = doc;
655
- const draftRef = this.db.doc(
656
- `${projectCollectionsPath}/${collection}/Drafts/${slug}`
657
- );
658
- const scheduledRef = this.db.doc(
659
- `${projectCollectionsPath}/${collection}/Scheduled/${slug}`
660
- );
661
- const publishedRef = this.db.doc(
662
- `${projectCollectionsPath}/${collection}/Published/${slug}`
663
- );
664
- const { scheduledAt, scheduledBy, ...sys } = data.sys || {};
665
- const firstPublishedAt = sys.firstPublishedAt ?? scheduledAt;
666
- const firstPublishedBy = sys.firstPublishedBy ?? (scheduledBy || "root-cms-client");
667
- batch.set(publishedRef, {
668
- id,
669
- collection,
670
- slug,
671
- fields: data.fields || {},
672
- sys: {
673
- ...sys,
674
- firstPublishedAt,
675
- firstPublishedBy,
676
- publishedAt: FieldValue2.serverTimestamp(),
677
- publishedBy: scheduledBy || ""
678
- }
679
- });
680
- batchCount += 1;
681
- const versionRef = this.db.doc(
682
- `${projectCollectionsPath}/${collection}/Drafts/${slug}/Versions/${Date.now()}`
683
- );
684
- const versionData = {
685
- id,
686
- collection,
687
- slug,
688
- fields: data.fields || {},
689
- sys: {
690
- ...sys,
691
- firstPublishedAt,
692
- firstPublishedBy,
693
- publishedAt: FieldValue2.serverTimestamp(),
694
- publishedBy: scheduledBy || ""
695
- },
696
- tags: versionTags
697
- };
698
- batch.set(versionRef, versionData);
699
- batchCount += 1;
700
- batch.delete(scheduledRef);
701
- batchCount += 1;
702
- batch.update(draftRef, {
703
- "sys.scheduledAt": FieldValue2.delete(),
704
- "sys.scheduledBy": FieldValue2.delete(),
705
- "sys.firstPublishedAt": firstPublishedAt,
706
- "sys.firstPublishedBy": firstPublishedBy,
707
- "sys.publishedAt": FieldValue2.serverTimestamp(),
708
- "sys.publishedBy": scheduledBy || "root-cms-client"
709
- });
710
- batchCount += 1;
711
- publishedDocs.push(doc);
712
- if (batchCount >= 400) {
713
- await batch.commit();
714
- batchCount = 0;
715
- continue;
716
- }
717
- }
718
- if (batchCount > 0) {
719
- await batch.commit();
720
- }
721
- console.log(`published ${publishedDocs.length} docs!`);
722
- return publishedDocs;
723
- }
724
- /**
725
- * Publishes docs in scheduled releases.
726
- */
727
- async publishScheduledReleases() {
728
- const releasesPath = `Projects/${this.projectId}/Releases`;
729
- const now = Math.ceil((/* @__PURE__ */ new Date()).getTime());
730
- const query = this.db.collection(releasesPath).where("scheduledAt", "<=", Timestamp2.fromMillis(now));
731
- const querySnapshot = await query.get();
732
- for (const snapshot of querySnapshot.docs) {
733
- const release = snapshot.data();
734
- const batch = this.db.batch();
735
- const publishedBy = release.scheduledBy || "root-cms-client";
736
- batch.update(snapshot.ref, {
737
- publishedAt: Timestamp2.now(),
738
- publishedBy,
739
- scheduledAt: FieldValue2.delete(),
740
- scheduledBy: FieldValue2.delete()
741
- });
742
- const dataSourceIds = release.dataSourceIds || [];
743
- if (dataSourceIds.length > 0) {
744
- await this.publishDataSources(dataSourceIds, {
745
- publishedBy,
746
- batch,
747
- commitBatch: false
748
- });
749
- }
750
- await this.publishDocs(release.docIds || [], {
751
- publishedBy,
752
- batch,
753
- releaseId: release.id
754
- });
755
- }
756
- }
757
- /**
758
- * Checks if a doc is currently "locked" for publishing.
759
- */
760
- testPublishingLocked(doc) {
761
- if (doc.sys?.publishingLocked) {
762
- if (doc.sys.publishingLocked.until) {
763
- const now = Timestamp2.now().toMillis();
764
- const until = doc.sys.publishingLocked.until.toMillis();
765
- return now < until;
766
- }
767
- return true;
768
- }
769
- return false;
770
- }
771
- /**
772
- * Returns a `TranslationsManager` object for managing translations.
773
- *
774
- * To get translations:
775
- * ```
776
- * await tm.loadTranslations({
777
- * ids: ['Global/strings', 'Pages/index'],
778
- * locales: ['es'],
779
- * });
780
- * ```
781
- *
782
- * NOTE: The `TranslationsManager` is a v2 feature that will eventually
783
- * replace the v1 translations system.
784
- */
785
- getTranslationsManager() {
786
- const cmsPluginOptions = this.cmsPlugin.getConfig();
787
- if (cmsPluginOptions.experiments?.v2TranslationsManager) {
788
- throw new Error(
789
- "`v2TranslationsManager` is not enabled. update root.config.ts and add: `{experiments: {v2TranslationsManager: true}}`"
790
- );
791
- }
792
- return new TranslationsManager(this);
793
- }
794
- /**
795
- * Loads translations saved in the translations collection, optionally
796
- * filtered by tag.
797
- *
798
- * Returns a map like:
799
- * ```
800
- * {
801
- * "<hash>": {"source": "Hello", "es": "Hola", "fr": "Bonjour"},
802
- * }
803
- * ```
804
- */
805
- async loadTranslations(options) {
806
- const dbPath = `Projects/${this.projectId}/Translations`;
807
- let query = this.db.collection(dbPath);
808
- if (options?.tags) {
809
- query = query.where("tags", "array-contains-any", options.tags);
810
- }
811
- const querySnapshot = await query.get();
812
- const translationsMap = {};
813
- querySnapshot.forEach((doc) => {
814
- const hash = doc.id;
815
- translationsMap[hash] = doc.data();
816
- });
817
- return translationsMap;
818
- }
819
- /**
820
- * Saves a map of translations, e.g.:
821
- * ```
822
- * await client.saveTranslations({
823
- * "Hello": {"es": "Hola", "fr": "Bonjour"},
824
- * });
825
- * ```
826
- */
827
- async saveTranslations(translations, tags) {
828
- const translationsPath = `Projects/${this.projectId}/Translations`;
829
- const batch = this.db.batch();
830
- let batchCount = 0;
831
- Object.entries(translations).forEach(([source, sourceTranslations]) => {
832
- const hash = this.getTranslationKey(source);
833
- const translationRef = this.db.doc(`${translationsPath}/${hash}`);
834
- const data = {
835
- ...sourceTranslations,
836
- source: this.normalizeString(source)
837
- };
838
- if (tags) {
839
- data.tags = FieldValue2.arrayUnion(...tags);
840
- }
841
- batch.set(translationRef, data, { merge: true });
842
- batchCount += 1;
843
- });
844
- if (batchCount > 500) {
845
- throw new Error("up to 500 translations can be saved at a time.");
846
- }
847
- await batch.commit();
848
- }
849
- /**
850
- * Returns the "key" used for a translation as stored in the db. Translations
851
- * are stored under `Projects/<project id>/Translations/<sha1 hash>`.
852
- */
853
- getTranslationKey(source) {
854
- const sha1 = crypto.createHash("sha1").update(this.normalizeString(source)).digest("hex");
855
- return sha1;
856
- }
857
- /**
858
- * Cleans a string that's used for translations. Performs the following:
859
- * - Removes any leading/trailing whitespace
860
- * - Removes spaces at the end of any line
861
- */
862
- normalizeString(str) {
863
- const lines = String(str).trim().split("\n").map((line) => line.trimEnd());
864
- return lines.join("\n");
865
- }
866
- /**
867
- * Loads translations for a particular locale.
868
- *
869
- * Returns a map like:
870
- * ```
871
- * {
872
- * "Hello": "Bonjour",
873
- * }
874
- * ```
875
- */
876
- async loadTranslationsForLocale(locale, options) {
877
- const translationsMap = await this.loadTranslations(options);
878
- return translationsForLocale(translationsMap, locale);
879
- }
880
- /**
881
- * Firestore path for a translations file.
882
- */
883
- dbTranslationsPath(translationsId, options) {
884
- const mode = options.mode;
885
- if (!(mode === "draft" || mode === "published")) {
886
- throw new Error(`invalid mode: ${mode}`);
887
- }
888
- const slug = translationsId.replaceAll("/", "--");
889
- const dbPath = `Projects/${this.projectId}/TranslationsManager/${mode}/Translations/${slug}`;
890
- return dbPath;
891
- }
892
- /**
893
- * Firestore doc ref for a translations file.
894
- */
895
- dbTranslationsRef(translationsId, options) {
896
- const dbPath = this.dbTranslationsPath(translationsId, options);
897
- return this.db.doc(dbPath);
898
- }
899
- /**
900
- * Returns a data source configuration object.
901
- */
902
- async getDataSource(dataSourceId) {
903
- const dbPath = `Projects/${this.projectId}/DataSources/${dataSourceId}`;
904
- const docRef = this.db.doc(dbPath);
905
- const doc = await docRef.get();
906
- if (doc.exists) {
907
- return doc.data();
908
- }
909
- return null;
910
- }
911
- /**
912
- * Syncs a data source to draft state.
913
- */
914
- async syncDataSource(dataSourceId, options) {
915
- const dataSource = await this.getDataSource(dataSourceId);
916
- if (!dataSource) {
917
- throw new Error(`data source not found: ${dataSourceId}`);
918
- }
919
- const data = await this.fetchData(dataSource);
920
- const dataSourceDocRef = this.db.doc(
921
- `Projects/${this.projectId}/DataSources/${dataSourceId}`
922
- );
923
- const dataDocRef = this.db.doc(
924
- `Projects/${this.projectId}/DataSources/${dataSourceId}/Data/draft`
925
- );
926
- const syncedBy = options?.syncedBy || "root-cms-client";
927
- const updatedDataSource = {
928
- ...dataSource,
929
- syncedAt: Timestamp2.now(),
930
- syncedBy
931
- };
932
- const batch = this.db.batch();
933
- batch.set(dataDocRef, {
934
- dataSource: updatedDataSource,
935
- data
936
- });
937
- batch.update(dataSourceDocRef, {
938
- syncedAt: Timestamp2.now(),
939
- syncedBy
940
- });
941
- await batch.commit();
942
- console.log(`synced data source: ${dataSourceId}`);
943
- console.log(`synced by: ${syncedBy}`);
944
- }
945
- async publishDataSource(dataSourceId, options) {
946
- const dataSource = await this.getDataSource(dataSourceId);
947
- if (!dataSource) {
948
- throw new Error(`data source not found: ${dataSourceId}`);
949
- }
950
- const dataSourceDocRef = this.db.doc(
951
- `Projects/${this.projectId}/DataSources/${dataSourceId}`
952
- );
953
- const dataDocRefDraft = this.db.doc(
954
- `Projects/${this.projectId}/DataSources/${dataSourceId}/draft`
955
- );
956
- const dataDocRefPublished = this.db.doc(
957
- `Projects/${this.projectId}/DataSources/${dataSourceId}/published`
958
- );
959
- const dataRes = await this.getFromDataSource(dataSourceId, { mode: "draft" });
960
- const publishedBy = options?.publishedBy || "root-cms-client";
961
- const updatedDataSource = {
962
- ...dataSource,
963
- publishedAt: Timestamp2.now(),
964
- publishedBy
965
- };
966
- const batch = this.db.batch();
967
- batch.set(dataDocRefPublished, {
968
- dataSource: updatedDataSource,
969
- data: dataRes?.data || null,
970
- ...dataRes?.headers ? { headers: dataRes.headers } : {}
971
- });
972
- batch.update(dataDocRefDraft, {
973
- dataSource: updatedDataSource
974
- });
975
- batch.update(dataSourceDocRef, {
976
- publishedAt: Timestamp2.now(),
977
- publishedBy
978
- });
979
- await batch.commit();
980
- console.log(`published data ${dataSourceId}`);
981
- console.log(`published by: ${publishedBy}`);
982
- }
983
- async publishDataSources(dataSourceIds, options) {
984
- const publishedBy = options?.publishedBy || "root-cms-client";
985
- const batch = options?.batch || this.db.batch();
986
- for (const id of dataSourceIds) {
987
- const dataSource = await this.getDataSource(id);
988
- if (!dataSource) {
989
- throw new Error(`data source not found: ${id}`);
990
- }
991
- const dataSourceDocRef = this.db.doc(
992
- `Projects/${this.projectId}/DataSources/${id}`
993
- );
994
- const dataDocRefDraft = this.db.doc(
995
- `Projects/${this.projectId}/DataSources/${id}/draft`
996
- );
997
- const dataDocRefPublished = this.db.doc(
998
- `Projects/${this.projectId}/DataSources/${id}/published`
999
- );
1000
- const dataRes = await this.getFromDataSource(id, { mode: "draft" });
1001
- const updatedDataSource = {
1002
- ...dataSource,
1003
- publishedAt: FieldValue2.serverTimestamp(),
1004
- publishedBy
1005
- };
1006
- batch.set(dataDocRefPublished, {
1007
- dataSource: updatedDataSource,
1008
- data: dataRes?.data || null,
1009
- ...dataRes?.headers ? { headers: dataRes.headers } : {}
1010
- });
1011
- batch.update(dataDocRefDraft, { dataSource: updatedDataSource });
1012
- batch.update(dataSourceDocRef, {
1013
- publishedAt: FieldValue2.serverTimestamp(),
1014
- publishedBy
1015
- });
1016
- }
1017
- if (!options?.batch || options?.commitBatch) {
1018
- await batch.commit();
1019
- }
1020
- }
1021
- async fetchData(dataSource) {
1022
- if (dataSource.type === "http") {
1023
- return await this.fetchHttpData(dataSource);
1024
- }
1025
- throw new Error(`unsupported data source: ${dataSource.type}`);
1026
- }
1027
- async fetchHttpData(dataSource) {
1028
- const url = dataSource.url || "";
1029
- if (!url.startsWith("https://")) {
1030
- throw new Error(`url not supported: ${url}`);
1031
- }
1032
- const res = await fetch(url, {
1033
- method: dataSource.httpOptions?.method || "GET",
1034
- headers: dataSource.httpOptions?.headers || [],
1035
- body: dataSource.httpOptions?.body || void 0
1036
- });
1037
- if (res.status !== 200) {
1038
- const err = await res.text();
1039
- throw new Error(`req failed: ${err}`);
1040
- }
1041
- const contentType = String(res.headers.get("content-type"));
1042
- if (contentType.includes("application/json")) {
1043
- return await res.json();
1044
- }
1045
- return res.text();
1046
- }
1047
- /**
1048
- * Fetches data from a data source.
1049
- */
1050
- async getFromDataSource(dataSourceId, options) {
1051
- const mode = options?.mode || "published";
1052
- if (!(mode === "draft" || mode === "published")) {
1053
- throw new Error(`invalid mode: ${mode}`);
1054
- }
1055
- if (!dataSourceId || dataSourceId.includes("/")) {
1056
- throw new Error(`invalid data source id: ${dataSourceId}`);
1057
- }
1058
- const docRef = this.dbDataSourceDataRef(dataSourceId, { mode });
1059
- const doc = await docRef.get();
1060
- if (doc.exists) {
1061
- return doc.data();
1062
- }
1063
- return null;
1064
- }
1065
- /**
1066
- * Firestore path for a datasource data.
1067
- */
1068
- dbDataSourceDataPath(dataSourceId, options) {
1069
- if (!dataSourceId || dataSourceId.includes("/")) {
1070
- throw new Error(`invalid data source id: ${dataSourceId}`);
1071
- }
1072
- const mode = options.mode;
1073
- if (!(mode === "draft" || mode === "published")) {
1074
- throw new Error(`invalid mode: ${mode}`);
1075
- }
1076
- const dbPath = `Projects/${this.projectId}/DataSources/${dataSourceId}/Data/${mode}`;
1077
- return dbPath;
1078
- }
1079
- /**
1080
- * Firestore doc ref for a datasource data.
1081
- */
1082
- dbDataSourceDataRef(dataSourceId, options) {
1083
- const dbPath = this.dbDataSourceDataPath(dataSourceId, options);
1084
- return this.db.doc(dbPath);
1085
- }
1086
- /**
1087
- * Gets the user's role from the project's ACL.
1088
- */
1089
- async getUserRole(email) {
1090
- if (!email) {
1091
- return null;
1092
- }
1093
- const docRef = this.db.doc(`Projects/${this.projectId}`);
1094
- const snapshot = await docRef.get();
1095
- const data = snapshot.data() || {};
1096
- const acl = data.roles || {};
1097
- if (email in acl) {
1098
- return acl[email];
1099
- }
1100
- if (!email.includes("@")) {
1101
- console.warn(`invalid email: ${email}`);
1102
- return null;
1103
- }
1104
- const domain = email.split("@").at(-1);
1105
- if (domain && `*@${domain}` in acl) {
1106
- return acl[`*@${domain}`];
1107
- }
1108
- return null;
1109
- }
1110
- /**
1111
- * Verifies user exists in the ACL list.
1112
- */
1113
- async userExistsInAcl(email) {
1114
- if (!email) {
1115
- return false;
1116
- }
1117
- const docRef = this.db.doc(`Projects/${this.projectId}`);
1118
- const snapshot = await docRef.get();
1119
- const data = snapshot.data() || {};
1120
- const acl = data.roles || {};
1121
- if (email in acl) {
1122
- return true;
1123
- }
1124
- if (!email.includes("@")) {
1125
- console.warn(`invalid email: ${email}`);
1126
- return false;
1127
- }
1128
- const domain = email.split("@").at(-1);
1129
- if (domain && `*@${domain}` in acl) {
1130
- return true;
1131
- }
1132
- return false;
1133
- }
1134
- async logAction(action, options) {
1135
- if (!action) {
1136
- throw new Error('missing required: "action"');
1137
- }
1138
- const data = {
1139
- action,
1140
- timestamp: Timestamp2.now(),
1141
- by: options?.by || "system",
1142
- metadata: options?.metadata || {}
1143
- };
1144
- if (options?.links) {
1145
- data.links = options.links;
1146
- }
1147
- const colRef = this.db.collection(`Projects/${this.projectId}/ActionLogs`);
1148
- await colRef.add(data);
1149
- const metaStr = options?.metadata ? stringifyObj(options.metadata) : "";
1150
- console.log(`[${data.timestamp.toMillis()}] action: ${action} ${metaStr}`);
1151
- const cmsPluginConfig = this.cmsPlugin.getConfig();
1152
- if (cmsPluginConfig.onAction) {
1153
- try {
1154
- cmsPluginConfig.onAction(data);
1155
- } catch (err) {
1156
- console.error(err);
1157
- }
1158
- }
1159
- }
1160
- /**
1161
- * Creates a batch request that is capable of fetching one or more docs,
1162
- * corresponding translations, and dataSources.
1163
- */
1164
- createBatchRequest(options) {
1165
- return new BatchRequest(this, options);
1166
- }
1167
- };
1168
- function isRichTextData(data) {
1169
- return Boolean(
1170
- isObject(data) && Array.isArray(data.blocks) && data.time && data.version
1171
- );
1172
- }
1173
- function getCmsPlugin(rootConfig) {
1174
- const plugins = rootConfig.plugins || [];
1175
- const plugin = plugins.find((plugin2) => plugin2.name === "root-cms");
1176
- if (!plugin) {
1177
- throw new Error("could not find root-cms plugin config in root.config.ts");
1178
- }
1179
- return plugin;
1180
- }
1181
- function marshalData(data) {
1182
- if (isRichTextData(data)) {
1183
- return data;
1184
- }
1185
- const result = {};
1186
- for (const key in data) {
1187
- const val = data[key];
1188
- if (isObject(val)) {
1189
- result[key] = marshalData(val);
1190
- } else if (Array.isArray(val)) {
1191
- if (val.length > 0 && val.some((item) => isObject(item))) {
1192
- const items = val.map((item) => {
1193
- if (isObject(item)) {
1194
- return marshalData(item);
1195
- }
1196
- return item;
1197
- });
1198
- result[key] = toArrayObject(items);
1199
- } else {
1200
- result[key] = val;
1201
- }
1202
- } else {
1203
- result[key] = val;
1204
- }
1205
- }
1206
- return result;
1207
- }
1208
- function unmarshalData(data) {
1209
- const result = {};
1210
- for (const key in data) {
1211
- const val = data[key];
1212
- if (isObject(val)) {
1213
- if (val.toMillis) {
1214
- result[key] = val.toMillis();
1215
- } else if (Object.hasOwn(val, "_array") && Array.isArray(val._array)) {
1216
- const arr = val._array.map((arrayKey) => {
1217
- return {
1218
- ...unmarshalData(val[arrayKey] || {}),
1219
- _arrayKey: arrayKey
1220
- };
1221
- });
1222
- result[key] = arr;
1223
- } else {
1224
- result[key] = unmarshalData(val);
1225
- }
1226
- } else {
1227
- result[key] = val;
1228
- }
1229
- }
1230
- return result;
1231
- }
1232
- function toArrayObject(arr) {
1233
- if (!Array.isArray(arr)) {
1234
- return arr;
1235
- }
1236
- const arrObject = { _array: [] };
1237
- for (const item of arr) {
1238
- const key = randString(6);
1239
- arrObject[key] = item;
1240
- arrObject._array.push(key);
1241
- }
1242
- return arrObject;
1243
- }
1244
- function isObject(data) {
1245
- return typeof data === "object" && !Array.isArray(data) && data !== null;
1246
- }
1247
- function randString(len) {
1248
- const result = [];
1249
- const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1250
- for (let i = 0; i < len; i++) {
1251
- const rand = Math.floor(Math.random() * chars.length);
1252
- result.push(chars.charAt(rand));
1253
- }
1254
- return result.join("");
1255
- }
1256
- function translationsForLocale(translationsMap, locale) {
1257
- const localeTranslations = {};
1258
- Object.values(translationsMap).forEach((string) => {
1259
- const source = string.source;
1260
- const translation = string[locale] || string.en || string.source;
1261
- localeTranslations[source] = translation;
1262
- });
1263
- return localeTranslations;
1264
- }
1265
- function stringifyObj(obj) {
1266
- function format(obj2) {
1267
- if (obj2 === null) {
1268
- return "null";
1269
- }
1270
- if (typeof obj2 === "undefined") {
1271
- return "undefined";
1272
- }
1273
- if (typeof obj2 === "string") {
1274
- return `"${obj2.replaceAll('"', '\\"')}"`;
1275
- }
1276
- if (typeof obj2 !== "object") {
1277
- return String(obj2);
1278
- }
1279
- if (Array.isArray(obj2)) {
1280
- return `[${obj2.map(format).join(", ")}]`;
1281
- }
1282
- const entries = Object.entries(obj2).map(([key, value]) => {
1283
- return `${key}: ${format(value)}`;
1284
- });
1285
- return `{${entries.join(", ")}}`;
1286
- }
1287
- return format(obj);
1288
- }
1289
- function parseDocId(docId) {
1290
- const sepIndex = docId.indexOf("/");
1291
- if (sepIndex <= 0) {
1292
- throw new Error(`invalid doc id: ${docId}`);
1293
- }
1294
- const collection = docId.slice(0, sepIndex);
1295
- const slug = docId.slice(sepIndex + 1).replaceAll("/", "--");
1296
- if (!collection || !slug) {
1297
- throw new Error(`invalid doc id: ${docId}`);
1298
- }
1299
- return { collection, slug };
1300
- }
1301
- var BatchRequest = class {
1302
- constructor(cmsClient, options) {
1303
- this.docIds = [];
1304
- this.dataSourceIds = [];
1305
- this.queries = [];
1306
- this.translationsIds = [];
1307
- this.cmsClient = cmsClient;
1308
- this.db = cmsClient.db;
1309
- this.options = options;
1310
- }
1311
- /**
1312
- * Adds a doc to the batch request.
1313
- */
1314
- addDoc(docId) {
1315
- this.docIds.push(docId);
1316
- }
1317
- /**
1318
- * Adds a data source to the batch request.
1319
- */
1320
- addDataSource(dataSourceId) {
1321
- this.dataSourceIds.push(dataSourceId);
1322
- }
1323
- /**
1324
- * Adds a collection-based query to the batch request.
1325
- */
1326
- addQuery(queryId, collectionId, queryOptions) {
1327
- this.queries.push({
1328
- queryId,
1329
- collectionId,
1330
- queryOptions
1331
- });
1332
- }
1333
- /**
1334
- * Adds a translation file to the request.
1335
- */
1336
- addTranslations(translationsId) {
1337
- this.translationsIds.push(translationsId);
1338
- }
1339
- /**
1340
- * Fetches data from the DB.
1341
- */
1342
- async fetch() {
1343
- const res = new BatchResponse();
1344
- const promises = [
1345
- this.fetchDocs(res),
1346
- this.fetchQueries(res),
1347
- this.fetchDataSources(res)
1348
- ];
1349
- if (!this.options.translate && this.translationsIds.length > 0) {
1350
- promises.push(this.fetchTranslations(res));
1351
- }
1352
- await Promise.all(promises);
1353
- if (this.translationsIds.length > 0) {
1354
- await this.fetchTranslations(res);
1355
- }
1356
- return res;
1357
- }
1358
- async fetchDocs(res) {
1359
- if (this.docIds.length === 0) {
1360
- return;
1361
- }
1362
- const docRefs = this.docIds.map((docId) => {
1363
- const [collectionId, slug] = docId.split("/");
1364
- return this.cmsClient.dbDocRef(collectionId, slug, {
1365
- mode: this.options.mode
1366
- });
1367
- });
1368
- const docs = await this.db.getAll(...docRefs);
1369
- this.docIds.forEach((docId, i) => {
1370
- const doc = docs[i];
1371
- if (!doc.exists) {
1372
- console.warn(`doc "${docId}" does not exist`);
1373
- return;
1374
- }
1375
- const docData = unmarshalData(doc.data());
1376
- res.docs[docId] = docData;
1377
- if (this.options.translate) {
1378
- this.addTranslations(docId);
1379
- }
1380
- });
1381
- }
1382
- async fetchQueries(res) {
1383
- if (this.queries.length === 0) {
1384
- return;
1385
- }
1386
- const mode = this.options.mode;
1387
- const handleQuery = async (queryItem) => {
1388
- const docsPath = this.cmsClient.dbCollectionDocsPath(
1389
- queryItem.collectionId,
1390
- { mode }
1391
- );
1392
- const queryOptions = queryItem.queryOptions || {};
1393
- let query = this.db.collection(docsPath);
1394
- if (queryOptions.limit) {
1395
- query = query.limit(queryOptions.limit);
1396
- }
1397
- if (queryOptions.offset) {
1398
- query = query.offset(queryOptions.offset);
1399
- }
1400
- if (queryOptions.orderBy) {
1401
- query = query.orderBy(
1402
- queryOptions.orderBy,
1403
- queryOptions.orderByDirection
1404
- );
1405
- }
1406
- if (queryOptions.query) {
1407
- query = queryOptions.query(query);
1408
- }
1409
- const results = await query.get();
1410
- const docs = [];
1411
- results.forEach((result) => {
1412
- const doc = unmarshalData(result.data());
1413
- docs.push(doc);
1414
- });
1415
- res.queries[queryItem.queryId] = docs;
1416
- };
1417
- await Promise.all(this.queries.map((queryItem) => handleQuery(queryItem)));
1418
- }
1419
- async fetchDataSources(res) {
1420
- if (this.dataSourceIds.length === 0) {
1421
- return;
1422
- }
1423
- const docRefs = this.dataSourceIds.map((dataSourceId) => {
1424
- return this.cmsClient.dbDataSourceDataRef(dataSourceId, {
1425
- mode: this.options.mode
1426
- });
1427
- });
1428
- const docs = await this.db.getAll(...docRefs);
1429
- this.dataSourceIds.forEach((dataSourceId, i) => {
1430
- const doc = docs[i];
1431
- if (!doc.exists) {
1432
- console.warn(`"data source "${dataSourceId}" does not exist`);
1433
- return;
1434
- }
1435
- res.dataSources[dataSourceId] = doc.data();
1436
- });
1437
- }
1438
- async fetchTranslations(res) {
1439
- if (this.translationsIds.length === 0) {
1440
- return;
1441
- }
1442
- const docRefs = this.translationsIds.map((translationsId) => {
1443
- return this.cmsClient.dbTranslationsRef(translationsId, {
1444
- mode: this.options.mode
1445
- });
1446
- });
1447
- const docs = await this.db.getAll(...docRefs);
1448
- this.translationsIds.forEach((translationsId, i) => {
1449
- const doc = docs[i];
1450
- if (!doc.exists) {
1451
- return;
1452
- }
1453
- res.translations[translationsId] = doc.data();
1454
- });
1455
- }
1456
- };
1457
- var BatchResponse = class {
1458
- constructor() {
1459
- this.docs = {};
1460
- this.queries = {};
1461
- this.dataSources = {};
1462
- this.translations = {};
1463
- }
1464
- /**
1465
- * Returns a map of translations for a given locale or locale fallbacks.
1466
- *
1467
- * The input is either a single locale (e.g. "de") or an array of locales
1468
- * representing the fallback tree, e.g. ["en-CA", "en-GB", "en"].
1469
- *
1470
- * TODO(stevenle): support the locale fallback tree.
1471
- *
1472
- * The returned value is a flat map of source string to translated string,
1473
- * e.g.:
1474
- * {"<source>": "<translation>"}
1475
- */
1476
- getTranslations(locale) {
1477
- const translationsMap = this.getTranslationsMap();
1478
- const translations = translationsForLocale(translationsMap, locale);
1479
- return translations;
1480
- }
1481
- /**
1482
- * Merges the strings from all translations files retrieved in the request.
1483
- * The returned value is a map of string to translations, e.g.:
1484
- *
1485
- * {"<hash>": {"source": "<source>", "<locale>": "<translation>"}}
1486
- */
1487
- getTranslationsMap() {
1488
- const translationsDocs = Object.values(this.translations).reverse();
1489
- const translationsMap = {};
1490
- for (const translationsDoc of translationsDocs) {
1491
- const strings = translationsDoc.strings || {};
1492
- for (const hash in strings) {
1493
- const translations = strings[hash];
1494
- translationsMap[hash] ??= { source: translations.source };
1495
- for (const locale in translations) {
1496
- if (locale !== "source" && translations[locale]) {
1497
- translationsMap[hash][locale] = translations[locale];
1498
- }
1499
- }
1500
- }
1501
- }
1502
- return translationsMap;
1503
- }
1504
- };
1505
-
1506
- // core/versions.ts
1507
- import path from "path";
1508
- import { Timestamp as Timestamp3 } from "firebase-admin/firestore";
1509
- import glob from "tiny-glob";
1510
- var DOCUMENT_SAVE_OFFSET = 5 * 60 * 1e3;
1511
- var VersionsService = class {
1512
- constructor(rootConfig) {
1513
- this.rootConfig = rootConfig;
1514
- const cmsPlugin = getCmsPlugin(rootConfig);
1515
- const cmsPluginOptions = cmsPlugin.getConfig();
1516
- const projectId = cmsPluginOptions.id || "default";
1517
- this.projectId = projectId;
1518
- this.db = cmsPlugin.getFirestore();
1519
- }
1520
- /**
1521
- * Saves a version of all documents that have been edited since the last run.
1522
- */
1523
- async saveVersions() {
1524
- const lastRun = await this.getLastRun();
1525
- const lastRunWithOffset = lastRun === 0 ? lastRun : lastRun - DOCUMENT_SAVE_OFFSET;
1526
- const changedDocs = await this.getDocsModifiedAfter(lastRunWithOffset);
1527
- const now = Timestamp3.now().toMillis();
1528
- const versions = changedDocs.filter((doc) => {
1529
- const modifiedAt = doc.sys.modifiedAt.toMillis();
1530
- return modifiedAt <= now - DOCUMENT_SAVE_OFFSET;
1531
- });
1532
- if (versions.length > 0) {
1533
- this.saveVersionsToFirestore(versions);
1534
- }
1535
- this.saveLastRun(now);
1536
- }
1537
- async saveVersionsToFirestore(versions) {
1538
- const batch = this.db.batch();
1539
- versions.forEach((version) => {
1540
- if (!version.collection || !version.slug || !version.sys?.modifiedAt) {
1541
- return;
1542
- }
1543
- const modifiedAtMillis = version.sys.modifiedAt.toMillis();
1544
- const versionPath = `Projects/${this.projectId}/Collections/${version.collection}/Drafts/${version.slug}/Versions/${modifiedAtMillis}`;
1545
- console.log(versionPath);
1546
- const versionRef = this.db.doc(versionPath);
1547
- batch.set(versionRef, version);
1548
- });
1549
- await batch.commit();
1550
- console.log(`versions: saved ${versions.length} versions`);
1551
- }
1552
- /**
1553
- * Returns the last time (in millis) saveVersions() was run, or 0 if has
1554
- * never been run.
1555
- */
1556
- async getLastRun() {
1557
- const projectDocRef = this.db.collection("Projects").doc(this.projectId);
1558
- const projectDoc = await projectDocRef.get();
1559
- if (projectDoc.exists) {
1560
- const data = projectDoc.data() || {};
1561
- const ts = data.versionsLastRun;
1562
- if (ts) {
1563
- return ts.toMillis();
1564
- }
1565
- }
1566
- return 0;
1567
- }
1568
- /**
1569
- * Saves {versionLastRun: <timestamp>} to the Projects/<projectId> doc.
1570
- */
1571
- async saveLastRun(millis) {
1572
- const ts = Timestamp3.fromMillis(millis);
1573
- const projectDocRef = this.db.collection("Projects").doc(this.projectId);
1574
- await projectDocRef.set({ versionsLastRun: ts }, { merge: true });
1575
- }
1576
- /**
1577
- * Returns a list of all docs that were edited after a certain time.
1578
- */
1579
- async getDocsModifiedAfter(millis) {
1580
- const ts = Timestamp3.fromMillis(millis);
1581
- const results = [];
1582
- const collectionIds = await this.listCollections();
1583
- for (const collectionId of collectionIds) {
1584
- const collectionPath = `Projects/${this.projectId}/Collections/${collectionId}/Drafts`;
1585
- const query = this.db.collection(collectionPath).where("sys.modifiedAt", ">=", ts);
1586
- const querySnapshot = await query.get();
1587
- querySnapshot.forEach((doc) => {
1588
- results.push(doc.data());
1589
- });
1590
- }
1591
- return results;
1592
- }
1593
- /**
1594
- * Returns a list of collection ids for the Root project.
1595
- */
1596
- async listCollections() {
1597
- const collectionIds = [];
1598
- const collectionFileNames = await glob("*.schema.ts", {
1599
- cwd: path.join(this.rootConfig.rootDir, "collections")
1600
- });
1601
- collectionFileNames.forEach((filename) => {
1602
- collectionIds.push(filename.slice(0, -10));
1603
- });
1604
- return collectionIds;
1605
- }
1606
- };
1607
-
1608
- // core/cron.ts
1609
- async function runCronJobs(rootConfig) {
1610
- await Promise.all([
1611
- runCronJob("publishScheduledDocs", runPublishScheduledDocs, rootConfig),
1612
- runCronJob("saveVersions", runSaveVersions, rootConfig)
1613
- ]);
1614
- }
1615
- async function runCronJob(name, fn, ...args) {
1616
- try {
1617
- await fn(...args);
1618
- } catch (err) {
1619
- console.log(`cron failed: ${name}`);
1620
- console.error(String(err.stack || err));
1621
- throw err;
1622
- }
1623
- }
1624
- async function runPublishScheduledDocs(rootConfig) {
1625
- const cmsClient = new RootCMSClient(rootConfig);
1626
- await cmsClient.publishScheduledDocs();
1627
- await cmsClient.publishScheduledReleases();
1628
- }
1629
- async function runSaveVersions(rootConfig) {
1630
- const service = new VersionsService(rootConfig);
1631
- await service.saveVersions();
1632
- }
2
+ runCronJobs
3
+ } from "./chunk-RIJF2AHU.js";
4
+ import "./chunk-NUUABQRN.js";
5
+ import "./chunk-MLKGABMK.js";
1633
6
 
1634
7
  // core/functions.ts
8
+ import path from "path";
9
+ import { loadBundledConfig } from "@blinkk/root/node";
10
+ import { onSchedule } from "firebase-functions/v2/scheduler";
1635
11
  function cron(options) {
1636
- const rootDir = path2.resolve(options?.rootDir || process.cwd());
12
+ const rootDir = path.resolve(options?.rootDir || process.cwd());
1637
13
  const scheduleOptions = {
1638
14
  schedule: "every 1 mins",
1639
15
  ...options?.scheduleOptions