@nitrogenbuilder/connector-payload 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/collections/NitrogenTemplates.js +8 -0
- package/dist/endpoints/all.js +3 -0
- package/dist/endpoints/batch.js +9 -0
- package/dist/endpoints/collection-endpoints.js +119 -3
- package/dist/endpoints/helpers.js +2 -1
- package/dist/endpoints/menu.js +2 -0
- package/dist/endpoints/sitemap.js +47 -7
- package/dist/endpoints/templates.js +116 -0
- package/dist/endpoints/translation-status.d.ts +11 -0
- package/dist/endpoints/translation-status.js +105 -0
- package/dist/frontend/NitrogenPageClient.d.ts +6 -1
- package/dist/frontend/NitrogenPageClient.js +6 -2
- package/dist/frontend/NitrogenWrapper.d.ts +8 -1
- package/dist/frontend/NitrogenWrapper.js +25 -3
- package/dist/index.d.ts +22 -0
- package/dist/index.js +69 -2
- package/dist/inventory/indexing.d.ts +2 -0
- package/dist/inventory/indexing.js +1 -0
- package/dist/localization.d.ts +121 -0
- package/dist/localization.js +563 -0
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localization support — locale threading for read endpoints, dynamic-data
|
|
3
|
+
* translation metadata, and per-language translation status for builder
|
|
4
|
+
* content.
|
|
5
|
+
*
|
|
6
|
+
* Two independent layers cooperate here:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Payload-native localization** (field data). When the host project's
|
|
9
|
+
* Payload config has `localization` configured (either directly or injected
|
|
10
|
+
* from the plugin's `localization` option), eligible text fields are
|
|
11
|
+
* localized and read endpoints thread `?lang=` through to
|
|
12
|
+
* `payload.find`/`findByID` as `locale` + `fallbackLocale`.
|
|
13
|
+
* 2. **Nitrogen settings localization** (builder content). Site languages live
|
|
14
|
+
* in the `nitrogen-settings` global under `nitrogenConfig.localization`.
|
|
15
|
+
* Translatable prop values inside `nitrogenData` are stored language-keyed
|
|
16
|
+
* with the language as the OUTER key (`{ en: ..., es: ... }`); legacy bare
|
|
17
|
+
* values read as the default language.
|
|
18
|
+
*
|
|
19
|
+
* Everything is gated: with no localization configured anywhere, every helper
|
|
20
|
+
* returns null/empty and callers behave exactly as before.
|
|
21
|
+
*/
|
|
22
|
+
import { NITROGEN_COMPONENT_CATALOG_COLLECTION, findAllDocs, } from './inventory/indexing.js';
|
|
23
|
+
/**
|
|
24
|
+
* Reads the sanitized Payload localization config at runtime. Returns null
|
|
25
|
+
* when the project has no Payload-level localization configured.
|
|
26
|
+
*/
|
|
27
|
+
export function getPayloadLocalization(payload) {
|
|
28
|
+
const localization = payload.config.localization;
|
|
29
|
+
if (!localization || typeof localization !== 'object')
|
|
30
|
+
return null;
|
|
31
|
+
const value = localization;
|
|
32
|
+
let locales = [];
|
|
33
|
+
if (Array.isArray(value.localeCodes)) {
|
|
34
|
+
locales = value.localeCodes.map(String).filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
else if (Array.isArray(value.locales)) {
|
|
37
|
+
locales = value.locales
|
|
38
|
+
.map((locale) => typeof locale === 'string'
|
|
39
|
+
? locale
|
|
40
|
+
: String(locale?.code ?? ''))
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
const defaultLocale = typeof value.defaultLocale === 'string' ? value.defaultLocale : '';
|
|
44
|
+
if (locales.length === 0 || !defaultLocale)
|
|
45
|
+
return null;
|
|
46
|
+
return { locales, defaultLocale };
|
|
47
|
+
}
|
|
48
|
+
/** Reads `?lang=` (alias `?locale=`) off a request URL. */
|
|
49
|
+
export function getRequestLanguage(req) {
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(req.url || '', 'http://localhost');
|
|
52
|
+
const lang = url.searchParams.get('lang') || url.searchParams.get('locale') || '';
|
|
53
|
+
return lang.trim() || null;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Turns a requested language into `locale`/`fallbackLocale` options for
|
|
61
|
+
* `payload.find`/`findByID`. Returns `{}` (default behavior) when no language
|
|
62
|
+
* was requested, the project has no Payload localization, or the language is
|
|
63
|
+
* not a configured locale.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveLocaleOptions(payload, lang) {
|
|
66
|
+
if (!lang)
|
|
67
|
+
return {};
|
|
68
|
+
const localization = getPayloadLocalization(payload);
|
|
69
|
+
if (!localization || !localization.locales.includes(lang))
|
|
70
|
+
return {};
|
|
71
|
+
return { locale: lang, fallbackLocale: localization.defaultLocale };
|
|
72
|
+
}
|
|
73
|
+
export function getCollectionConfig(payload, collectionSlug) {
|
|
74
|
+
return (payload.config.collections || []).find((collection) => collection.slug === collectionSlug);
|
|
75
|
+
}
|
|
76
|
+
/** Text-bearing field types eligible for translation. */
|
|
77
|
+
const LOCALIZABLE_FIELD_TYPES = new Set(['text', 'textarea', 'richText']);
|
|
78
|
+
/**
|
|
79
|
+
* Dot-paths of localized text-typed fields of a collection config. Walks
|
|
80
|
+
* groups, rows, collapsibles, and tabs; named groups/tabs contribute a path
|
|
81
|
+
* segment.
|
|
82
|
+
*/
|
|
83
|
+
export function getLocalizedTextFieldPaths(fields, prefix = '') {
|
|
84
|
+
const paths = [];
|
|
85
|
+
for (const field of fields || []) {
|
|
86
|
+
const f = field;
|
|
87
|
+
const name = typeof f.name === 'string' && f.name ? f.name : undefined;
|
|
88
|
+
if (name && LOCALIZABLE_FIELD_TYPES.has(f.type) && f.localized) {
|
|
89
|
+
paths.push(`${prefix}${name}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Array/blocks rows can't be addressed by a dot-path (getByPath cannot
|
|
93
|
+
// index rows), so only non-repeating containers are walked.
|
|
94
|
+
if (Array.isArray(f.fields) && f.type !== 'array') {
|
|
95
|
+
const nestedPrefix = name && f.type === 'group' ? `${prefix}${name}.` : prefix;
|
|
96
|
+
paths.push(...getLocalizedTextFieldPaths(f.fields, nestedPrefix));
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(f.tabs)) {
|
|
99
|
+
for (const tab of f.tabs) {
|
|
100
|
+
const nestedPrefix = tab.name ? `${prefix}${tab.name}.` : prefix;
|
|
101
|
+
paths.push(...getLocalizedTextFieldPaths(tab.fields || [], nestedPrefix));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return paths;
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Nitrogen settings localization (builder content)
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
/**
|
|
111
|
+
* The site language config from the `nitrogen-settings` global. Null when
|
|
112
|
+
* localization is absent or disabled — callers must treat null as
|
|
113
|
+
* "no localization behavior at all".
|
|
114
|
+
*/
|
|
115
|
+
export function getLocalizationSettings(settings) {
|
|
116
|
+
const localization = settings.nitrogenConfig?.localization;
|
|
117
|
+
if (!localization || typeof localization !== 'object')
|
|
118
|
+
return null;
|
|
119
|
+
const value = localization;
|
|
120
|
+
if (!value.enabled ||
|
|
121
|
+
!value.defaultLanguage ||
|
|
122
|
+
!Array.isArray(value.languages) ||
|
|
123
|
+
value.languages.length === 0) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Shared value helpers
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
function hasContent(value) {
|
|
132
|
+
if (value === undefined || value === null)
|
|
133
|
+
return false;
|
|
134
|
+
if (typeof value === 'string')
|
|
135
|
+
return value.trim() !== '';
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
function getByPath(obj, path) {
|
|
139
|
+
let current = obj;
|
|
140
|
+
for (const part of path.split('.')) {
|
|
141
|
+
if (!current || typeof current !== 'object')
|
|
142
|
+
return undefined;
|
|
143
|
+
current = current[part];
|
|
144
|
+
}
|
|
145
|
+
return current;
|
|
146
|
+
}
|
|
147
|
+
function isPlainObject(value) {
|
|
148
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A stored translatable value: every key is a configured language code.
|
|
152
|
+
* (Responsive values keyed by breakpoint names never collide because
|
|
153
|
+
* breakpoint names are not language codes.)
|
|
154
|
+
*/
|
|
155
|
+
function isLangMap(value, codes) {
|
|
156
|
+
const keys = Object.keys(value);
|
|
157
|
+
return keys.length > 0 && keys.every((key) => codes.has(key));
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Dynamic data meta
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
/** `Post.`-namespaced dynamic-data keys for a doc field path. */
|
|
163
|
+
function dynamicDataKeysForField(path) {
|
|
164
|
+
const keys = [`Post.${path}`];
|
|
165
|
+
if (path === 'title')
|
|
166
|
+
keys.push('Post.post_title');
|
|
167
|
+
return keys;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Fetches a doc with every locale's value for localized fields (one
|
|
171
|
+
* `locale: 'all'` query). Returns null when the project has no Payload
|
|
172
|
+
* localization configured or the doc can't be read — advisory callers keep
|
|
173
|
+
* their default behavior in that case.
|
|
174
|
+
*/
|
|
175
|
+
export async function fetchLocaleAllDoc(payload, collectionSlug, docId) {
|
|
176
|
+
if (!getPayloadLocalization(payload))
|
|
177
|
+
return null;
|
|
178
|
+
try {
|
|
179
|
+
return (await payload.findByID({
|
|
180
|
+
collection: collectionSlug,
|
|
181
|
+
id: docId,
|
|
182
|
+
depth: 0,
|
|
183
|
+
locale: 'all',
|
|
184
|
+
overrideAccess: true,
|
|
185
|
+
disableErrors: true,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The default-locale title from a `locale: 'all'` doc, for editor-facing
|
|
194
|
+
* single-doc responses. The editor edits default-language field data only
|
|
195
|
+
* (the CMS admin owns field translations), so the `title` it displays and
|
|
196
|
+
* PATCHes back on save must never be a locale-resolved one. Returns null when
|
|
197
|
+
* there is nothing to override (no localization, title not localized, or no
|
|
198
|
+
* stored title) — callers then keep the response title as-is.
|
|
199
|
+
*/
|
|
200
|
+
export function getDefaultLocaleTitle(payload, localeAllDoc) {
|
|
201
|
+
const localization = getPayloadLocalization(payload);
|
|
202
|
+
if (!localization || !localeAllDoc)
|
|
203
|
+
return null;
|
|
204
|
+
const title = localeAllDoc.title;
|
|
205
|
+
if (!isPlainObject(title))
|
|
206
|
+
return null;
|
|
207
|
+
const value = title[localization.defaultLocale];
|
|
208
|
+
if (typeof value === 'string')
|
|
209
|
+
return value;
|
|
210
|
+
// No default-locale title stored — first stored one, matching the
|
|
211
|
+
// renderer's language-resolution fallback chain.
|
|
212
|
+
for (const candidate of Object.values(title)) {
|
|
213
|
+
if (typeof candidate === 'string' && candidate)
|
|
214
|
+
return candidate;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Builds the connector-provided metadata the editor uses to badge
|
|
220
|
+
* untranslated CMS fields. Eligible keys are the localized text-typed fields
|
|
221
|
+
* of the collection config; translated keys per language are the fields whose
|
|
222
|
+
* locale-specific value exists (checked via one `locale: 'all'` fetch, or a
|
|
223
|
+
* caller-preloaded `localeAllDoc` to avoid a duplicate query).
|
|
224
|
+
*
|
|
225
|
+
* Returns null when the project has no Payload localization configured, so
|
|
226
|
+
* callers can attach `dynamic_data_meta` conditionally with zero change to
|
|
227
|
+
* the default response shape.
|
|
228
|
+
*/
|
|
229
|
+
export async function buildDynamicDataMeta(payload, collectionSlug, docId, localeAllDoc) {
|
|
230
|
+
const localization = getPayloadLocalization(payload);
|
|
231
|
+
if (!localization)
|
|
232
|
+
return null;
|
|
233
|
+
const collection = getCollectionConfig(payload, collectionSlug);
|
|
234
|
+
if (!collection)
|
|
235
|
+
return null;
|
|
236
|
+
const fieldPaths = getLocalizedTextFieldPaths(collection.fields);
|
|
237
|
+
const meta = {
|
|
238
|
+
eligibleKeys: fieldPaths.flatMap(dynamicDataKeysForField),
|
|
239
|
+
translatedKeys: {},
|
|
240
|
+
perPostNamespaces: ['Post'],
|
|
241
|
+
};
|
|
242
|
+
if (fieldPaths.length === 0)
|
|
243
|
+
return meta;
|
|
244
|
+
const doc = localeAllDoc !== undefined
|
|
245
|
+
? localeAllDoc
|
|
246
|
+
: docId !== undefined
|
|
247
|
+
? await fetchLocaleAllDoc(payload, collectionSlug, docId)
|
|
248
|
+
: null;
|
|
249
|
+
if (!doc)
|
|
250
|
+
return meta;
|
|
251
|
+
for (const lang of localization.locales) {
|
|
252
|
+
const translated = [];
|
|
253
|
+
for (const path of fieldPaths) {
|
|
254
|
+
const value = getByPath(doc, path);
|
|
255
|
+
if (isPlainObject(value) && hasContent(value[lang])) {
|
|
256
|
+
translated.push(...dynamicDataKeysForField(path));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
meta.translatedKeys[lang] = translated;
|
|
260
|
+
}
|
|
261
|
+
return meta;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Loads component prop definitions from the nitrogen-component-catalog
|
|
265
|
+
* collection so translatable props can be identified precisely. Returns an
|
|
266
|
+
* empty map when the catalog is unavailable — the walk then falls back to the
|
|
267
|
+
* lang-map heuristic.
|
|
268
|
+
*/
|
|
269
|
+
export async function loadTranslatablePropDefs(payload) {
|
|
270
|
+
const defs = new Map();
|
|
271
|
+
try {
|
|
272
|
+
const docs = await findAllDocs(payload, NITROGEN_COMPONENT_CATALOG_COLLECTION);
|
|
273
|
+
for (const doc of docs) {
|
|
274
|
+
const definition = doc.definition;
|
|
275
|
+
if (!definition?.categories)
|
|
276
|
+
continue;
|
|
277
|
+
const byGroup = {};
|
|
278
|
+
for (const category of Object.values(definition.categories)) {
|
|
279
|
+
for (const [groupKey, group] of Object.entries(category?.groups || {})) {
|
|
280
|
+
if (!group?.props || Object.keys(group.props).length === 0)
|
|
281
|
+
continue;
|
|
282
|
+
byGroup[groupKey] = { ...byGroup[groupKey], ...group.props };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (Object.keys(byGroup).length > 0) {
|
|
286
|
+
defs.set(doc.componentName, byGroup);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Catalog unavailable — heuristic fallback.
|
|
292
|
+
}
|
|
293
|
+
return defs;
|
|
294
|
+
}
|
|
295
|
+
function countLangMap(value, ctx, counter) {
|
|
296
|
+
const hasDefault = hasContent(value[ctx.defaultLanguage]);
|
|
297
|
+
const hasLang = hasContent(value[ctx.lang]);
|
|
298
|
+
if (!hasDefault && !hasLang)
|
|
299
|
+
return;
|
|
300
|
+
counter.total += 1;
|
|
301
|
+
if (hasDefault && !hasLang)
|
|
302
|
+
counter.untranslated += 1;
|
|
303
|
+
}
|
|
304
|
+
/** A DynamicValue binding: `{ key, before?, fallback?, after? }`. */
|
|
305
|
+
function isDynamicValue(value) {
|
|
306
|
+
if (typeof value.key !== 'string')
|
|
307
|
+
return false;
|
|
308
|
+
const keys = Object.keys(value);
|
|
309
|
+
return keys.every((key) => ['key', 'before', 'fallback', 'after'].includes(key));
|
|
310
|
+
}
|
|
311
|
+
function countDynamicValueSlots(value, ctx, counter) {
|
|
312
|
+
for (const slot of ['before', 'fallback', 'after']) {
|
|
313
|
+
const slotValue = value[slot];
|
|
314
|
+
if (slotValue === undefined || typeof slotValue === 'boolean')
|
|
315
|
+
continue;
|
|
316
|
+
if (isPlainObject(slotValue) && isLangMap(slotValue, ctx.codes)) {
|
|
317
|
+
// Language-keyed boolean toggles (`fallback: { en: true }`) are
|
|
318
|
+
// settings, not text — mirror the bare-boolean skip above.
|
|
319
|
+
if (typeof slotValue[ctx.defaultLanguage] === 'boolean' ||
|
|
320
|
+
typeof slotValue[ctx.lang] === 'boolean') {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
countLangMap(slotValue, ctx, counter);
|
|
324
|
+
}
|
|
325
|
+
else if (hasContent(slotValue)) {
|
|
326
|
+
// Legacy bare value = default language only.
|
|
327
|
+
counter.total += 1;
|
|
328
|
+
counter.untranslated += 1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Heuristic walk for props without catalog definitions: any nested object
|
|
334
|
+
* whose keys are all configured language codes counts as a translatable
|
|
335
|
+
* value; it is untranslated in L when it has a default-language value but no
|
|
336
|
+
* L value. Bare values are NOT counted here (we cannot know they are
|
|
337
|
+
* translatable without a definition).
|
|
338
|
+
*/
|
|
339
|
+
function walkValueHeuristic(value, ctx, counter) {
|
|
340
|
+
if (!value || typeof value !== 'object')
|
|
341
|
+
return;
|
|
342
|
+
if (Array.isArray(value)) {
|
|
343
|
+
for (const item of value)
|
|
344
|
+
walkValueHeuristic(item, ctx, counter);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const record = value;
|
|
348
|
+
if (isLangMap(record, ctx.codes)) {
|
|
349
|
+
countLangMap(record, ctx, counter);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
for (const [key, nested] of Object.entries(record)) {
|
|
353
|
+
if (key === 'children')
|
|
354
|
+
continue;
|
|
355
|
+
walkValueHeuristic(nested, ctx, counter);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Editor storage convention: `props[group][propKey + '___dynamic']` holds a
|
|
359
|
+
* DynamicValue binding for the base prop. */
|
|
360
|
+
const DYNAMIC_KEY_SUFFIX = '___dynamic';
|
|
361
|
+
/**
|
|
362
|
+
* One stored prop entry, dispatching on the editor's key conventions.
|
|
363
|
+
* `<key>___dynamic` entries hold DynamicValue bindings whose text slots are
|
|
364
|
+
* page content regardless of the base prop's own translatability (the
|
|
365
|
+
* renderer language-resolves every dynamic slot).
|
|
366
|
+
*/
|
|
367
|
+
function countPropEntry(key, value, defs, ctx, counter) {
|
|
368
|
+
if (key.endsWith(DYNAMIC_KEY_SUFFIX)) {
|
|
369
|
+
if (isPlainObject(value) && isDynamicValue(value)) {
|
|
370
|
+
countDynamicValueSlots(value, ctx, counter);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
walkValueHeuristic(value, ctx, counter);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
countPropValue(value, defs?.[key], ctx, counter);
|
|
378
|
+
}
|
|
379
|
+
function countPropValue(value, def, ctx, counter) {
|
|
380
|
+
if (def?.translatable) {
|
|
381
|
+
if (isPlainObject(value)) {
|
|
382
|
+
if (isLangMap(value, ctx.codes)) {
|
|
383
|
+
countLangMap(value, ctx, counter);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (isDynamicValue(value)) {
|
|
387
|
+
countDynamicValueSlots(value, ctx, counter);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (hasContent(value)) {
|
|
392
|
+
// Legacy bare value = default language only.
|
|
393
|
+
counter.total += 1;
|
|
394
|
+
counter.untranslated += 1;
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (def?.type === 'array' && Array.isArray(value)) {
|
|
399
|
+
for (const item of value) {
|
|
400
|
+
if (!isPlainObject(item))
|
|
401
|
+
continue;
|
|
402
|
+
for (const [key, nested] of Object.entries(item)) {
|
|
403
|
+
countPropEntry(key, nested, def.props, ctx, counter);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (def?.props && isPlainObject(value)) {
|
|
409
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
410
|
+
countPropEntry(key, nested, def.props, ctx, counter);
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!def)
|
|
415
|
+
walkValueHeuristic(value, ctx, counter);
|
|
416
|
+
}
|
|
417
|
+
function moduleNameFromNode(node) {
|
|
418
|
+
for (const key of ['module', 'moduleId']) {
|
|
419
|
+
const value = node[key];
|
|
420
|
+
if (typeof value === 'string')
|
|
421
|
+
return value;
|
|
422
|
+
if (isPlainObject(value) &&
|
|
423
|
+
typeof value.name === 'string') {
|
|
424
|
+
return value.name;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Walks the module tree counting translatable prop values for one language.
|
|
431
|
+
* Modules hidden in that language are skipped entirely, including their
|
|
432
|
+
* subtree. The editor stores hidden languages at `props.__hiddenLanguages`
|
|
433
|
+
* (canonical); module-level `hiddenLanguages` is the legacy fallback.
|
|
434
|
+
*/
|
|
435
|
+
function walkModules(modules, ctx, defs, counter) {
|
|
436
|
+
if (!Array.isArray(modules))
|
|
437
|
+
return;
|
|
438
|
+
for (const maybeNode of modules) {
|
|
439
|
+
if (!isPlainObject(maybeNode))
|
|
440
|
+
continue;
|
|
441
|
+
const node = maybeNode;
|
|
442
|
+
const props = node.props;
|
|
443
|
+
const hiddenLanguages = (isPlainObject(props) ? props.__hiddenLanguages : undefined) ??
|
|
444
|
+
node.hiddenLanguages;
|
|
445
|
+
if (Array.isArray(hiddenLanguages) && hiddenLanguages.includes(ctx.lang)) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const componentName = moduleNameFromNode(node);
|
|
449
|
+
const componentDefs = componentName ? defs.get(componentName) : undefined;
|
|
450
|
+
if (isPlainObject(props)) {
|
|
451
|
+
// Prop values are stored group-keyed: props[groupKey][propKey].
|
|
452
|
+
// `__`-prefixed keys are instance metadata (__label, __hiddenLanguages).
|
|
453
|
+
for (const [groupKey, groupValue] of Object.entries(props)) {
|
|
454
|
+
if (groupKey === 'children' || groupKey.startsWith('__'))
|
|
455
|
+
continue;
|
|
456
|
+
if (isPlainObject(groupValue)) {
|
|
457
|
+
if (isLangMap(groupValue, ctx.codes)) {
|
|
458
|
+
// Flat (ungrouped) translatable value — count it directly.
|
|
459
|
+
countLangMap(groupValue, ctx, counter);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const groupDefs = componentDefs?.[groupKey];
|
|
463
|
+
for (const [propKey, propValue] of Object.entries(groupValue)) {
|
|
464
|
+
countPropEntry(propKey, propValue, groupDefs, ctx, counter);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
walkValueHeuristic(groupValue, ctx, counter);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const children = props.children;
|
|
472
|
+
if (Array.isArray(children)) {
|
|
473
|
+
walkModules(children, ctx, defs, counter);
|
|
474
|
+
}
|
|
475
|
+
else if (isPlainObject(children)) {
|
|
476
|
+
for (const slotChildren of Object.values(children)) {
|
|
477
|
+
walkModules(slotChildren, ctx, defs, counter);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (Array.isArray(node.children)) {
|
|
482
|
+
walkModules(node.children, ctx, defs, counter);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Computes per-language translation status for one page/template. Returns a
|
|
488
|
+
* `{ [langCode]: LanguageStatus }` map covering every configured non-default
|
|
489
|
+
* language, or null when there is nothing to compute.
|
|
490
|
+
*/
|
|
491
|
+
export async function computeDocTranslationStatus(args) {
|
|
492
|
+
const { payload, collectionSlug, docId, nitrogenData, localization } = args;
|
|
493
|
+
const defaultLanguage = localization.defaultLanguage;
|
|
494
|
+
const languages = (localization.languages || [])
|
|
495
|
+
.map((language) => language.code)
|
|
496
|
+
.filter(Boolean);
|
|
497
|
+
const nonDefault = languages.filter((code) => code !== defaultLanguage);
|
|
498
|
+
if (!defaultLanguage || nonDefault.length === 0)
|
|
499
|
+
return null;
|
|
500
|
+
const codes = new Set(languages.concat(defaultLanguage));
|
|
501
|
+
const propDefs = args.propDefs ?? (await loadTranslatablePropDefs(payload));
|
|
502
|
+
// Field-side coverage relies on Payload-native localization.
|
|
503
|
+
let fieldPaths = [];
|
|
504
|
+
let localeAllDoc = args.localeAllDoc ?? null;
|
|
505
|
+
if (getPayloadLocalization(payload)) {
|
|
506
|
+
const collection = getCollectionConfig(payload, collectionSlug);
|
|
507
|
+
fieldPaths = collection ? getLocalizedTextFieldPaths(collection.fields) : [];
|
|
508
|
+
if (fieldPaths.length > 0 && localeAllDoc === null && args.localeAllDoc === undefined) {
|
|
509
|
+
localeAllDoc = await fetchLocaleAllDoc(payload, collectionSlug, docId);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const statuses = {};
|
|
513
|
+
for (const lang of nonDefault) {
|
|
514
|
+
const ctx = { lang, defaultLanguage, codes };
|
|
515
|
+
const propCounter = { total: 0, untranslated: 0 };
|
|
516
|
+
walkModules(nitrogenData, ctx, propDefs, propCounter);
|
|
517
|
+
const fieldCounter = { total: 0, untranslated: 0 };
|
|
518
|
+
let slugTranslated;
|
|
519
|
+
let seoTranslated;
|
|
520
|
+
if (localeAllDoc) {
|
|
521
|
+
let seoTotal = 0;
|
|
522
|
+
let seoUntranslated = 0;
|
|
523
|
+
for (const path of fieldPaths) {
|
|
524
|
+
const value = getByPath(localeAllDoc, path);
|
|
525
|
+
if (!isPlainObject(value))
|
|
526
|
+
continue;
|
|
527
|
+
const hasDefault = hasContent(value[defaultLanguage]);
|
|
528
|
+
const hasLang = hasContent(value[lang]);
|
|
529
|
+
if (path === 'slug') {
|
|
530
|
+
slugTranslated = hasLang;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (!hasDefault && !hasLang)
|
|
534
|
+
continue;
|
|
535
|
+
fieldCounter.total += 1;
|
|
536
|
+
if (hasDefault && !hasLang) {
|
|
537
|
+
fieldCounter.untranslated += 1;
|
|
538
|
+
if (path.startsWith('meta.'))
|
|
539
|
+
seoUntranslated += 1;
|
|
540
|
+
}
|
|
541
|
+
if (path.startsWith('meta.'))
|
|
542
|
+
seoTotal += 1;
|
|
543
|
+
}
|
|
544
|
+
if (seoTotal > 0)
|
|
545
|
+
seoTranslated = seoUntranslated === 0;
|
|
546
|
+
}
|
|
547
|
+
const total = propCounter.total + fieldCounter.total;
|
|
548
|
+
const untranslated = propCounter.untranslated + fieldCounter.untranslated;
|
|
549
|
+
const state = total === 0 || untranslated === 0
|
|
550
|
+
? 'complete'
|
|
551
|
+
: untranslated >= total
|
|
552
|
+
? 'none'
|
|
553
|
+
: 'partial';
|
|
554
|
+
statuses[lang] = {
|
|
555
|
+
state,
|
|
556
|
+
untranslatedProps: propCounter.untranslated,
|
|
557
|
+
untranslatedFields: fieldCounter.untranslated,
|
|
558
|
+
...(slugTranslated !== undefined ? { slugTranslated } : {}),
|
|
559
|
+
...(seoTranslated !== undefined ? { seoTranslated } : {}),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return statuses;
|
|
563
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -123,8 +123,11 @@ interface TemplateUpdateData {
|
|
|
123
123
|
}
|
|
124
124
|
export interface SlugLookupRequestBody {
|
|
125
125
|
slug?: string;
|
|
126
|
+
/** Language to resolve localized fields/slugs in (alias of ?lang=). */
|
|
127
|
+
lang?: string;
|
|
126
128
|
data?: {
|
|
127
129
|
slug?: string;
|
|
130
|
+
lang?: string;
|
|
128
131
|
};
|
|
129
132
|
}
|
|
130
133
|
export interface MediaUpdateRequestBody {
|
package/package.json
CHANGED