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 +1 -1
- package/dist/server/index.js +70 -39
- package/dist/server/index.mjs +70 -39
- package/dist/server/src/types/index.d.ts +1 -1
- package/dist/server/src/utils/json-utils.d.ts +1 -0
- package/package.json +1 -1
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
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
path: [key],
|
|
175
|
-
value,
|
|
176
|
-
originalPath: [key]
|
|
177
|
-
});
|
|
177
|
+
const isTranslatableFieldSchema = (schema, value) => {
|
|
178
|
+
if (!schema) {
|
|
179
|
+
return false;
|
|
178
180
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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(
|
|
422
|
+
return safeJSONParse(jsonContent);
|
|
392
423
|
} catch (parseError) {
|
|
393
|
-
const balancedContent = balanceJSONBraces(
|
|
424
|
+
const balancedContent = balanceJSONBraces(jsonContent);
|
|
394
425
|
try {
|
|
395
426
|
return safeJSONParse(balancedContent);
|
|
396
427
|
} catch (secondError) {
|
package/dist/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
path: [key],
|
|
174
|
-
value,
|
|
175
|
-
originalPath: [key]
|
|
176
|
-
});
|
|
176
|
+
const isTranslatableFieldSchema = (schema, value) => {
|
|
177
|
+
if (!schema) {
|
|
178
|
+
return false;
|
|
177
179
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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(
|
|
421
|
+
return safeJSONParse(jsonContent);
|
|
391
422
|
} catch (parseError) {
|
|
392
|
-
const balancedContent = balanceJSONBraces(
|
|
423
|
+
const balancedContent = balanceJSONBraces(jsonContent);
|
|
393
424
|
try {
|
|
394
425
|
return safeJSONParse(balancedContent);
|
|
395
426
|
} catch (secondError) {
|
|
@@ -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