@leadcms/sdk 3.2.0 → 3.3.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.
Files changed (62) hide show
  1. package/README.md +15 -3
  2. package/dist/cli/bin/generate-env.d.ts +1 -1
  3. package/dist/cli/bin/generate-env.d.ts.map +1 -1
  4. package/dist/cli/bin/generate-env.js +2 -2
  5. package/dist/cli/bin/generate-env.js.map +1 -1
  6. package/dist/cli/bin/init.d.ts +1 -1
  7. package/dist/cli/bin/init.d.ts.map +1 -1
  8. package/dist/cli/bin/init.js +5 -2
  9. package/dist/cli/bin/init.js.map +1 -1
  10. package/dist/cli/bin/pull-comments.js +3 -1
  11. package/dist/cli/bin/pull-comments.js.map +1 -1
  12. package/dist/cli/bin/pull-content.js +2 -1
  13. package/dist/cli/bin/pull-content.js.map +1 -1
  14. package/dist/cli/bin/pull-media.js +3 -1
  15. package/dist/cli/bin/pull-media.js.map +1 -1
  16. package/dist/cli/bin/watch.d.ts +1 -1
  17. package/dist/cli/bin/watch.d.ts.map +1 -1
  18. package/dist/cli/bin/watch.js +2 -2
  19. package/dist/cli/bin/watch.js.map +1 -1
  20. package/dist/cli/index.js +7 -4
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/lib/content-merge.d.ts +80 -0
  27. package/dist/lib/content-merge.d.ts.map +1 -0
  28. package/dist/lib/content-merge.js +350 -0
  29. package/dist/lib/content-merge.js.map +1 -0
  30. package/dist/scripts/fetch-leadcms-content.d.ts +1 -0
  31. package/dist/scripts/fetch-leadcms-content.d.ts.map +1 -1
  32. package/dist/scripts/fetch-leadcms-content.js +106 -13
  33. package/dist/scripts/fetch-leadcms-content.js.map +1 -1
  34. package/dist/scripts/generate-env-js.d.ts +5 -0
  35. package/dist/scripts/generate-env-js.d.ts.map +1 -1
  36. package/dist/scripts/generate-env-js.js +12 -10
  37. package/dist/scripts/generate-env-js.js.map +1 -1
  38. package/dist/scripts/init-leadcms.d.ts +4 -0
  39. package/dist/scripts/init-leadcms.d.ts.map +1 -1
  40. package/dist/scripts/init-leadcms.js +13 -22
  41. package/dist/scripts/init-leadcms.js.map +1 -1
  42. package/dist/scripts/pull-all.d.ts +16 -1
  43. package/dist/scripts/pull-all.d.ts.map +1 -1
  44. package/dist/scripts/pull-all.js +49 -24
  45. package/dist/scripts/pull-all.js.map +1 -1
  46. package/dist/scripts/pull-comments.d.ts +5 -1
  47. package/dist/scripts/pull-comments.d.ts.map +1 -1
  48. package/dist/scripts/pull-comments.js +8 -1
  49. package/dist/scripts/pull-comments.js.map +1 -1
  50. package/dist/scripts/pull-content.d.ts +2 -0
  51. package/dist/scripts/pull-content.d.ts.map +1 -1
  52. package/dist/scripts/pull-content.js +7 -1
  53. package/dist/scripts/pull-content.js.map +1 -1
  54. package/dist/scripts/pull-media.d.ts +5 -1
  55. package/dist/scripts/pull-media.d.ts.map +1 -1
  56. package/dist/scripts/pull-media.js +8 -1
  57. package/dist/scripts/pull-media.js.map +1 -1
  58. package/dist/scripts/sse-watcher.d.ts +2 -0
  59. package/dist/scripts/sse-watcher.d.ts.map +1 -1
  60. package/dist/scripts/sse-watcher.js +2 -1
  61. package/dist/scripts/sse-watcher.js.map +1 -1
  62. package/package.json +3 -2
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Three-way merge utilities for LeadCMS content
3
+ *
4
+ * Uses the node-diff3 library to perform git-style three-way merges between
5
+ * a base version, a local version, and a remote version of content files.
6
+ *
7
+ * For JSON content, a structural (field-level) merge is used instead of
8
+ * line-based diff to avoid false conflicts from adjacent line changes.
9
+ *
10
+ * The base version comes from the server via the sync API (the state of the
11
+ * content at the time of the client's last sync token). This avoids needing
12
+ * any local storage of base snapshots.
13
+ */
14
+ import { diff3Merge } from 'node-diff3';
15
+ /**
16
+ * Fields that are controlled by the server and should always take the remote
17
+ * value during merge, even if they differ between base and local.
18
+ * These fields are set/updated by the server automatically and should never
19
+ * be treated as meaningful local edits.
20
+ */
21
+ const SERVER_CONTROLLED_FIELDS = new Set([
22
+ 'updatedAt',
23
+ 'createdAt',
24
+ ]);
25
+ /**
26
+ * Regex to match a YAML frontmatter line for a server-controlled field.
27
+ * Matches lines like:
28
+ * updatedAt: "2026-02-01T00:00:00Z"
29
+ * createdAt: "2026-01-01T00:00:00Z"
30
+ * updatedAt: 2026-02-01T00:00:00Z
31
+ */
32
+ const SERVER_CONTROLLED_YAML_LINE = /^\s*(updatedAt|createdAt)\s*:/;
33
+ /**
34
+ * Perform a three-way merge between base, local, and remote content.
35
+ *
36
+ * This works like git merge:
37
+ * - Changes that don't overlap are merged automatically
38
+ * - Changes that modify the same lines produce conflict markers
39
+ * - Server-controlled fields (updatedAt, createdAt) in YAML frontmatter
40
+ * are auto-resolved to the remote value, never producing conflicts
41
+ *
42
+ * For JSON content, prefer threeWayMergeJson() which does structural merging
43
+ * and avoids false conflicts from adjacent line changes.
44
+ *
45
+ * @param base - The original content at the time of last sync (from server's baseItems)
46
+ * @param local - The current local file content (possibly user-modified)
47
+ * @param remote - The current remote content (fetched from server)
48
+ * @returns MergeResult with merged content and conflict information
49
+ */
50
+ export function threeWayMerge(base, local, remote) {
51
+ const baseLines = base.split('\n');
52
+ const localLines = local.split('\n');
53
+ const remoteLines = remote.split('\n');
54
+ const regions = diff3Merge(localLines, baseLines, remoteLines);
55
+ let conflictCount = 0;
56
+ const resultLines = [];
57
+ for (const region of regions) {
58
+ if ('ok' in region && region.ok) {
59
+ resultLines.push(...region.ok);
60
+ }
61
+ else if ('conflict' in region && region.conflict) {
62
+ const localConflictLines = region.conflict.a;
63
+ const remoteConflictLines = region.conflict.b;
64
+ // Try to auto-resolve server-controlled fields within the conflict
65
+ const resolved = resolveServerControlledConflict(localConflictLines, remoteConflictLines);
66
+ if (resolved.remainingConflict) {
67
+ // There are still real conflicts after extracting server-controlled fields
68
+ conflictCount++;
69
+ resultLines.push(...resolved.resolvedLines);
70
+ resultLines.push('<<<<<<< local');
71
+ resultLines.push(...resolved.remainingConflict.local);
72
+ resultLines.push('=======');
73
+ resultLines.push(...resolved.remainingConflict.remote);
74
+ resultLines.push('>>>>>>> remote');
75
+ }
76
+ else {
77
+ // All lines in this conflict were server-controlled → fully auto-resolved
78
+ resultLines.push(...resolved.resolvedLines);
79
+ }
80
+ }
81
+ }
82
+ const merged = resultLines.join('\n');
83
+ return {
84
+ success: conflictCount === 0,
85
+ merged,
86
+ hasConflicts: conflictCount > 0,
87
+ conflictCount,
88
+ };
89
+ }
90
+ /**
91
+ * Attempt to auto-resolve server-controlled fields within a conflict region.
92
+ *
93
+ * For each line in the conflict, if it's a server-controlled YAML field
94
+ * (updatedAt, createdAt), take the remote version. Non-server-controlled
95
+ * lines remain as conflicts.
96
+ *
97
+ * Returns:
98
+ * - resolvedLines: lines that were auto-resolved (server-controlled)
99
+ * - remainingConflict: null if fully resolved, or { local, remote } with
100
+ * the non-server-controlled lines that still conflict
101
+ */
102
+ function resolveServerControlledConflict(localLines, remoteLines) {
103
+ const resolvedLines = [];
104
+ const remainingLocal = [];
105
+ const remainingRemote = [];
106
+ // Separate server-controlled from non-server-controlled lines on each side
107
+ const localServerControlled = [];
108
+ const localOther = [];
109
+ for (const line of localLines) {
110
+ if (SERVER_CONTROLLED_YAML_LINE.test(line)) {
111
+ localServerControlled.push(line);
112
+ }
113
+ else {
114
+ localOther.push(line);
115
+ }
116
+ }
117
+ const remoteServerControlled = [];
118
+ const remoteOther = [];
119
+ for (const line of remoteLines) {
120
+ if (SERVER_CONTROLLED_YAML_LINE.test(line)) {
121
+ remoteServerControlled.push(line);
122
+ }
123
+ else {
124
+ remoteOther.push(line);
125
+ }
126
+ }
127
+ // Auto-resolve server-controlled fields: always take remote version
128
+ resolvedLines.push(...remoteServerControlled);
129
+ // Check if there are remaining non-server-controlled lines
130
+ if (localOther.length === 0 && remoteOther.length === 0) {
131
+ // Entire conflict was server-controlled → fully resolved
132
+ return { resolvedLines, remainingConflict: null };
133
+ }
134
+ // There are real conflicting lines remaining
135
+ return {
136
+ resolvedLines,
137
+ remainingConflict: { local: localOther, remote: remoteOther },
138
+ };
139
+ }
140
+ /**
141
+ * Perform a structural three-way merge on JSON content.
142
+ *
143
+ * Instead of doing a line-based diff (which produces false conflicts for
144
+ * adjacent field changes), this parses the JSON and merges field-by-field:
145
+ *
146
+ * - Fields changed only locally → keep local value
147
+ * - Fields changed only remotely → take remote value
148
+ * - Fields changed identically on both sides → take either (same value)
149
+ * - Fields changed differently on both sides → conflict
150
+ * - Server-controlled fields (updatedAt, createdAt) → always take remote
151
+ *
152
+ * For nested objects, the merge recurses into each level.
153
+ *
154
+ * @param base - The base JSON content (from server's baseItems, transformed to local format)
155
+ * @param local - The current local JSON file content
156
+ * @param remote - The current remote JSON content (transformed to local format)
157
+ * @returns MergeResult with the merged JSON string
158
+ */
159
+ export function threeWayMergeJson(base, local, remote) {
160
+ let baseObj;
161
+ let localObj;
162
+ let remoteObj;
163
+ try {
164
+ baseObj = JSON.parse(base);
165
+ localObj = JSON.parse(local);
166
+ remoteObj = JSON.parse(remote);
167
+ }
168
+ catch {
169
+ // If any version is not valid JSON, fall back to line-based merge
170
+ return threeWayMerge(base, local, remote);
171
+ }
172
+ const { value: mergedObj, conflicted, conflictCount } = mergeValues(baseObj, localObj, remoteObj);
173
+ const merged = JSON.stringify(mergedObj, null, 2);
174
+ return {
175
+ success: !conflicted,
176
+ merged,
177
+ hasConflicts: conflicted,
178
+ conflictCount,
179
+ };
180
+ }
181
+ /**
182
+ * Recursively merge three values (base, local, remote).
183
+ * Returns the merged value and whether any conflicts were found.
184
+ */
185
+ function mergeValues(base, local, remote) {
186
+ // If both local and remote are objects (not arrays), merge field by field
187
+ if (isPlainObject(base) && isPlainObject(local) && isPlainObject(remote)) {
188
+ return mergeObjects(base, local, remote);
189
+ }
190
+ // For non-object values (primitives, arrays, etc.): compare as JSON strings
191
+ const baseStr = JSON.stringify(base);
192
+ const localStr = JSON.stringify(local);
193
+ const remoteStr = JSON.stringify(remote);
194
+ if (baseStr === localStr && baseStr === remoteStr) {
195
+ // No changes
196
+ return { value: local, conflicted: false, conflictCount: 0 };
197
+ }
198
+ if (baseStr === localStr) {
199
+ // Only remote changed → take remote
200
+ return { value: remote, conflicted: false, conflictCount: 0 };
201
+ }
202
+ if (baseStr === remoteStr) {
203
+ // Only local changed → keep local
204
+ return { value: local, conflicted: false, conflictCount: 0 };
205
+ }
206
+ if (localStr === remoteStr) {
207
+ // Both changed identically → take either
208
+ return { value: local, conflicted: false, conflictCount: 0 };
209
+ }
210
+ // Both changed differently → conflict
211
+ // Use a special marker object that will be serialized with conflict info
212
+ return {
213
+ value: {
214
+ '<<<<<<< local': local,
215
+ '=======': '---',
216
+ '>>>>>>> remote': remote,
217
+ },
218
+ conflicted: true,
219
+ conflictCount: 1,
220
+ };
221
+ }
222
+ /**
223
+ * Merge three plain objects field by field.
224
+ */
225
+ function mergeObjects(base, local, remote) {
226
+ const allKeys = new Set([
227
+ ...Object.keys(base),
228
+ ...Object.keys(local),
229
+ ...Object.keys(remote),
230
+ ]);
231
+ const merged = {};
232
+ let hasConflict = false;
233
+ let totalConflicts = 0;
234
+ for (const key of allKeys) {
235
+ const inBase = key in base;
236
+ const inLocal = key in local;
237
+ const inRemote = key in remote;
238
+ // Server-controlled fields: always take remote value
239
+ if (SERVER_CONTROLLED_FIELDS.has(key)) {
240
+ if (inRemote) {
241
+ merged[key] = remote[key];
242
+ }
243
+ else if (inLocal) {
244
+ merged[key] = local[key];
245
+ }
246
+ // If only in base, it was deleted from both → omit
247
+ continue;
248
+ }
249
+ if (inBase && inLocal && inRemote) {
250
+ // Key exists in all three — merge the values
251
+ const result = mergeValues(base[key], local[key], remote[key]);
252
+ merged[key] = result.value;
253
+ if (result.conflicted) {
254
+ hasConflict = true;
255
+ totalConflicts += result.conflictCount;
256
+ }
257
+ }
258
+ else if (!inBase && inLocal && inRemote) {
259
+ // Key added by both sides
260
+ const result = mergeValues(undefined, local[key], remote[key]);
261
+ merged[key] = result.value;
262
+ if (result.conflicted) {
263
+ hasConflict = true;
264
+ totalConflicts += result.conflictCount;
265
+ }
266
+ }
267
+ else if (!inBase && inLocal && !inRemote) {
268
+ // Key added only locally → keep
269
+ merged[key] = local[key];
270
+ }
271
+ else if (!inBase && !inLocal && inRemote) {
272
+ // Key added only remotely → take
273
+ merged[key] = remote[key];
274
+ }
275
+ else if (inBase && !inLocal && inRemote) {
276
+ // Key deleted locally — check if remote also changed it
277
+ const baseStr = JSON.stringify(base[key]);
278
+ const remoteStr = JSON.stringify(remote[key]);
279
+ if (baseStr === remoteStr) {
280
+ // Remote didn't change it, local deleted it → keep deleted (omit)
281
+ }
282
+ else {
283
+ // Remote changed it but local deleted it → conflict, prefer remote
284
+ merged[key] = remote[key];
285
+ hasConflict = true;
286
+ totalConflicts++;
287
+ }
288
+ }
289
+ else if (inBase && inLocal && !inRemote) {
290
+ // Key deleted remotely — check if local also changed it
291
+ const baseStr = JSON.stringify(base[key]);
292
+ const localStr = JSON.stringify(local[key]);
293
+ if (baseStr === localStr) {
294
+ // Local didn't change it, remote deleted it → keep deleted (omit)
295
+ }
296
+ else {
297
+ // Local changed it but remote deleted it → conflict, prefer local
298
+ merged[key] = local[key];
299
+ hasConflict = true;
300
+ totalConflicts++;
301
+ }
302
+ }
303
+ else if (inBase && !inLocal && !inRemote) {
304
+ // Deleted by both sides → omit
305
+ }
306
+ }
307
+ return { value: merged, conflicted: hasConflict, conflictCount: totalConflicts };
308
+ }
309
+ /**
310
+ * Check if a value is a plain object (not array, null, Date, etc.)
311
+ */
312
+ function isPlainObject(value) {
313
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
314
+ }
315
+ /**
316
+ * Determine whether a local file has been modified compared to the base version.
317
+ *
318
+ * This is used to decide whether three-way merge is needed:
319
+ * - If local === base → local is unmodified, safe to overwrite with remote
320
+ * - If local !== base → local was modified, need three-way merge
321
+ *
322
+ * Uses timestamp normalization to avoid false positives from precision differences
323
+ * (e.g. server returns 7 decimal places but local file has 6).
324
+ *
325
+ * @param base - The base content (from server's baseItems, transformed to local format)
326
+ * @param local - The current local file content
327
+ * @returns true if local content differs from base
328
+ */
329
+ export function isLocallyModified(base, local) {
330
+ return normalizeForMergeComparison(base) !== normalizeForMergeComparison(local);
331
+ }
332
+ /**
333
+ * Normalize content for merge comparison.
334
+ * Handles trivial whitespace and timestamp precision differences that shouldn't
335
+ * trigger a merge.
336
+ */
337
+ function normalizeForMergeComparison(content) {
338
+ return content
339
+ .replace(/\r\n/g, '\n')
340
+ .replace(/\s+\n/g, '\n')
341
+ // Normalize ISO timestamp precision: truncate fractional seconds to 6 decimal
342
+ // places (microsecond precision) then strip trailing zeros.
343
+ // e.g. "2026-02-13T10:32:20.2939836Z" → "2026-02-13T10:32:20.293983Z"
344
+ // This prevents false diffs from servers returning 7-digit precision while
345
+ // local serializers use 6-digit precision.
346
+ .replace(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,6})\d*Z/g, '$1Z')
347
+ .replace(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+?)0+Z/g, '$1Z')
348
+ .trimEnd();
349
+ }
350
+ //# sourceMappingURL=content-merge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-merge.js","sourceRoot":"","sources":["../../src/lib/content-merge.ts"],"names":[],"mappings":"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"}
@@ -11,6 +11,7 @@ interface MediaDeletedItem {
11
11
  interface ContentSyncResult {
12
12
  items: ContentItem[];
13
13
  deleted: number[];
14
+ baseItems: Record<string, ContentItem>;
14
15
  nextSyncToken: string;
15
16
  }
16
17
  interface MediaSyncResult {
@@ -1 +1 @@
1
- {"version":3,"file":"fetch-leadcms-content.d.ts","sourceRoot":"","sources":["../../src/scripts/fetch-leadcms-content.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AAIvB,OAAO,EAQL,WAAW,EACZ,MAAM,sBAAsB,CAAC;AAU9B,UAAU,SAAS;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,UAAU,gBAAgB;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAQD,UAAU,iBAAiB;IACzB,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,eAAe;IACvB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,aAAa,EAAE,MAAM,CAAC;CACvB;AA0RD;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;AAEnD;;;;GAIG;AACH,iBAAe,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAqCvE;AAED;;;;GAIG;AACH,iBAAS,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAU7D;AAED;;;GAGG;AACH,iBAAe,sBAAsB,CAAC,KAAK,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAezF;AAED;;;;;;GAMG;AACH,iBAAe,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0BjF;AAED,iBAAe,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CA2LnC;AAGD,OAAO,EAAE,IAAI,IAAI,mBAAmB,EAAE,CAAC;AAGvC,OAAO,EAAE,wBAAwB,EAAE,CAAC;AACpC,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,CAAC;AAMzE,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,SAAS,EAAE,gBAAgB,EAAE,CAAC"}
1
+ {"version":3,"file":"fetch-leadcms-content.d.ts","sourceRoot":"","sources":["../../src/scripts/fetch-leadcms-content.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AAIvB,OAAO,EAQL,WAAW,EACZ,MAAM,sBAAsB,CAAC;AAa9B,UAAU,SAAS;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,UAAU,gBAAgB;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAQD,UAAU,iBAAiB;IACzB,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACvC,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,eAAe;IACvB,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,aAAa,EAAE,MAAM,CAAC;CACvB;AAuSD;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;AAEnD;;;;GAIG;AACH,iBAAe,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAqCvE;AAED;;;;GAIG;AACH,iBAAS,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAU7D;AAED;;;GAGG;AACH,iBAAe,sBAAsB,CAAC,KAAK,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAezF;AAED;;;;;;GAMG;AACH,iBAAe,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0BjF;AAED,iBAAe,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CA4QnC;AAGD,OAAO,EAAE,IAAI,IAAI,mBAAmB,EAAE,CAAC;AAGvC,OAAO,EAAE,wBAAwB,EAAE,CAAC;AACpC,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,CAAC;AAMzE,YAAY,EAAE,iBAAiB,EAAE,eAAe,EAAE,SAAS,EAAE,gBAAgB,EAAE,CAAC"}
@@ -3,7 +3,9 @@ import fs from "fs/promises";
3
3
  import path from "path";
4
4
  import axios from "axios";
5
5
  import { downloadMediaFileDirect, leadCMSUrl, leadCMSApiKey, defaultLanguage, CONTENT_DIR, MEDIA_DIR, fetchContentTypes, } from "./leadcms-helpers.js";
6
- import { saveContentFile } from "../lib/content-transformation.js";
6
+ import { saveContentFile, transformRemoteToLocalFormat } from "../lib/content-transformation.js";
7
+ import { threeWayMerge, threeWayMergeJson, isLocallyModified } from "../lib/content-merge.js";
8
+ import { getConfig } from "../lib/config.js";
7
9
  // Add axios request/response interceptors for debugging
8
10
  axios.interceptors.request.use((config) => {
9
11
  console.log(`[AXIOS REQUEST] ${config.method?.toUpperCase()} ${config.url}`);
@@ -112,6 +114,7 @@ async function fetchContentSync(syncToken) {
112
114
  console.log(`[FETCH_CONTENT_SYNC] Fetching public content (no authentication)`);
113
115
  let allItems = [];
114
116
  let allDeleted = [];
117
+ let allBaseItems = {};
115
118
  let token = syncToken || "";
116
119
  let nextSyncToken = undefined;
117
120
  let page = 0;
@@ -119,6 +122,10 @@ async function fetchContentSync(syncToken) {
119
122
  const url = new URL("/api/content/sync", leadCMSUrl);
120
123
  url.searchParams.set("filter[limit]", "100");
121
124
  url.searchParams.set("syncToken", token);
125
+ // Request base versions for three-way merge when doing incremental sync
126
+ if (syncToken) {
127
+ url.searchParams.set("includeBase", "true");
128
+ }
122
129
  console.log(`[FETCH_CONTENT_SYNC] Page ${page}, URL: ${url.toString()}`);
123
130
  try {
124
131
  // SECURITY: Never send API key for read operations
@@ -135,6 +142,11 @@ async function fetchContentSync(syncToken) {
135
142
  if (data.deleted && Array.isArray(data.deleted)) {
136
143
  allDeleted.push(...data.deleted);
137
144
  }
145
+ // Collect base items for three-way merge support
146
+ if (data.baseItems && typeof data.baseItems === 'object') {
147
+ Object.assign(allBaseItems, data.baseItems);
148
+ console.log(`[FETCH_CONTENT_SYNC] Page ${page} - Got ${Object.keys(data.baseItems).length} base items for merge`);
149
+ }
138
150
  const newSyncToken = res.headers["x-next-sync-token"] || token;
139
151
  console.log(`[FETCH_CONTENT_SYNC] Next sync token: ${newSyncToken}`);
140
152
  if (!newSyncToken || newSyncToken === token) {
@@ -151,10 +163,11 @@ async function fetchContentSync(syncToken) {
151
163
  throw error;
152
164
  }
153
165
  }
154
- console.log(`[FETCH_CONTENT_SYNC] Completed - Total items: ${allItems.length}, deleted: ${allDeleted.length}`);
166
+ console.log(`[FETCH_CONTENT_SYNC] Completed - Total items: ${allItems.length}, deleted: ${allDeleted.length}, base items: ${Object.keys(allBaseItems).length}`);
155
167
  return {
156
168
  items: allItems,
157
169
  deleted: allDeleted,
170
+ baseItems: allBaseItems,
158
171
  nextSyncToken: nextSyncToken || token,
159
172
  };
160
173
  }
@@ -380,18 +393,18 @@ async function main() {
380
393
  const typeMap = await fetchContentTypes();
381
394
  const { token: lastSyncToken, migrated: contentTokenMigrated } = await readSyncToken();
382
395
  const { token: lastMediaSyncToken, migrated: mediaTokenMigrated } = await readMediaSyncToken();
383
- let items = [], deleted = [], nextSyncToken = "";
396
+ let items = [], deleted = [], baseItems = {}, nextSyncToken = "";
384
397
  let mediaItems = [], mediaDeleted = [], nextMediaSyncToken = "";
385
398
  // Sync content (only if supported)
386
399
  if (contentSupported) {
387
400
  try {
388
401
  if (lastSyncToken) {
389
402
  console.log(`Syncing content from LeadCMS using sync token: ${lastSyncToken}`);
390
- ({ items, deleted, nextSyncToken } = await fetchContentSync(lastSyncToken));
403
+ ({ items, deleted, baseItems, nextSyncToken } = await fetchContentSync(lastSyncToken));
391
404
  }
392
405
  else {
393
406
  console.log("No content sync token found. Doing full fetch from LeadCMS...");
394
- ({ items, deleted, nextSyncToken } = await fetchContentSync(undefined));
407
+ ({ items, deleted, baseItems, nextSyncToken } = await fetchContentSync(undefined));
395
408
  }
396
409
  }
397
410
  catch (error) {
@@ -430,23 +443,103 @@ async function main() {
430
443
  const contentIdIndex = (items.length > 0 || deleted.length > 0)
431
444
  ? await buildContentIdIndex(CONTENT_DIR)
432
445
  : new Map();
433
- // Save content files
446
+ // Save content files (with three-way merge support)
447
+ const hasBaseItems = Object.keys(baseItems).length > 0;
448
+ let mergedCount = 0;
449
+ let conflictCount = 0;
450
+ let overwrittenCount = 0;
451
+ let newCount = 0;
452
+ // Build a ContentTypeMap for transformation
453
+ const contentTypeMap = {};
454
+ for (const [key, value] of Object.entries(typeMap)) {
455
+ contentTypeMap[key] = value === 'JSON' ? 'JSON' : 'MDX';
456
+ }
434
457
  for (const content of items) {
435
458
  if (content && typeof content === "object") {
459
+ const idStr = content.id != null ? String(content.id) : undefined;
460
+ // Determine the file path where this content would be saved
461
+ const contentConfig = getConfig();
462
+ const contentLanguage = content.language || contentConfig.defaultLanguage;
463
+ let targetContentDir = CONTENT_DIR;
464
+ if (contentLanguage !== contentConfig.defaultLanguage) {
465
+ targetContentDir = path.join(CONTENT_DIR, contentLanguage);
466
+ }
467
+ const contentType = contentTypeMap[content.type] || 'MDX';
468
+ const extension = contentType === 'MDX' ? '.mdx' : '.json';
469
+ const expectedPath = path.join(targetContentDir, `${content.slug}${extension}`);
470
+ // Check if local file exists before deleting old paths
471
+ let localContent = null;
472
+ try {
473
+ localContent = await fs.readFile(expectedPath, 'utf8');
474
+ }
475
+ catch {
476
+ // File does not exist — this is new content
477
+ }
436
478
  // Before saving, remove any existing file with the same id.
437
479
  // This handles slug renames, type changes (MDX↔JSON), and language
438
480
  // changes — the old file at the previous path is cleaned up before the
439
481
  // new version is written at the (potentially different) new path.
440
- if (content.id != null) {
441
- await deleteContentFilesById(contentIdIndex, String(content.id));
482
+ if (idStr != null) {
483
+ await deleteContentFilesById(contentIdIndex, idStr);
484
+ }
485
+ // Try three-way merge if we have a base version and local file exists
486
+ const baseContent = idStr ? baseItems[idStr] : undefined;
487
+ if (localContent && baseContent && hasBaseItems) {
488
+ // Transform both base and remote to local file format for comparison
489
+ const baseTransformed = await transformRemoteToLocalFormat(baseContent, contentTypeMap);
490
+ const remoteTransformed = await transformRemoteToLocalFormat(content, contentTypeMap);
491
+ if (!isLocallyModified(baseTransformed, localContent)) {
492
+ // Local file is unchanged from base — safe to overwrite with remote
493
+ await saveContentFile({ content, typeMap, contentDir: CONTENT_DIR });
494
+ overwrittenCount++;
495
+ }
496
+ else {
497
+ // Local file was modified — perform three-way merge
498
+ // Use structural merge for JSON (avoids false conflicts from adjacent lines)
499
+ // and line-based merge for MDX
500
+ const mergeResult = contentType === 'JSON'
501
+ ? threeWayMergeJson(baseTransformed, localContent, remoteTransformed)
502
+ : threeWayMerge(baseTransformed, localContent, remoteTransformed);
503
+ if (mergeResult.success) {
504
+ // Clean merge — write the merged result
505
+ await fs.mkdir(path.dirname(expectedPath), { recursive: true });
506
+ await fs.writeFile(expectedPath, mergeResult.merged, 'utf8');
507
+ console.log(`🔀 Auto-merged: ${content.slug} (local + remote changes combined)`);
508
+ mergedCount++;
509
+ }
510
+ else {
511
+ // Conflicts — write merged content with conflict markers
512
+ await fs.mkdir(path.dirname(expectedPath), { recursive: true });
513
+ await fs.writeFile(expectedPath, mergeResult.merged, 'utf8');
514
+ console.warn(`⚠️ Conflict in: ${content.slug} (${mergeResult.conflictCount} conflict(s) — manual resolution needed)`);
515
+ conflictCount++;
516
+ }
517
+ }
518
+ }
519
+ else {
520
+ // No base available or file is new — overwrite (current behavior)
521
+ await saveContentFile({ content, typeMap, contentDir: CONTENT_DIR });
522
+ if (localContent) {
523
+ overwrittenCount++;
524
+ }
525
+ else {
526
+ newCount++;
527
+ }
442
528
  }
443
- await saveContentFile({
444
- content,
445
- typeMap,
446
- contentDir: CONTENT_DIR,
447
- });
448
529
  }
449
530
  }
531
+ // Print merge summary
532
+ if (hasBaseItems && items.length > 0) {
533
+ console.log(`\n📊 Content sync summary:`);
534
+ if (newCount > 0)
535
+ console.log(` ✨ New: ${newCount}`);
536
+ if (overwrittenCount > 0)
537
+ console.log(` 📝 Updated (no local changes): ${overwrittenCount}`);
538
+ if (mergedCount > 0)
539
+ console.log(` 🔀 Auto-merged: ${mergedCount}`);
540
+ if (conflictCount > 0)
541
+ console.log(` ⚠️ Conflicts (need manual resolution): ${conflictCount}`);
542
+ }
450
543
  // Remove deleted content files from all language directories
451
544
  for (const id of deleted) {
452
545
  await deleteContentFilesById(contentIdIndex, String(id));