@purposeinplay/payload-ai-translate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +714 -0
  3. package/dist/alerts-collection.d.ts +21 -0
  4. package/dist/alerts-collection.js +159 -0
  5. package/dist/api.d.ts +4 -0
  6. package/dist/api.js +918 -0
  7. package/dist/bulk-translate-batches-collection.d.ts +29 -0
  8. package/dist/bulk-translate-batches-collection.js +404 -0
  9. package/dist/bulk-translate-units-collection.d.ts +35 -0
  10. package/dist/bulk-translate-units-collection.js +310 -0
  11. package/dist/client/estimated-cost-cell.d.ts +6 -0
  12. package/dist/client/estimated-cost-cell.js +12 -0
  13. package/dist/client/excluded-fields-field.d.ts +45 -0
  14. package/dist/client/excluded-fields-field.js +553 -0
  15. package/dist/client/field-translate-button.d.ts +6 -0
  16. package/dist/client/field-translate-button.js +199 -0
  17. package/dist/client/index.d.ts +6 -0
  18. package/dist/client/index.js +6 -0
  19. package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
  20. package/dist/client/lib/use-global-kill-switches.js +58 -0
  21. package/dist/client/translate-button.d.ts +2 -0
  22. package/dist/client/translate-button.js +228 -0
  23. package/dist/client/translate-modal.d.ts +16 -0
  24. package/dist/client/translate-modal.js +549 -0
  25. package/dist/client/translation-progress.d.ts +10 -0
  26. package/dist/client/translation-progress.js +297 -0
  27. package/dist/components/TranslationNavGroup.d.ts +45 -0
  28. package/dist/components/TranslationNavGroup.js +104 -0
  29. package/dist/defaults.d.ts +11 -0
  30. package/dist/defaults.js +16 -0
  31. package/dist/endpoints/client-config.d.ts +44 -0
  32. package/dist/endpoints/client-config.js +145 -0
  33. package/dist/endpoints/estimate.d.ts +5 -0
  34. package/dist/endpoints/estimate.js +237 -0
  35. package/dist/endpoints/progress.d.ts +2 -0
  36. package/dist/endpoints/progress.js +314 -0
  37. package/dist/endpoints/translate.d.ts +11 -0
  38. package/dist/endpoints/translate.js +376 -0
  39. package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
  40. package/dist/endpoints/translation-hub/_helpers.js +297 -0
  41. package/dist/endpoints/translation-hub/active.d.ts +21 -0
  42. package/dist/endpoints/translation-hub/active.js +220 -0
  43. package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
  44. package/dist/endpoints/translation-hub/cancel.js +233 -0
  45. package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
  46. package/dist/endpoints/translation-hub/enqueue.js +529 -0
  47. package/dist/endpoints/translation-hub/failures.d.ts +12 -0
  48. package/dist/endpoints/translation-hub/failures.js +67 -0
  49. package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
  50. package/dist/endpoints/translation-hub/force-reset.js +144 -0
  51. package/dist/endpoints/translation-hub/index.d.ts +21 -0
  52. package/dist/endpoints/translation-hub/index.js +20 -0
  53. package/dist/endpoints/translation-hub/list.d.ts +40 -0
  54. package/dist/endpoints/translation-hub/list.js +182 -0
  55. package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
  56. package/dist/endpoints/translation-hub/preflight.js +141 -0
  57. package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
  58. package/dist/endpoints/translation-hub/retry-failed.js +235 -0
  59. package/dist/endpoints/translation-hub/revert.d.ts +88 -0
  60. package/dist/endpoints/translation-hub/revert.js +405 -0
  61. package/dist/endpoints/translation-hub/status.d.ts +45 -0
  62. package/dist/endpoints/translation-hub/status.js +391 -0
  63. package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
  64. package/dist/endpoints/translation-hub/usage-summary.js +481 -0
  65. package/dist/exports/client.d.ts +6 -0
  66. package/dist/exports/client.js +6 -0
  67. package/dist/exports/components.d.ts +6 -0
  68. package/dist/exports/components.js +5 -0
  69. package/dist/exports/index.d.ts +8 -0
  70. package/dist/exports/index.js +7 -0
  71. package/dist/exports/providers.d.ts +9 -0
  72. package/dist/exports/providers.js +5 -0
  73. package/dist/exports/views-client.d.ts +23 -0
  74. package/dist/exports/views-client.js +22 -0
  75. package/dist/exports/views.d.ts +30 -0
  76. package/dist/exports/views.js +29 -0
  77. package/dist/hooks/after-change-global.d.ts +4 -0
  78. package/dist/hooks/after-change-global.js +109 -0
  79. package/dist/hooks/after-change.d.ts +16 -0
  80. package/dist/hooks/after-change.js +205 -0
  81. package/dist/hooks/after-delete.d.ts +30 -0
  82. package/dist/hooks/after-delete.js +95 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.js +5 -0
  85. package/dist/jobs-collection.d.ts +17 -0
  86. package/dist/jobs-collection.js +139 -0
  87. package/dist/lexical/classifier.d.ts +3 -0
  88. package/dist/lexical/classifier.js +108 -0
  89. package/dist/lexical/deserializer.d.ts +4 -0
  90. package/dist/lexical/deserializer.js +263 -0
  91. package/dist/lexical/placeholder-integrity.d.ts +6 -0
  92. package/dist/lexical/placeholder-integrity.js +21 -0
  93. package/dist/lexical/placeholders.d.ts +21 -0
  94. package/dist/lexical/placeholders.js +117 -0
  95. package/dist/lexical/serializer.d.ts +21 -0
  96. package/dist/lexical/serializer.js +233 -0
  97. package/dist/lexical/types.d.ts +32 -0
  98. package/dist/lexical/types.js +1 -0
  99. package/dist/lib/auth-diagnostics.d.ts +14 -0
  100. package/dist/lib/auth-diagnostics.js +19 -0
  101. package/dist/lib/batch-counts.d.ts +58 -0
  102. package/dist/lib/batch-counts.js +105 -0
  103. package/dist/lib/bulk-translate-migrations.d.ts +92 -0
  104. package/dist/lib/bulk-translate-migrations.js +153 -0
  105. package/dist/lib/coalescing-queue.d.ts +38 -0
  106. package/dist/lib/coalescing-queue.js +69 -0
  107. package/dist/lib/content-extractor.d.ts +16 -0
  108. package/dist/lib/content-extractor.js +410 -0
  109. package/dist/lib/content-hash.d.ts +1 -0
  110. package/dist/lib/content-hash.js +19 -0
  111. package/dist/lib/content-patcher.d.ts +15 -0
  112. package/dist/lib/content-patcher.js +293 -0
  113. package/dist/lib/cost-guards.d.ts +2 -0
  114. package/dist/lib/cost-guards.js +18 -0
  115. package/dist/lib/daily-spend-cap.d.ts +58 -0
  116. package/dist/lib/daily-spend-cap.js +233 -0
  117. package/dist/lib/effective-locales.d.ts +181 -0
  118. package/dist/lib/effective-locales.js +302 -0
  119. package/dist/lib/error-messages.d.ts +245 -0
  120. package/dist/lib/error-messages.js +626 -0
  121. package/dist/lib/events.d.ts +39 -0
  122. package/dist/lib/events.js +146 -0
  123. package/dist/lib/exclude-fields.d.ts +3 -0
  124. package/dist/lib/exclude-fields.js +64 -0
  125. package/dist/lib/field-breadcrumb.d.ts +31 -0
  126. package/dist/lib/field-breadcrumb.js +227 -0
  127. package/dist/lib/field-diff.d.ts +1 -0
  128. package/dist/lib/field-diff.js +25 -0
  129. package/dist/lib/field-empty.d.ts +2 -0
  130. package/dist/lib/field-empty.js +68 -0
  131. package/dist/lib/field-resolver.d.ts +3 -0
  132. package/dist/lib/field-resolver.js +164 -0
  133. package/dist/lib/group-soft-skips.d.ts +39 -0
  134. package/dist/lib/group-soft-skips.js +45 -0
  135. package/dist/lib/locale-merge.d.ts +44 -0
  136. package/dist/lib/locale-merge.js +357 -0
  137. package/dist/lib/locale-row-check.d.ts +30 -0
  138. package/dist/lib/locale-row-check.js +64 -0
  139. package/dist/lib/logger.d.ts +74 -0
  140. package/dist/lib/logger.js +97 -0
  141. package/dist/lib/manual-edit-guard.d.ts +128 -0
  142. package/dist/lib/manual-edit-guard.js +393 -0
  143. package/dist/lib/output-validation.d.ts +48 -0
  144. package/dist/lib/output-validation.js +148 -0
  145. package/dist/lib/payload-read.d.ts +16 -0
  146. package/dist/lib/payload-read.js +51 -0
  147. package/dist/lib/per-doc-claim.d.ts +90 -0
  148. package/dist/lib/per-doc-claim.js +140 -0
  149. package/dist/lib/per-doc-lock.d.ts +94 -0
  150. package/dist/lib/per-doc-lock.js +119 -0
  151. package/dist/lib/persist-usage.d.ts +91 -0
  152. package/dist/lib/persist-usage.js +116 -0
  153. package/dist/lib/progress-store.d.ts +103 -0
  154. package/dist/lib/progress-store.js +314 -0
  155. package/dist/lib/rate-limiter.d.ts +3 -0
  156. package/dist/lib/rate-limiter.js +53 -0
  157. package/dist/lib/snapshot-select.d.ts +43 -0
  158. package/dist/lib/snapshot-select.js +108 -0
  159. package/dist/lib/translate-prompt.d.ts +31 -0
  160. package/dist/lib/translate-prompt.js +66 -0
  161. package/dist/lib/translation-token-bucket.d.ts +57 -0
  162. package/dist/lib/translation-token-bucket.js +365 -0
  163. package/dist/lib/truncate-source-value.d.ts +1 -0
  164. package/dist/lib/truncate-source-value.js +27 -0
  165. package/dist/manual-edit-collection.d.ts +22 -0
  166. package/dist/manual-edit-collection.js +124 -0
  167. package/dist/plugin.d.ts +3 -0
  168. package/dist/plugin.js +934 -0
  169. package/dist/providers/ai-sdk-adapter.d.ts +35 -0
  170. package/dist/providers/ai-sdk-adapter.js +100 -0
  171. package/dist/providers/anthropic.d.ts +31 -0
  172. package/dist/providers/anthropic.js +66 -0
  173. package/dist/providers/custom.d.ts +36 -0
  174. package/dist/providers/custom.js +24 -0
  175. package/dist/providers/gemini.d.ts +20 -0
  176. package/dist/providers/gemini.js +48 -0
  177. package/dist/providers/mock.d.ts +2 -0
  178. package/dist/providers/mock.js +29 -0
  179. package/dist/providers/openai.d.ts +28 -0
  180. package/dist/providers/openai.js +69 -0
  181. package/dist/settings-global.d.ts +74 -0
  182. package/dist/settings-global.js +216 -0
  183. package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
  184. package/dist/tasks/bulk-translate-coordinator.js +708 -0
  185. package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
  186. package/dist/tasks/bulk-translate-doc-task.js +1000 -0
  187. package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
  188. package/dist/tasks/bulk-translate-janitor.js +311 -0
  189. package/dist/tasks/translate-job-task.d.ts +51 -0
  190. package/dist/tasks/translate-job-task.js +154 -0
  191. package/dist/translate.d.ts +113 -0
  192. package/dist/translate.js +911 -0
  193. package/dist/translation-daily-spend-collection.d.ts +24 -0
  194. package/dist/translation-daily-spend-collection.js +133 -0
  195. package/dist/translation-rate-limits-collection.d.ts +30 -0
  196. package/dist/translation-rate-limits-collection.js +144 -0
  197. package/dist/types.d.ts +672 -0
  198. package/dist/types.js +1 -0
  199. package/dist/usage-collection.d.ts +14 -0
  200. package/dist/usage-collection.js +377 -0
  201. package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
  202. package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
  203. package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
  204. package/dist/views/BulkRunsHub/BucketRow.js +982 -0
  205. package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
  206. package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
  207. package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
  208. package/dist/views/BulkRunsHub/EmptyState.js +64 -0
  209. package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
  210. package/dist/views/BulkRunsHub/FilterBar.js +284 -0
  211. package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
  212. package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
  213. package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
  214. package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
  215. package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
  216. package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
  217. package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
  218. package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
  219. package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
  220. package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
  221. package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
  222. package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
  223. package/dist/views/BulkRunsHub/index.d.ts +17 -0
  224. package/dist/views/BulkRunsHub/index.js +80 -0
  225. package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
  226. package/dist/views/BulkRunsHub/urlFilters.js +50 -0
  227. package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
  228. package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
  229. package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
  230. package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
  231. package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
  232. package/dist/views/TranslationHub/ActiveJobs.js +320 -0
  233. package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
  234. package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
  235. package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
  236. package/dist/views/TranslationHub/AlertBanner.js +568 -0
  237. package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
  238. package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
  239. package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
  240. package/dist/views/TranslationHub/AuditPanel.js +1367 -0
  241. package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
  242. package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
  243. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
  244. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
  245. package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
  246. package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
  247. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
  248. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
  249. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
  250. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
  251. package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
  252. package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
  253. package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
  254. package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
  255. package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
  256. package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
  257. package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
  258. package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
  259. package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
  260. package/dist/views/TranslationHub/Hub.client.js +357 -0
  261. package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
  262. package/dist/views/TranslationHub/ModelCombobox.js +415 -0
  263. package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
  264. package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
  265. package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
  266. package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
  267. package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
  268. package/dist/views/TranslationHub/SettingsRail.js +382 -0
  269. package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
  270. package/dist/views/TranslationHub/StatusStrip.js +451 -0
  271. package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
  272. package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
  273. package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
  274. package/dist/views/TranslationHub/UsageTable.js +1240 -0
  275. package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
  276. package/dist/views/TranslationHub/alertGrouping.js +99 -0
  277. package/dist/views/TranslationHub/index.d.ts +20 -0
  278. package/dist/views/TranslationHub/index.js +109 -0
  279. package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
  280. package/dist/views/TranslationHub/tabNavigation.js +74 -0
  281. package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
  282. package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
  283. package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
  284. package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
  285. package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
  286. package/dist/views/TranslationHub/useFocusTrap.js +81 -0
  287. package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
  288. package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
  289. package/dist/views/shared/EditorError.d.ts +97 -0
  290. package/dist/views/shared/EditorError.js +205 -0
  291. package/dist/views/shared/ModelCell.d.ts +18 -0
  292. package/dist/views/shared/ModelCell.js +31 -0
  293. package/dist/views/shared/docHref.d.ts +16 -0
  294. package/dist/views/shared/docHref.js +26 -0
  295. package/dist/views/shared/fetch-error-body.d.ts +25 -0
  296. package/dist/views/shared/fetch-error-body.js +42 -0
  297. package/dist/views/shared/filterPillStyle.d.ts +35 -0
  298. package/dist/views/shared/filterPillStyle.js +40 -0
  299. package/dist/views/shared/format.d.ts +75 -0
  300. package/dist/views/shared/format.js +131 -0
  301. package/package.json +141 -0
@@ -0,0 +1,164 @@
1
+ const TRANSLATABLE_LEAF_TYPES = new Set([
2
+ 'text',
3
+ 'textarea',
4
+ 'richText',
5
+ 'json'
6
+ ]);
7
+ const CONTAINER_TYPES = new Set([
8
+ 'group',
9
+ 'array',
10
+ 'blocks'
11
+ ]);
12
+ const TRANSPARENT_TYPES = new Set([
13
+ 'row',
14
+ 'collapsible'
15
+ ]);
16
+ function buildPath(parent, segment) {
17
+ return parent ? `${parent}.${segment}` : segment;
18
+ }
19
+ function isNamedTab(tab) {
20
+ return 'name' in tab && typeof tab.name === 'string';
21
+ }
22
+ function hasFieldsProperty(field) {
23
+ return 'fields' in field && Array.isArray(field.fields);
24
+ }
25
+ function hasBlocksProperty(field) {
26
+ return 'blocks' in field && Array.isArray(field.blocks);
27
+ }
28
+ function hasTabsProperty(field) {
29
+ return 'tabs' in field && Array.isArray(field.tabs);
30
+ }
31
+ function getFieldName(field) {
32
+ return 'name' in field ? field.name : undefined;
33
+ }
34
+ function isLocalized(field) {
35
+ return 'localized' in field && field.localized === true;
36
+ }
37
+ export function resolveTranslatableFields(fields, parentPath = '', inheritedLocalized = false) {
38
+ const result = [];
39
+ for (const field of fields){
40
+ const { type } = field;
41
+ // Transparent layout containers — recurse without adding a path segment
42
+ if (TRANSPARENT_TYPES.has(type)) {
43
+ if (hasFieldsProperty(field)) {
44
+ result.push(...resolveTranslatableFields(field.fields, parentPath, inheritedLocalized));
45
+ }
46
+ continue;
47
+ }
48
+ // Tabs — each tab's fields are traversed; named tabs add a path segment
49
+ if (type === 'tabs' && hasTabsProperty(field)) {
50
+ for (const tab of field.tabs){
51
+ const tabPath = isNamedTab(tab) ? buildPath(parentPath, tab.name) : parentPath;
52
+ result.push(...resolveTranslatableFields(tab.fields, tabPath, inheritedLocalized));
53
+ }
54
+ continue;
55
+ }
56
+ // From here, only named fields matter
57
+ const name = getFieldName(field);
58
+ if (!name) continue;
59
+ // Payload auto-injects an `id` text field onto every array item at runtime.
60
+ // It carries the row's UUID and must never be sent through translation —
61
+ // the locale-update would also reject the write since `id` is read-only on
62
+ // partial updates.
63
+ if (name === 'id' && type === 'text') continue;
64
+ const fieldPath = buildPath(parentPath, name);
65
+ // A field is translatable if it's marked localized OR an ancestor
66
+ // container (array/blocks/group) is localized. Payload localizes the
67
+ // entire container per locale, so descendants implicitly need translation
68
+ // even when not individually flagged `localized: true`.
69
+ const localized = isLocalized(field) || inheritedLocalized;
70
+ // Translatable leaf types
71
+ if (TRANSLATABLE_LEAF_TYPES.has(type)) {
72
+ if (localized) {
73
+ result.push({
74
+ path: fieldPath,
75
+ type: type,
76
+ localized: true
77
+ });
78
+ }
79
+ continue;
80
+ }
81
+ // Group
82
+ if (type === 'group' && hasFieldsProperty(field)) {
83
+ if (localized) {
84
+ result.push({
85
+ path: fieldPath,
86
+ type: 'group',
87
+ localized: true
88
+ });
89
+ result.push(...resolveTranslatableFields(field.fields, fieldPath, true));
90
+ } else {
91
+ result.push(...resolveTranslatableFields(field.fields, fieldPath, false));
92
+ }
93
+ continue;
94
+ }
95
+ // Array
96
+ if (type === 'array' && hasFieldsProperty(field)) {
97
+ if (localized) {
98
+ result.push({
99
+ path: fieldPath,
100
+ type: 'array',
101
+ localized: true
102
+ });
103
+ // Recurse so descendant leaves are discoverable by the extractor.
104
+ // All descendants inherit localization from this array.
105
+ result.push(...resolveTranslatableFields(field.fields, fieldPath, true));
106
+ } else {
107
+ // Items are indexed at runtime; recurse with arrayName prefix so
108
+ // individual localized fields inside the array are discovered.
109
+ const children = resolveTranslatableFields(field.fields, fieldPath, false);
110
+ // If any descendant is localized (e.g. a navigation `label` field
111
+ // marked `localized: true` while the parent array is shared across
112
+ // locales — the canonical Payload pattern for "structure shared,
113
+ // labels per-locale"), we still need to emit the array entry so
114
+ // `extractTranslationUnits` calls `extractArrayUnits` and iterates
115
+ // items by index. Without this, leaf paths like
116
+ // `items.label` are emitted but never resolved — `getValueAtPath`
117
+ // bails at the array because `'label'` isn't a numeric segment.
118
+ if (children.some((c)=>c.localized)) {
119
+ result.push({
120
+ path: fieldPath,
121
+ type: 'array',
122
+ localized: false
123
+ });
124
+ }
125
+ result.push(...children);
126
+ }
127
+ continue;
128
+ }
129
+ // Blocks
130
+ if (type === 'blocks' && hasBlocksProperty(field)) {
131
+ if (localized) {
132
+ result.push({
133
+ path: fieldPath,
134
+ type: 'blocks',
135
+ localized: true
136
+ });
137
+ for (const block of field.blocks){
138
+ result.push(...resolveTranslatableFields(block.fields, fieldPath, true));
139
+ }
140
+ } else {
141
+ // Same rationale as the array branch above: when any block's
142
+ // descendant is individually localized, emit the blocks entry so
143
+ // `extractBlocksUnits` runs and resolves leaves through runtime
144
+ // indexing. Without it, paths inside blocks-children are emitted
145
+ // but never resolved by the extractor.
146
+ const children = [];
147
+ for (const block of field.blocks){
148
+ children.push(...resolveTranslatableFields(block.fields, fieldPath, false));
149
+ }
150
+ if (children.some((c)=>c.localized)) {
151
+ result.push({
152
+ path: fieldPath,
153
+ type: 'blocks',
154
+ localized: false
155
+ });
156
+ }
157
+ result.push(...children);
158
+ }
159
+ }
160
+ // All other types (number, email, date, select, radio, checkbox,
161
+ // json, code, point, relationship, upload, ui, join) — skip
162
+ }
163
+ return result;
164
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Group soft-skipped fields across locales by their source value.
3
+ *
4
+ * The Translation Hub renders these for editors who want to understand
5
+ * which source strings the AI declined to translate. Today the AI may
6
+ * echo a brand name ("Wheel Of") across every locale — surfacing that
7
+ * once per (field × locale) drowns the editor in repetition. Grouping
8
+ * by value collapses the noise:
9
+ *
10
+ * "Wheel Of"
11
+ * used in: sidebar.featured_sidebar_items.3.label
12
+ * kept in: de, es, fr (3 locales)
13
+ *
14
+ * Falls back to grouping by path for legacy rows where `sourceValue`
15
+ * was never persisted (pre-1.2.8) — those entries display the path
16
+ * alone, same as the pre-grouping rendering.
17
+ */
18
+ export type SoftSkipEntry = {
19
+ path: string;
20
+ locale: string;
21
+ reason?: string | null;
22
+ reasonCode?: string | null;
23
+ sourceValue?: string | null;
24
+ };
25
+ export type SoftSkipGroup = {
26
+ /** Source-locale text. Null when the entry is a legacy row with no value. */
27
+ sourceValue: string | null;
28
+ /** All field paths this value appears in (deduped, original order). */
29
+ paths: string[];
30
+ /** All locales the AI kept this value verbatim in (deduped, original order). */
31
+ locales: string[];
32
+ /** The first non-empty reason code in the group — used for the editor message. */
33
+ reasonCode: string | null;
34
+ /** First raw reason string seen in the group — used as the technical-details fallback. */
35
+ reason: string | null;
36
+ /** Total number of (path × locale) entries collapsed into this group. */
37
+ count: number;
38
+ };
39
+ export declare function groupSoftSkipsByValue(entries: ReadonlyArray<SoftSkipEntry>): SoftSkipGroup[];
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Group soft-skipped fields across locales by their source value.
3
+ *
4
+ * The Translation Hub renders these for editors who want to understand
5
+ * which source strings the AI declined to translate. Today the AI may
6
+ * echo a brand name ("Wheel Of") across every locale — surfacing that
7
+ * once per (field × locale) drowns the editor in repetition. Grouping
8
+ * by value collapses the noise:
9
+ *
10
+ * "Wheel Of"
11
+ * used in: sidebar.featured_sidebar_items.3.label
12
+ * kept in: de, es, fr (3 locales)
13
+ *
14
+ * Falls back to grouping by path for legacy rows where `sourceValue`
15
+ * was never persisted (pre-1.2.8) — those entries display the path
16
+ * alone, same as the pre-grouping rendering.
17
+ */ export function groupSoftSkipsByValue(entries) {
18
+ const buckets = new Map();
19
+ for (const e of entries){
20
+ // Bucket on source value when present; fall back to path so legacy
21
+ // rows (no sourceValue) still appear once per path rather than
22
+ // collapsing every legacy entry into a single noise bucket.
23
+ const bucketKey = e.sourceValue ? `v:${e.sourceValue}` : `p:${e.path}`;
24
+ let group = buckets.get(bucketKey);
25
+ if (!group) {
26
+ group = {
27
+ sourceValue: e.sourceValue ?? null,
28
+ paths: [],
29
+ locales: [],
30
+ reasonCode: e.reasonCode ?? null,
31
+ reason: e.reason ?? null,
32
+ count: 0
33
+ };
34
+ buckets.set(bucketKey, group);
35
+ }
36
+ if (!group.paths.includes(e.path)) group.paths.push(e.path);
37
+ if (!group.locales.includes(e.locale)) group.locales.push(e.locale);
38
+ if (!group.reasonCode && e.reasonCode) group.reasonCode = e.reasonCode;
39
+ if (!group.reason && e.reason) group.reason = e.reason;
40
+ group.count += 1;
41
+ }
42
+ return [
43
+ ...buckets.values()
44
+ ];
45
+ }
@@ -0,0 +1,44 @@
1
+ import type { TranslatableField } from '../types.js';
2
+ export type MergeParams = {
3
+ sourceDoc: Record<string, unknown>;
4
+ targetDoc: Record<string, unknown> | null | undefined;
5
+ translatedMap: Map<string, string>;
6
+ fields: TranslatableField[];
7
+ translatedTopFields: Set<string>;
8
+ /**
9
+ * When provided, overrides the union derived from `fields`. Used by callers
10
+ * that filter `fields` down to a subset (e.g. per-field translate) but
11
+ * still need the merge to include all localized top-level fields so
12
+ * required-localized siblings stay populated in the locale-write body.
13
+ */
14
+ allLocalizedTopFields?: Set<string>;
15
+ /**
16
+ * `'full'` (default): emit every localized top-level field, filling
17
+ * untranslated siblings from existing target / source fallback. Safe
18
+ * default — Payload's locale-write validation always passes.
19
+ *
20
+ * `'minimal'`: emit ONLY top-level fields that actually got translated.
21
+ * Caller accepts that Payload may reject the write when other localized
22
+ * required fields are empty in target. Useful when target is known to be
23
+ * fully populated and the caller doesn't want to touch siblings.
24
+ */
25
+ writeMode?: 'full' | 'minimal';
26
+ };
27
+ /**
28
+ * Build the writeData object for a locale-specific update.
29
+ *
30
+ * Strategy: for each top-level field that has at least one translated unit,
31
+ * start from the existing target-locale value (so we preserve any prior
32
+ * translations + required-localized siblings), fall back to the source value
33
+ * when target is empty/missing, then overlay the new translations via the
34
+ * existing patcher. Strip Payload internal columns recursively before
35
+ * returning.
36
+ *
37
+ * Why merge target-first: `payload.update({ locale, data })` validates the
38
+ * data against the full schema, so if a required-localized field is missing
39
+ * in the target locale Payload rejects the write. Reading the target locale
40
+ * (via `findByIdNoFallback`/`findGlobalNoFallback`) gives us the existing
41
+ * shape we can safely overlay. When the target is fresh (no rows yet) we
42
+ * fall back to the source clone — Payload generates ids on insert.
43
+ */
44
+ export declare function mergeTranslationsIntoTargetDoc(params: MergeParams): Record<string, unknown>;
@@ -0,0 +1,357 @@
1
+ import { patchTranslatedContent } from './content-patcher.js';
2
+ // Fields Payload either auto-generates or rejects on update. Stripped
3
+ // recursively from the merge output before write so the locale update body
4
+ // never carries internals that round-tripped from a `findByID` read.
5
+ //
6
+ // `_locale`, `_parent_id`, `_uuid` are the row-level columns Payload exposes
7
+ // for nested localized rows (arrays, blocks, groups). They surface in the
8
+ // JSON shape returned by the local API but Payload's update validator
9
+ // rejects them as invalid input.
10
+ // Fields Payload either auto-generates or rejects on update. They CAN
11
+ // surface in the source doc the merge is built from (relationship
12
+ // hydration, nested upload metadata) but must never be sent back via
13
+ // `payload.update` — Payload either generates them server-side or
14
+ // rejects them as invalid input.
15
+ //
16
+ // Notably absent: `url`, `filename`, `thumbnailURL`. Those would catch
17
+ // auto-generated media-doc fields IF a Media collection were translated
18
+ // directly, but they're also legitimate text-field names users give to
19
+ // regular content (link `url`, slug-like `filename`). Stripping them
20
+ // blanket breaks normal writes. The locale-write only emits translated
21
+ // top-level fields anyway, so a Media doc's auto-generated `url` never
22
+ // enters writeData unless the consumer explicitly translates it.
23
+ const STRIP_FIELDS = new Set([
24
+ // Doc-level system fields
25
+ 'createdAt',
26
+ 'updatedAt',
27
+ '_status',
28
+ // Localization internals
29
+ '_locale',
30
+ '_parent_id',
31
+ '_uuid',
32
+ // nestedDocs plugin
33
+ 'slugLock',
34
+ 'breadcrumbs'
35
+ ]);
36
+ /**
37
+ * Build the writeData object for a locale-specific update.
38
+ *
39
+ * Strategy: for each top-level field that has at least one translated unit,
40
+ * start from the existing target-locale value (so we preserve any prior
41
+ * translations + required-localized siblings), fall back to the source value
42
+ * when target is empty/missing, then overlay the new translations via the
43
+ * existing patcher. Strip Payload internal columns recursively before
44
+ * returning.
45
+ *
46
+ * Why merge target-first: `payload.update({ locale, data })` validates the
47
+ * data against the full schema, so if a required-localized field is missing
48
+ * in the target locale Payload rejects the write. Reading the target locale
49
+ * (via `findByIdNoFallback`/`findGlobalNoFallback`) gives us the existing
50
+ * shape we can safely overlay. When the target is fresh (no rows yet) we
51
+ * fall back to the source clone — Payload generates ids on insert.
52
+ */ export function mergeTranslationsIntoTargetDoc(params) {
53
+ const { sourceDoc, targetDoc, translatedMap, fields, translatedTopFields, allLocalizedTopFields, writeMode = 'full' } = params;
54
+ // Top-level fields that are localized in the schema. The `'full'` mode
55
+ // (default) includes ALL of them in the write data — not just those with
56
+ // translated units — so Payload's required-localized-field validation
57
+ // passes when the target locale row is fresh. Each value still uses the
58
+ // target-first / source-fallback strategy below, so we never overwrite
59
+ // existing target content with source values.
60
+ //
61
+ // Why `'full'` matters: per-field translate restricts `translatedTopFields`
62
+ // to a single key (e.g. `{'title'}`). Without this expansion, writing to
63
+ // a locale that doesn't yet have a row would fail with
64
+ // "field is invalid: <other-required-localized-field>" because Payload
65
+ // validates the full schema on the locale-update body.
66
+ //
67
+ // `'minimal'` mode skips the expansion entirely — only translated top-
68
+ // fields are written. Caller is responsible for ensuring Payload accepts
69
+ // a partial write (target row already populated, no required-but-empty
70
+ // siblings).
71
+ const localizedTopFields = new Set(translatedTopFields);
72
+ if (writeMode === 'full') {
73
+ if (allLocalizedTopFields) {
74
+ for (const name of allLocalizedTopFields)localizedTopFields.add(name);
75
+ } else {
76
+ for (const f of fields){
77
+ if (f.localized) {
78
+ localizedTopFields.add(f.path.split('.')[0]);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ // Build the merge base: target value if present, else source value.
84
+ // For array-typed fields and any nested arrays we encounter while walking,
85
+ // align the base shape to the source shape (see `alignBaseShapeToSource`).
86
+ // For object-typed fields (groups, JSON containers), align recursively via
87
+ // `alignObjectArraysToSource` so target objects reshape to match source —
88
+ // dropped keys are pruned, nested arrays reshape, and new keys get seeded
89
+ // from source. The patcher walks paths derived from source extraction;
90
+ // when target shape diverges from source, path-keyed translations silently
91
+ // drop on the floor and stale data persists. Aligning shape recursively
92
+ // kills that bug class at every nesting level instead of only the top.
93
+ const base = {};
94
+ for (const topField of localizedTopFields){
95
+ const targetValue = targetDoc?.[topField];
96
+ if (targetValue !== undefined && targetValue !== null && !isEmpty(targetValue)) {
97
+ base[topField] = structuredClone(targetValue);
98
+ const sourceValue = sourceDoc[topField];
99
+ if (Array.isArray(base[topField]) && Array.isArray(sourceValue)) {
100
+ alignBaseShapeToSource(base[topField], sourceValue);
101
+ } else if (base[topField] && sourceValue && typeof base[topField] === 'object' && typeof sourceValue === 'object' && !Array.isArray(base[topField]) && !Array.isArray(sourceValue)) {
102
+ // Object reshape: drop target-only keys, propagate source nesting,
103
+ // align nested arrays. Skips Lexical roots (`root` key) — those have
104
+ // their own deserializer path via `patchTranslatedContent`.
105
+ alignObjectArraysToSource(base[topField], sourceValue);
106
+ }
107
+ } else if (topField in sourceDoc) {
108
+ // Fresh target locale (no prior translation data). Use source
109
+ // as the shape skeleton, but STRIP array-row `id` fields so
110
+ // Payload generates fresh ones for the target locale. If we
111
+ // kept the source ids, the locale write would try to insert
112
+ // rows into shared `<col>_blocks_*` / `<col>_*` parent tables
113
+ // with ids that ALREADY EXIST (those are the source-locale's
114
+ // rows) — Payload's drizzle `upsertRow` then throws
115
+ // "Value must be unique: id" and the whole locale write
116
+ // fails. Reproduced 2026-06-04 on `pages/34` for every
117
+ // non-EN locale that had no prior data; debug trail confirmed
118
+ // writeData carried source-locale block ids verbatim.
119
+ //
120
+ // We DON'T strip ids in the targetValue-present branch above
121
+ // because reusing existing target row ids is safe (and
122
+ // intentional — stripping ids on a row-present write forces a
123
+ // delete+recreate that cascades into other locales' `_locales`
124
+ // children, the 2026-06-04 nav-data-loss incident the v1.2.7
125
+ // STRIP_FIELDS comment warns about).
126
+ base[topField] = stripArrayItemIds(structuredClone(sourceDoc[topField]));
127
+ }
128
+ }
129
+ // Apply translations onto the base. The patcher mutates a clone of `base`
130
+ // and writes translated leaves into it. `sourceDoc` is passed separately
131
+ // so the patcher can recover structural skeletons (Lexical trees, etc.)
132
+ // from the TRUE source when the merge base has an empty / placeholder
133
+ // shape from a fresh target locale — without this, Lexical content gets
134
+ // flattened to a single empty paragraph.
135
+ const patched = patchTranslatedContent(base, translatedMap, fields, sourceDoc);
136
+ // Emit all localized top-level fields. System columns stripped recursively;
137
+ // array-item ids unconditionally stripped (see comment above STRIP_FIELDS).
138
+ const writeData = {};
139
+ for (const topField of localizedTopFields){
140
+ if (topField in patched) {
141
+ writeData[topField] = stripSystem(patched[topField]);
142
+ }
143
+ }
144
+ return writeData;
145
+ }
146
+ function isEmpty(value) {
147
+ if (value === null || value === undefined) return true;
148
+ if (Array.isArray(value)) return value.length === 0;
149
+ if (typeof value === 'object') {
150
+ return Object.keys(value).length === 0;
151
+ }
152
+ return false;
153
+ }
154
+ /**
155
+ * Mutates `base` so its array shape matches `source` at every nesting level.
156
+ * Truncates excess items, fills sparse/null slots from source, and replaces
157
+ * items where shape (array vs object vs primitive) or `blockType` diverges.
158
+ * Recurses into matching object slots and matching array slots so deletions,
159
+ * reorders, and type swaps inside nested arrays propagate too — not just at
160
+ * the top level.
161
+ *
162
+ * Why this exists: the patcher walks paths derived from the source. When
163
+ * target shape diverges from source, path-keyed translations land on stale
164
+ * shapes (or vanish silently). Aligning recursively kills that bug class at
165
+ * every nesting level instead of patching one boundary.
166
+ */ function alignBaseShapeToSource(base, source) {
167
+ base.length = source.length;
168
+ for(let i = 0; i < source.length; i++){
169
+ const baseItem = base[i];
170
+ const sourceItem = source[i];
171
+ if (baseItem === undefined || baseItem === null) {
172
+ // Gap fill: target has no counterpart at this index. The new row
173
+ // must NOT inherit source's array-row ids — `_parent_id` on nested
174
+ // child tables points at the source-locale's parent row, so writing
175
+ // these ids under the target locale fails with "field is invalid:
176
+ // id". Strip top-level + nested ids so Payload generates fresh ones.
177
+ base[i] = cloneSourceItemAsNewLocaleRow(sourceItem);
178
+ continue;
179
+ }
180
+ const baseIsArray = Array.isArray(baseItem);
181
+ const sourceIsArray = Array.isArray(sourceItem);
182
+ // Shape divergence: array-vs-not, object-vs-primitive — replace from source.
183
+ // Same id-strip reasoning as the gap-fill branch above.
184
+ if (baseIsArray !== sourceIsArray || typeof baseItem !== typeof sourceItem) {
185
+ base[i] = cloneSourceItemAsNewLocaleRow(sourceItem);
186
+ continue;
187
+ }
188
+ if (baseIsArray && sourceIsArray) {
189
+ alignBaseShapeToSource(baseItem, sourceItem);
190
+ continue;
191
+ }
192
+ if (baseItem && sourceItem && typeof baseItem === 'object' && !baseIsArray) {
193
+ const baseObj = baseItem;
194
+ const sourceObj = sourceItem;
195
+ const baseType = baseObj.blockType;
196
+ const sourceType = sourceObj.blockType;
197
+ if (baseType !== undefined && sourceType !== undefined && baseType !== sourceType) {
198
+ // Block type changed — base item's ids belong to the OLD blockType
199
+ // and can't be reused for the new shape. Strip and let Payload
200
+ // regenerate.
201
+ base[i] = cloneSourceItemAsNewLocaleRow(sourceItem);
202
+ continue;
203
+ }
204
+ alignObjectArraysToSource(baseObj, sourceObj);
205
+ }
206
+ }
207
+ }
208
+ /**
209
+ * Walks an object and recurses into any arrays/objects on the source side,
210
+ * aligning the base's matching array fields and pruning keys the source no
211
+ * longer has. Skips Lexical roots (`root` key) which have their own
212
+ * deserializer; flattening those would break rich text.
213
+ *
214
+ * Key pruning is required for free-shape fields (`type: 'json'`) where the
215
+ * source object's keys can change between publishes. Without pruning, target
216
+ * keeps stale keys removed from source. For schema-fixed fields (block sub-
217
+ * fields, group fields), pruning is harmless because Payload validates
218
+ * writes against the schema regardless.
219
+ */ function alignObjectArraysToSource(base, source) {
220
+ if ('root' in source) return; // Lexical — leave to deserializer
221
+ // Prune keys present in base but absent from source.
222
+ for (const key of Object.keys(base)){
223
+ if (!(key in source)) {
224
+ delete base[key];
225
+ }
226
+ }
227
+ for (const key of Object.keys(source)){
228
+ const baseVal = base[key];
229
+ const sourceVal = source[key];
230
+ if (Array.isArray(sourceVal)) {
231
+ if (Array.isArray(baseVal)) {
232
+ alignBaseShapeToSource(baseVal, sourceVal);
233
+ } else {
234
+ // Shape divergence at this key — base had something non-array,
235
+ // source has an array. Strip element ids on the source clone so
236
+ // Payload regenerates them for the new locale's rows.
237
+ base[key] = stripArrayItemIds(structuredClone(sourceVal));
238
+ }
239
+ } else if (sourceVal && baseVal && typeof sourceVal === 'object' && typeof baseVal === 'object' && !Array.isArray(sourceVal) && !Array.isArray(baseVal)) {
240
+ alignObjectArraysToSource(baseVal, sourceVal);
241
+ } else if (baseVal === undefined && sourceVal !== undefined) {
242
+ // Source has a key base doesn't (after the prune step this means base
243
+ // never had it). Seed from source so subsequent translation units land.
244
+ // Nested array ids stripped — same _parent_id reasoning as in
245
+ // `alignBaseShapeToSource`.
246
+ base[key] = walkAndStripArrayIds(structuredClone(sourceVal));
247
+ }
248
+ }
249
+ }
250
+ function stripSystem(value) {
251
+ if (value === null || value === undefined) return value;
252
+ if (typeof value !== 'object') return value;
253
+ if (Array.isArray(value)) {
254
+ // Recurse — each child is an array item. We PRESERVE the item's `id`
255
+ // (see comment on the id-handling branch below).
256
+ return value.map((item)=>stripSystem(item));
257
+ }
258
+ const obj = value;
259
+ // RichText (Lexical) values have a `root` key and shouldn't be flattened.
260
+ // Their internal structure is a separate concern handled by the lexical
261
+ // serializer/deserializer.
262
+ if ('root' in obj) {
263
+ return obj;
264
+ }
265
+ const out = {};
266
+ for (const [key, val] of Object.entries(obj)){
267
+ if (STRIP_FIELDS.has(key)) continue;
268
+ // v1.2.7 critical fix — DO NOT strip array-item `id`.
269
+ //
270
+ // Pre-v1.2.7 this function stripped `id` from every array item before
271
+ // a locale-scoped `updateGlobal` / `updateOne` write, on the (wrong)
272
+ // theory that Payload would regenerate fresh ids per locale. In
273
+ // Payload's Postgres adapter the array-row id is SHARED across
274
+ // locales — the `_locales` child tables key on (_parent_id, _locale)
275
+ // with `ON DELETE CASCADE` on _parent_id. When the incoming array
276
+ // items have no id, Payload cannot match them to existing rows and
277
+ // DELETE+RECREATE the parent rows, which cascades into the `_locales`
278
+ // children of EVERY locale (including the source). Result: writing
279
+ // the German translation wipes the English source labels of every
280
+ // localized array on the global. This was the cause of the
281
+ // wild-payload-cms / wolf nav-data-loss incident (2026-06-04).
282
+ //
283
+ // Preserve `id` whenever the array item already carries one — that
284
+ // tells Payload "match this existing row, just update the per-locale
285
+ // leaf values". Items with no id are genuinely new (rare on a
286
+ // translation write path) and Payload will create them.
287
+ out[key] = stripSystem(val);
288
+ }
289
+ return out;
290
+ }
291
+ /**
292
+ * Recursively strip the `id` field from every array item in `value`,
293
+ * leaving object-level ids and primitive values untouched. Used by the
294
+ * merge function when a fresh target locale falls back to cloning the
295
+ * source — keeping the source's array-row ids would collide with the
296
+ * existing source rows in Payload's shared `<col>_blocks_*` /
297
+ * `<col>_<array>` tables (Postgres PK conflict). Stripping them lets
298
+ * Payload generate fresh ids for the new locale.
299
+ *
300
+ * Only `id` on array elements is stripped — non-array object `id`
301
+ * fields (e.g. Lexical node fragments that may carry an id for
302
+ * downstream tooling) stay intact. This narrower scope avoids the
303
+ * cascading delete+recreate of `_locales` children that the
304
+ * unconditional `stripSystem` strip caused in pre-1.2.7.
305
+ */ function stripArrayItemIds(value) {
306
+ if (value === null || value === undefined || typeof value !== 'object') {
307
+ return value;
308
+ }
309
+ if (Array.isArray(value)) {
310
+ return value.map((item)=>{
311
+ if (item !== null && typeof item === 'object' && !Array.isArray(item) && 'id' in item) {
312
+ const { id: _stripped, ...rest } = item;
313
+ // Recurse into the stripped item's own nested arrays so deep
314
+ // array structures (e.g. blocks → array of items → array of
315
+ // sub-items) all get their array-row ids stripped, not just
316
+ // the top level.
317
+ return walkAndStripArrayIds(rest);
318
+ }
319
+ return walkAndStripArrayIds(item);
320
+ });
321
+ }
322
+ return walkAndStripArrayIds(value);
323
+ }
324
+ function walkAndStripArrayIds(value) {
325
+ if (value === null || value === undefined || typeof value !== 'object') {
326
+ return value;
327
+ }
328
+ if (Array.isArray(value)) {
329
+ return stripArrayItemIds(value);
330
+ }
331
+ const out = {};
332
+ for (const [key, val] of Object.entries(value)){
333
+ out[key] = walkAndStripArrayIds(val);
334
+ }
335
+ return out;
336
+ }
337
+ /**
338
+ * Clone an array element with its top-level `id` AND every nested
339
+ * array-row `id` stripped. Use when the alignment step fills a gap in
340
+ * the target array from a source item — Payload must generate fresh
341
+ * ids for the new locale's rows; carrying source ids forward causes
342
+ * "field is invalid: id" on locale-scoped writes whose nested arrays
343
+ * point at the wrong _parent_id.
344
+ *
345
+ * Symmetric to `stripArrayItemIds` but applied to a SINGLE item rather
346
+ * than mapping over an array. Preserves the v1.2.7 invariant that
347
+ * existing target-row ids are NOT stripped: callers only invoke this
348
+ * on items they are creating fresh, never on items already present in
349
+ * the target locale.
350
+ */ function cloneSourceItemAsNewLocaleRow(item) {
351
+ const cloned = structuredClone(item);
352
+ if (cloned !== null && typeof cloned === 'object' && !Array.isArray(cloned) && 'id' in cloned) {
353
+ const { id: _stripped, ...rest } = cloned;
354
+ return walkAndStripArrayIds(rest);
355
+ }
356
+ return walkAndStripArrayIds(cloned);
357
+ }