strapi-llm-translator 0.9.5 → 0.9.6

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/README.md CHANGED
@@ -6,7 +6,7 @@ The Strapi LLM Translator plugin enhances your localization workflow by utilisin
6
6
 
7
7
  ## 🚀 Key Features
8
8
 
9
- - 🌍 **Multi-field Support** - Translates all text-based fields (string, text, richtext)
9
+ - 🌍 **Multi-field Support** - Translates all text-based fields (string, text, richtext) and JSON/Blocks content, including Strapi 5 structured rich text
10
10
  - 🔌 **LLM Agnostic** - Works with any OpenAI-compatible API (your choice of provider)
11
11
  - 📝 **Format Preservation** - Maintains markdown formatting during translation
12
12
  - 🔗 **Smart UUID Handling** - Auto-translates slugs when i18n is enabled with relative fields
@@ -134,13 +134,32 @@ const cleanJSONString = (content) => {
134
134
  return content.replace(/^```json\s*\n/, "").replace(/^```\s*\n/, "").replace(/\n\s*```$/, "").replace(/\u200B/g, "").replace(/[\u2018\u2019]/g, "'").replace(/[\u201C\u201D]/g, '"').trim();
135
135
  };
136
136
  const balanceJSONBraces = (content) => {
137
- const openBraces = (content.match(/{/g) || []).length;
138
- const closeBraces = (content.match(/}/g) || []).length;
137
+ let openBraces = 0;
138
+ let closeBraces = 0;
139
+ let inString = false;
140
+ for (let i = 0; i < content.length; i += 1) {
141
+ const char = content[i];
142
+ if (char === '"' && content[i - 1] !== "\\") {
143
+ inString = !inString;
144
+ }
145
+ if (!inString) {
146
+ if (char === "{") openBraces += 1;
147
+ if (char === "}") closeBraces += 1;
148
+ }
149
+ }
139
150
  if (openBraces > closeBraces) {
140
151
  return content + "}".repeat(openBraces - closeBraces);
141
152
  }
142
153
  return content;
143
154
  };
155
+ const extractJSONObject = (content) => {
156
+ const firstBrace = content.indexOf("{");
157
+ const lastBrace = content.lastIndexOf("}");
158
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
159
+ return content.slice(firstBrace, lastBrace + 1);
160
+ }
161
+ return content;
162
+ };
144
163
  const safeJSONParse = (content) => {
145
164
  const parsed = JSON.parse(content);
146
165
  if (typeof parsed === "object" && parsed !== null) {
@@ -153,46 +172,56 @@ const openai = new openai$1.OpenAI({
153
172
  apiKey: process.env.LLM_TRANSLATOR_LLM_API_KEY
154
173
  });
155
174
  const model = process.env.STRAPI_ADMIN_LLM_TRANSLATOR_LLM_MODEL;
156
- const isTranslatableField = (contentType, key, value) => {
157
- if (typeof value !== "string") {
158
- return false;
159
- }
160
- const fieldSchema = contentType?.attributes?.[key];
161
- if (!fieldSchema) {
162
- return false;
163
- }
164
- const isStringOrText = ["string", "text", "richtext"].includes(fieldSchema.type);
165
- const isNotUID = fieldSchema.type !== "uid";
166
- const isLocalizable = fieldSchema.pluginOptions?.i18n?.localized !== false;
167
- return isStringOrText && isNotUID && isLocalizable;
168
- };
169
175
  const extractTranslatableFields = (contentType, fields, components = {}) => {
170
176
  const translatableFields = [];
171
- Object.entries(fields).forEach(([key, value]) => {
172
- if (isTranslatableField(contentType, key, value)) {
173
- translatableFields.push({
174
- path: [key],
175
- value,
176
- originalPath: [key]
177
- });
177
+ const isTranslatableFieldSchema = (schema, value) => {
178
+ if (!schema) {
179
+ return false;
178
180
  }
179
- });
180
- if (fields.blocks && Array.isArray(fields.blocks)) {
181
- fields.blocks.forEach((block, blockIndex) => {
182
- if (block.__component && components[block.__component]) {
183
- const componentSchema = components[block.__component];
184
- Object.entries(componentSchema.attributes).forEach(([fieldName, schema]) => {
185
- if (block[fieldName] && typeof block[fieldName] === "string" && ["string", "text", "richtext"].includes(schema.type)) {
186
- translatableFields.push({
187
- path: ["blocks", String(blockIndex), fieldName],
188
- value: block[fieldName],
189
- originalPath: ["blocks", String(blockIndex), fieldName]
190
- });
181
+ const { type } = schema;
182
+ const isStringType = ["string", "text"].includes(type) && typeof value === "string";
183
+ const isRichTextType = ["richtext", "richText", "blocks"].includes(type) && (typeof value === "string" || typeof value === "object");
184
+ const isJSONType = type === "json" && typeof value === "object";
185
+ const isNotUID = type !== "uid";
186
+ const isLocalizable = schema.pluginOptions?.i18n?.localized !== false;
187
+ return (isStringType || isRichTextType || isJSONType) && isNotUID && isLocalizable;
188
+ };
189
+ const traverse = (schema, data, path = [], originalPath = []) => {
190
+ Object.entries(schema.attributes || {}).forEach(([fieldName, fieldSchemaRaw]) => {
191
+ const fieldSchema = fieldSchemaRaw;
192
+ const value = data?.[fieldName];
193
+ if (value === void 0 || value === null) {
194
+ return;
195
+ }
196
+ if (isTranslatableFieldSchema(fieldSchema, value)) {
197
+ translatableFields.push({
198
+ path: [...path, fieldName],
199
+ value,
200
+ originalPath: [...originalPath, fieldName]
201
+ });
202
+ return;
203
+ }
204
+ if (fieldSchema.type === "component") {
205
+ const componentSchema = components[fieldSchema.component];
206
+ if (!componentSchema) return;
207
+ if (fieldSchema.repeatable && Array.isArray(value)) {
208
+ value.forEach(
209
+ (item, index2) => traverse(componentSchema, item, [...path, fieldName, String(index2)], [...originalPath, fieldName, String(index2)])
210
+ );
211
+ } else if (typeof value === "object") {
212
+ traverse(componentSchema, value, [...path, fieldName], [...originalPath, fieldName]);
213
+ }
214
+ } else if (fieldSchema.type === "dynamiczone" && Array.isArray(value)) {
215
+ value.forEach((item, index2) => {
216
+ const compSchema = components[item.__component];
217
+ if (compSchema) {
218
+ traverse(compSchema, item, [...path, fieldName, String(index2)], [...originalPath, fieldName, String(index2)]);
191
219
  }
192
220
  });
193
221
  }
194
222
  });
195
- }
223
+ };
224
+ traverse(contentType, fields, [], []);
196
225
  return translatableFields;
197
226
  };
198
227
  const prepareTranslationPayload = (fields) => {
@@ -326,7 +355,7 @@ IMPORTANT RULES:
326
355
  4. Keep HTML tags intact if present
327
356
  5. Preserve any special characters or placeholders
328
357
  6. Return ONLY the translated JSON object
329
- 7. Ensure the JSON is valid and well-formed, all values must be strings
358
+ 7. Ensure the JSON is valid and well-formed. Keep arrays and nested objects intact
330
359
  8. Do not add any explanations or comments
331
360
  9. Ensure professional and culturally appropriate translations
332
361
 
@@ -349,7 +378,8 @@ const createLLMRequest = (messages, temperature = 0.1) => {
349
378
  return openai.chat.completions.create({
350
379
  model,
351
380
  messages,
352
- temperature
381
+ temperature,
382
+ response_format: { type: "json_object" }
353
383
  });
354
384
  };
355
385
  const callLLMProvider = async (prompt, systemPrompt, model2, config2) => {
@@ -387,10 +417,11 @@ const parseLLMResponse = async (response) => {
387
417
  const content = response.choices[0]?.message?.content;
388
418
  if (!content) throw new Error("No content in response");
389
419
  const cleanContent = cleanJSONString(content);
420
+ const jsonContent = extractJSONObject(cleanContent);
390
421
  try {
391
- return safeJSONParse(cleanContent);
422
+ return safeJSONParse(jsonContent);
392
423
  } catch (parseError) {
393
- const balancedContent = balanceJSONBraces(cleanContent);
424
+ const balancedContent = balanceJSONBraces(jsonContent);
394
425
  try {
395
426
  return safeJSONParse(balancedContent);
396
427
  } catch (secondError) {
@@ -133,13 +133,32 @@ const cleanJSONString = (content) => {
133
133
  return content.replace(/^```json\s*\n/, "").replace(/^```\s*\n/, "").replace(/\n\s*```$/, "").replace(/\u200B/g, "").replace(/[\u2018\u2019]/g, "'").replace(/[\u201C\u201D]/g, '"').trim();
134
134
  };
135
135
  const balanceJSONBraces = (content) => {
136
- const openBraces = (content.match(/{/g) || []).length;
137
- const closeBraces = (content.match(/}/g) || []).length;
136
+ let openBraces = 0;
137
+ let closeBraces = 0;
138
+ let inString = false;
139
+ for (let i = 0; i < content.length; i += 1) {
140
+ const char = content[i];
141
+ if (char === '"' && content[i - 1] !== "\\") {
142
+ inString = !inString;
143
+ }
144
+ if (!inString) {
145
+ if (char === "{") openBraces += 1;
146
+ if (char === "}") closeBraces += 1;
147
+ }
148
+ }
138
149
  if (openBraces > closeBraces) {
139
150
  return content + "}".repeat(openBraces - closeBraces);
140
151
  }
141
152
  return content;
142
153
  };
154
+ const extractJSONObject = (content) => {
155
+ const firstBrace = content.indexOf("{");
156
+ const lastBrace = content.lastIndexOf("}");
157
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
158
+ return content.slice(firstBrace, lastBrace + 1);
159
+ }
160
+ return content;
161
+ };
143
162
  const safeJSONParse = (content) => {
144
163
  const parsed = JSON.parse(content);
145
164
  if (typeof parsed === "object" && parsed !== null) {
@@ -152,46 +171,56 @@ const openai = new OpenAI({
152
171
  apiKey: process.env.LLM_TRANSLATOR_LLM_API_KEY
153
172
  });
154
173
  const model = process.env.STRAPI_ADMIN_LLM_TRANSLATOR_LLM_MODEL;
155
- const isTranslatableField = (contentType, key, value) => {
156
- if (typeof value !== "string") {
157
- return false;
158
- }
159
- const fieldSchema = contentType?.attributes?.[key];
160
- if (!fieldSchema) {
161
- return false;
162
- }
163
- const isStringOrText = ["string", "text", "richtext"].includes(fieldSchema.type);
164
- const isNotUID = fieldSchema.type !== "uid";
165
- const isLocalizable = fieldSchema.pluginOptions?.i18n?.localized !== false;
166
- return isStringOrText && isNotUID && isLocalizable;
167
- };
168
174
  const extractTranslatableFields = (contentType, fields, components = {}) => {
169
175
  const translatableFields = [];
170
- Object.entries(fields).forEach(([key, value]) => {
171
- if (isTranslatableField(contentType, key, value)) {
172
- translatableFields.push({
173
- path: [key],
174
- value,
175
- originalPath: [key]
176
- });
176
+ const isTranslatableFieldSchema = (schema, value) => {
177
+ if (!schema) {
178
+ return false;
177
179
  }
178
- });
179
- if (fields.blocks && Array.isArray(fields.blocks)) {
180
- fields.blocks.forEach((block, blockIndex) => {
181
- if (block.__component && components[block.__component]) {
182
- const componentSchema = components[block.__component];
183
- Object.entries(componentSchema.attributes).forEach(([fieldName, schema]) => {
184
- if (block[fieldName] && typeof block[fieldName] === "string" && ["string", "text", "richtext"].includes(schema.type)) {
185
- translatableFields.push({
186
- path: ["blocks", String(blockIndex), fieldName],
187
- value: block[fieldName],
188
- originalPath: ["blocks", String(blockIndex), fieldName]
189
- });
180
+ const { type } = schema;
181
+ const isStringType = ["string", "text"].includes(type) && typeof value === "string";
182
+ const isRichTextType = ["richtext", "richText", "blocks"].includes(type) && (typeof value === "string" || typeof value === "object");
183
+ const isJSONType = type === "json" && typeof value === "object";
184
+ const isNotUID = type !== "uid";
185
+ const isLocalizable = schema.pluginOptions?.i18n?.localized !== false;
186
+ return (isStringType || isRichTextType || isJSONType) && isNotUID && isLocalizable;
187
+ };
188
+ const traverse = (schema, data, path = [], originalPath = []) => {
189
+ Object.entries(schema.attributes || {}).forEach(([fieldName, fieldSchemaRaw]) => {
190
+ const fieldSchema = fieldSchemaRaw;
191
+ const value = data?.[fieldName];
192
+ if (value === void 0 || value === null) {
193
+ return;
194
+ }
195
+ if (isTranslatableFieldSchema(fieldSchema, value)) {
196
+ translatableFields.push({
197
+ path: [...path, fieldName],
198
+ value,
199
+ originalPath: [...originalPath, fieldName]
200
+ });
201
+ return;
202
+ }
203
+ if (fieldSchema.type === "component") {
204
+ const componentSchema = components[fieldSchema.component];
205
+ if (!componentSchema) return;
206
+ if (fieldSchema.repeatable && Array.isArray(value)) {
207
+ value.forEach(
208
+ (item, index2) => traverse(componentSchema, item, [...path, fieldName, String(index2)], [...originalPath, fieldName, String(index2)])
209
+ );
210
+ } else if (typeof value === "object") {
211
+ traverse(componentSchema, value, [...path, fieldName], [...originalPath, fieldName]);
212
+ }
213
+ } else if (fieldSchema.type === "dynamiczone" && Array.isArray(value)) {
214
+ value.forEach((item, index2) => {
215
+ const compSchema = components[item.__component];
216
+ if (compSchema) {
217
+ traverse(compSchema, item, [...path, fieldName, String(index2)], [...originalPath, fieldName, String(index2)]);
190
218
  }
191
219
  });
192
220
  }
193
221
  });
194
- }
222
+ };
223
+ traverse(contentType, fields, [], []);
195
224
  return translatableFields;
196
225
  };
197
226
  const prepareTranslationPayload = (fields) => {
@@ -325,7 +354,7 @@ IMPORTANT RULES:
325
354
  4. Keep HTML tags intact if present
326
355
  5. Preserve any special characters or placeholders
327
356
  6. Return ONLY the translated JSON object
328
- 7. Ensure the JSON is valid and well-formed, all values must be strings
357
+ 7. Ensure the JSON is valid and well-formed. Keep arrays and nested objects intact
329
358
  8. Do not add any explanations or comments
330
359
  9. Ensure professional and culturally appropriate translations
331
360
 
@@ -348,7 +377,8 @@ const createLLMRequest = (messages, temperature = 0.1) => {
348
377
  return openai.chat.completions.create({
349
378
  model,
350
379
  messages,
351
- temperature
380
+ temperature,
381
+ response_format: { type: "json_object" }
352
382
  });
353
383
  };
354
384
  const callLLMProvider = async (prompt, systemPrompt, model2, config2) => {
@@ -386,10 +416,11 @@ const parseLLMResponse = async (response) => {
386
416
  const content = response.choices[0]?.message?.content;
387
417
  if (!content) throw new Error("No content in response");
388
418
  const cleanContent = cleanJSONString(content);
419
+ const jsonContent = extractJSONObject(cleanContent);
389
420
  try {
390
- return safeJSONParse(cleanContent);
421
+ return safeJSONParse(jsonContent);
391
422
  } catch (parseError) {
392
- const balancedContent = balanceJSONBraces(cleanContent);
423
+ const balancedContent = balanceJSONBraces(jsonContent);
393
424
  try {
394
425
  return safeJSONParse(balancedContent);
395
426
  } catch (secondError) {
@@ -32,7 +32,7 @@ export interface GenerateRequestBody {
32
32
  }
33
33
  export interface TranslatableField {
34
34
  path: string[];
35
- value: string;
35
+ value: any;
36
36
  originalPath: string[];
37
37
  }
38
38
  export interface UIDField {
@@ -1,3 +1,4 @@
1
1
  export declare const cleanJSONString: (content: string) => string;
2
2
  export declare const balanceJSONBraces: (content: string) => string;
3
+ export declare const extractJSONObject: (content: string) => string;
3
4
  export declare const safeJSONParse: (content: string) => Record<string, any>;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.9.5",
2
+ "version": "0.9.6",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "plugin",