payload-translate 1.0.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/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # payload-translate
2
+
3
+ AI-powered translation plugin for Payload CMS using Google Gemini.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add payload-translate
9
+ # or
10
+ npm install payload-translate
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ ```ts
16
+ import { buildConfig } from 'payload'
17
+ import { payloadTranslate } from 'payload-translate'
18
+
19
+ export default buildConfig({
20
+ plugins: [
21
+ payloadTranslate({
22
+ apiKey: process.env.GEMINI_API_KEY,
23
+ collections: ['posts', 'pages'],
24
+ }),
25
+ ],
26
+ localization: {
27
+ defaultLocale: 'en',
28
+ locales: ['en', 'es', 'fr'],
29
+ },
30
+ })
31
+ ```
32
+
33
+ ## Options
34
+
35
+ | Option | Type | Required | Description |
36
+ |--------|------|----------|-------------|
37
+ | `apiKey` | `string` | Yes | Google Gemini API key |
38
+ | `collections` | `string[]` | Yes | Collection slugs to enable translation |
39
+ | `disabled` | `boolean` | No | Disable the plugin |
40
+
41
+ ## How It Works
42
+
43
+ 1. Open any document in an enabled collection
44
+ 2. Click the **Translate** button
45
+ 3. Select target locale
46
+ 4. Content is translated via Gemini and saved
47
+
48
+ ## Supported Fields
49
+
50
+ - Text fields
51
+ - Textarea fields
52
+ - Rich text (Lexical) - preserves formatting
53
+
54
+ ## Requirements
55
+
56
+ - Payload 3.x
57
+ - Localization enabled in Payload config
58
+ - Google Gemini API key
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,3 @@
1
+ import React from 'react';
2
+ import './index.scss';
3
+ export declare const TranslateButton: React.FC;
@@ -0,0 +1,150 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button, Modal, ReactSelect, useConfig, useDocumentInfo, useLocale, useModal } from '@payloadcms/ui';
4
+ import React, { useCallback, useState } from 'react';
5
+ import { toast } from 'sonner';
6
+ import './index.scss';
7
+ const MODAL_SLUG = 'translate-document-modal';
8
+ export const TranslateButton = ()=>{
9
+ const { config } = useConfig();
10
+ const { id, collectionSlug } = useDocumentInfo();
11
+ const locale = useLocale();
12
+ const { closeModal, openModal } = useModal();
13
+ const [isTranslating, setIsTranslating] = useState(false);
14
+ const [selectedLocale, setSelectedLocale] = useState(null);
15
+ // Get available locales (exclude current locale)
16
+ const localization = config.localization;
17
+ const locales = localization ? localization.locales : [];
18
+ const availableTargetLocales = locales.filter((l)=>{
19
+ const code = typeof l === 'string' ? l : l.code;
20
+ return code !== locale?.code;
21
+ });
22
+ const handleTranslate = useCallback(async ()=>{
23
+ if (!collectionSlug || !id || !locale?.code || !selectedLocale) {
24
+ return;
25
+ }
26
+ setIsTranslating(true);
27
+ closeModal(MODAL_SLUG);
28
+ try {
29
+ const response = await fetch(`${config.serverURL}${config.routes.api}/translate`, {
30
+ body: JSON.stringify({
31
+ collection: collectionSlug,
32
+ documentId: id,
33
+ sourceLocale: locale.code,
34
+ targetLocale: selectedLocale.value
35
+ }),
36
+ credentials: 'include',
37
+ headers: {
38
+ 'Content-Type': 'application/json'
39
+ },
40
+ method: 'POST'
41
+ });
42
+ const result = await response.json();
43
+ if (result.success) {
44
+ toast.success(result.message || 'Translation complete');
45
+ } else {
46
+ toast.error(result.error || 'Translation failed');
47
+ }
48
+ } catch (error) {
49
+ toast.error('Translation request failed');
50
+ console.error('Translation error:', error);
51
+ } finally{
52
+ setIsTranslating(false);
53
+ setSelectedLocale(null);
54
+ }
55
+ }, [
56
+ closeModal,
57
+ collectionSlug,
58
+ config,
59
+ id,
60
+ locale,
61
+ selectedLocale
62
+ ]);
63
+ const handleCancel = useCallback(()=>{
64
+ setSelectedLocale(null);
65
+ closeModal(MODAL_SLUG);
66
+ }, [
67
+ closeModal
68
+ ]);
69
+ // Don't render if no localization, no other locales, or document not saved yet
70
+ if (!config.localization || availableTargetLocales.length === 0 || !id) {
71
+ return null;
72
+ }
73
+ const localeOptions = availableTargetLocales.map((l)=>{
74
+ if (typeof l === 'string') {
75
+ return {
76
+ label: l.toUpperCase(),
77
+ value: l
78
+ };
79
+ }
80
+ return {
81
+ label: typeof l.label === 'string' ? l.label : l.code.toUpperCase(),
82
+ value: l.code
83
+ };
84
+ });
85
+ return /*#__PURE__*/ _jsxs(_Fragment, {
86
+ children: [
87
+ /*#__PURE__*/ _jsx(Button, {
88
+ buttonStyle: "secondary",
89
+ disabled: isTranslating,
90
+ onClick: ()=>openModal(MODAL_SLUG),
91
+ children: isTranslating ? 'Translating...' : 'Translate'
92
+ }),
93
+ /*#__PURE__*/ _jsx(Modal, {
94
+ className: "translate-modal",
95
+ slug: MODAL_SLUG,
96
+ children: /*#__PURE__*/ _jsxs("div", {
97
+ className: "translate-modal__wrapper",
98
+ children: [
99
+ /*#__PURE__*/ _jsxs("div", {
100
+ className: "translate-modal__content",
101
+ children: [
102
+ /*#__PURE__*/ _jsx("h3", {
103
+ children: "Translate Document"
104
+ }),
105
+ /*#__PURE__*/ _jsxs("p", {
106
+ children: [
107
+ "Translate content from ",
108
+ /*#__PURE__*/ _jsx("strong", {
109
+ children: locale?.code?.toUpperCase()
110
+ }),
111
+ " to:"
112
+ ]
113
+ }),
114
+ /*#__PURE__*/ _jsx("div", {
115
+ className: "translate-modal__select",
116
+ children: /*#__PURE__*/ _jsx(ReactSelect, {
117
+ isClearable: true,
118
+ onChange: (option)=>setSelectedLocale(option),
119
+ options: localeOptions,
120
+ placeholder: "Select target locale...",
121
+ value: selectedLocale ?? undefined
122
+ })
123
+ })
124
+ ]
125
+ }),
126
+ /*#__PURE__*/ _jsxs("div", {
127
+ className: "translate-modal__controls",
128
+ children: [
129
+ /*#__PURE__*/ _jsx(Button, {
130
+ buttonStyle: "secondary",
131
+ onClick: handleCancel,
132
+ size: "medium",
133
+ children: "Cancel"
134
+ }),
135
+ /*#__PURE__*/ _jsx(Button, {
136
+ disabled: !selectedLocale,
137
+ onClick: handleTranslate,
138
+ size: "medium",
139
+ children: "Translate"
140
+ })
141
+ ]
142
+ })
143
+ ]
144
+ })
145
+ })
146
+ ]
147
+ });
148
+ };
149
+
150
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/components/TranslateButton/index.tsx"],"sourcesContent":["'use client'\n\nimport type { Locale } from 'payload'\n\nimport {\n Button,\n Modal,\n ReactSelect,\n type ReactSelectOption,\n useConfig,\n useDocumentInfo,\n useLocale,\n useModal,\n} from '@payloadcms/ui'\nimport React, { useCallback, useState } from 'react'\nimport { toast } from 'sonner'\n\nimport './index.scss'\n\nconst MODAL_SLUG = 'translate-document-modal'\n\nexport const TranslateButton: React.FC = () => {\n const { config } = useConfig()\n const { id, collectionSlug } = useDocumentInfo()\n const locale = useLocale()\n const { closeModal, openModal } = useModal()\n const [isTranslating, setIsTranslating] = useState(false)\n const [selectedLocale, setSelectedLocale] = useState<null | ReactSelectOption>(null)\n\n // Get available locales (exclude current locale)\n const localization = config.localization\n const locales = localization ? localization.locales : []\n const availableTargetLocales = locales.filter((l: Locale | string) => {\n const code = typeof l === 'string' ? l : l.code\n return code !== locale?.code\n })\n\n const handleTranslate = useCallback(async () => {\n if (!collectionSlug || !id || !locale?.code || !selectedLocale) {\n return\n }\n\n setIsTranslating(true)\n closeModal(MODAL_SLUG)\n\n try {\n const response = await fetch(`${config.serverURL}${config.routes.api}/translate`, {\n body: JSON.stringify({\n collection: collectionSlug,\n documentId: id,\n sourceLocale: locale.code,\n targetLocale: selectedLocale.value,\n }),\n credentials: 'include',\n headers: {\n 'Content-Type': 'application/json',\n },\n method: 'POST',\n })\n\n const result = await response.json()\n\n if (result.success) {\n toast.success(result.message || 'Translation complete')\n } else {\n toast.error(result.error || 'Translation failed')\n }\n } catch (error) {\n toast.error('Translation request failed')\n console.error('Translation error:', error)\n } finally {\n setIsTranslating(false)\n setSelectedLocale(null)\n }\n }, [closeModal, collectionSlug, config, id, locale, selectedLocale])\n\n const handleCancel = useCallback(() => {\n setSelectedLocale(null)\n closeModal(MODAL_SLUG)\n }, [closeModal])\n\n // Don't render if no localization, no other locales, or document not saved yet\n if (!config.localization || availableTargetLocales.length === 0 || !id) {\n return null\n }\n\n const localeOptions: ReactSelectOption[] = availableTargetLocales.map((l: Locale | string) => {\n if (typeof l === 'string') {\n return { label: l.toUpperCase(), value: l }\n }\n return {\n label: typeof l.label === 'string' ? l.label : l.code.toUpperCase(),\n value: l.code,\n }\n })\n\n return (\n <>\n <Button\n buttonStyle=\"secondary\"\n disabled={isTranslating}\n onClick={() => openModal(MODAL_SLUG)}\n >\n {isTranslating ? 'Translating...' : 'Translate'}\n </Button>\n <Modal className=\"translate-modal\" slug={MODAL_SLUG}>\n <div className=\"translate-modal__wrapper\">\n <div className=\"translate-modal__content\">\n <h3>Translate Document</h3>\n <p>\n Translate content from <strong>{locale?.code?.toUpperCase()}</strong> to:\n </p>\n <div className=\"translate-modal__select\">\n <ReactSelect\n isClearable\n onChange={(option) => setSelectedLocale(option as ReactSelectOption)}\n options={localeOptions}\n placeholder=\"Select target locale...\"\n value={selectedLocale ?? undefined}\n />\n </div>\n </div>\n <div className=\"translate-modal__controls\">\n <Button buttonStyle=\"secondary\" onClick={handleCancel} size=\"medium\">\n Cancel\n </Button>\n <Button disabled={!selectedLocale} onClick={handleTranslate} size=\"medium\">\n Translate\n </Button>\n </div>\n </div>\n </Modal>\n </>\n )\n}\n"],"names":["Button","Modal","ReactSelect","useConfig","useDocumentInfo","useLocale","useModal","React","useCallback","useState","toast","MODAL_SLUG","TranslateButton","config","id","collectionSlug","locale","closeModal","openModal","isTranslating","setIsTranslating","selectedLocale","setSelectedLocale","localization","locales","availableTargetLocales","filter","l","code","handleTranslate","response","fetch","serverURL","routes","api","body","JSON","stringify","collection","documentId","sourceLocale","targetLocale","value","credentials","headers","method","result","json","success","message","error","console","handleCancel","length","localeOptions","map","label","toUpperCase","buttonStyle","disabled","onClick","className","slug","div","h3","p","strong","isClearable","onChange","option","options","placeholder","undefined","size"],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ .translate-modal {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ position: fixed;
6
+ inset: 0;
7
+ z-index: 100;
8
+ background: rgba(0, 0, 0, 0.5);
9
+ backdrop-filter: blur(4px);
10
+
11
+ &__wrapper {
12
+ background: var(--theme-bg);
13
+ border-radius: 8px;
14
+ padding: 24px;
15
+ max-width: 400px;
16
+ width: 90%;
17
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
18
+ }
19
+
20
+ &__content {
21
+ margin-bottom: 24px;
22
+
23
+ h3 {
24
+ margin: 0 0 8px 0;
25
+ font-size: 1.25rem;
26
+ font-weight: 600;
27
+ }
28
+
29
+ p {
30
+ margin: 0 0 16px 0;
31
+ color: var(--theme-elevation-600);
32
+ }
33
+ }
34
+
35
+ &__select {
36
+ margin-top: 8px;
37
+ }
38
+
39
+ &__controls {
40
+ display: flex;
41
+ justify-content: flex-end;
42
+ gap: 12px;
43
+ }
44
+ }
@@ -0,0 +1,2 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ export declare const translateHandler: PayloadHandler;
@@ -0,0 +1,114 @@
1
+ import { translateWithGemini } from '../services/gemini.js';
2
+ import { applyTranslations } from '../utils/applyTranslations.js';
3
+ import { extractTranslatableFields } from '../utils/extractTranslatableFields.js';
4
+ export const translateHandler = async (req)=>{
5
+ try {
6
+ const { payload, user } = req;
7
+ // Check authentication
8
+ if (!user) {
9
+ return Response.json({
10
+ error: 'Unauthorized',
11
+ success: false
12
+ }, {
13
+ status: 401
14
+ });
15
+ }
16
+ // Parse request body
17
+ const body = await req.json?.();
18
+ if (!body) {
19
+ return Response.json({
20
+ error: 'Invalid request body',
21
+ success: false
22
+ }, {
23
+ status: 400
24
+ });
25
+ }
26
+ const { collection, documentId, sourceLocale, targetLocale } = body;
27
+ // Validate request
28
+ if (!collection || !documentId || !sourceLocale || !targetLocale) {
29
+ return Response.json({
30
+ error: 'Missing required fields',
31
+ success: false
32
+ }, {
33
+ status: 400
34
+ });
35
+ }
36
+ // Get API key from config
37
+ const apiKey = payload.config.custom?.translateApiKey;
38
+ if (!apiKey) {
39
+ return Response.json({
40
+ error: 'Translation API key not configured',
41
+ success: false
42
+ }, {
43
+ status: 500
44
+ });
45
+ }
46
+ // Fetch document in source locale
47
+ const document = await payload.findByID({
48
+ id: documentId,
49
+ collection,
50
+ depth: 0,
51
+ locale: sourceLocale
52
+ });
53
+ if (!document) {
54
+ return Response.json({
55
+ error: 'Document not found',
56
+ success: false
57
+ }, {
58
+ status: 404
59
+ });
60
+ }
61
+ // Get collection config to find localized fields
62
+ const collectionConfig = payload.collections[collection]?.config;
63
+ if (!collectionConfig) {
64
+ return Response.json({
65
+ error: 'Collection not found',
66
+ success: false
67
+ }, {
68
+ status: 404
69
+ });
70
+ }
71
+ // Extract translatable fields
72
+ const translatableFields = extractTranslatableFields(document, collectionConfig.fields);
73
+ if (translatableFields.length === 0) {
74
+ return Response.json({
75
+ message: 'No translatable fields found',
76
+ success: true,
77
+ translatedFields: 0
78
+ });
79
+ }
80
+ // Prepare texts for translation
81
+ const texts = translatableFields.map((f)=>f.value);
82
+ // Translate with Gemini
83
+ const translations = await translateWithGemini({
84
+ apiKey,
85
+ sourceLocale,
86
+ targetLocale,
87
+ texts
88
+ });
89
+ // Apply translations to document
90
+ const updatedData = applyTranslations(document, translatableFields, translations);
91
+ // Update document in target locale
92
+ await payload.update({
93
+ id: documentId,
94
+ collection,
95
+ data: updatedData,
96
+ locale: targetLocale
97
+ });
98
+ return Response.json({
99
+ message: `Successfully translated ${translatableFields.length} field(s)`,
100
+ success: true,
101
+ translatedFields: translatableFields.length
102
+ });
103
+ } catch (error) {
104
+ console.error('Translation error:', error);
105
+ return Response.json({
106
+ error: error instanceof Error ? error.message : 'Translation failed',
107
+ success: false
108
+ }, {
109
+ status: 500
110
+ });
111
+ }
112
+ };
113
+
114
+ //# sourceMappingURL=translateHandler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/endpoints/translateHandler.ts"],"sourcesContent":["import type { PayloadHandler } from 'payload'\n\nimport type { TranslateRequestBody, TranslateResponse } from '../types.js'\n\nimport { translateWithGemini } from '../services/gemini.js'\nimport { applyTranslations } from '../utils/applyTranslations.js'\nimport { extractTranslatableFields } from '../utils/extractTranslatableFields.js'\n\nexport const translateHandler: PayloadHandler = async (req) => {\n try {\n const { payload, user } = req\n\n // Check authentication\n if (!user) {\n return Response.json({ error: 'Unauthorized', success: false } as TranslateResponse, {\n status: 401,\n })\n }\n\n // Parse request body\n const body = (await req.json?.()) as TranslateRequestBody | undefined\n if (!body) {\n return Response.json(\n { error: 'Invalid request body', success: false } as TranslateResponse,\n { status: 400 },\n )\n }\n const { collection, documentId, sourceLocale, targetLocale } = body\n\n // Validate request\n if (!collection || !documentId || !sourceLocale || !targetLocale) {\n return Response.json(\n { error: 'Missing required fields', success: false } as TranslateResponse,\n { status: 400 },\n )\n }\n\n // Get API key from config\n const apiKey = (payload.config.custom as Record<string, unknown>)?.translateApiKey as\n | string\n | undefined\n if (!apiKey) {\n return Response.json(\n { error: 'Translation API key not configured', success: false } as TranslateResponse,\n { status: 500 },\n )\n }\n\n // Fetch document in source locale\n const document = await payload.findByID({\n id: documentId,\n collection,\n depth: 0,\n locale: sourceLocale,\n })\n\n if (!document) {\n return Response.json({ error: 'Document not found', success: false } as TranslateResponse, {\n status: 404,\n })\n }\n\n // Get collection config to find localized fields\n const collectionConfig = payload.collections[collection]?.config\n if (!collectionConfig) {\n return Response.json({ error: 'Collection not found', success: false } as TranslateResponse, {\n status: 404,\n })\n }\n\n // Extract translatable fields\n const translatableFields = extractTranslatableFields(\n document as Record<string, unknown>,\n collectionConfig.fields,\n )\n\n if (translatableFields.length === 0) {\n return Response.json({\n message: 'No translatable fields found',\n success: true,\n translatedFields: 0,\n } as TranslateResponse)\n }\n\n // Prepare texts for translation\n const texts = translatableFields.map((f) => f.value)\n\n // Translate with Gemini\n const translations = await translateWithGemini({\n apiKey,\n sourceLocale,\n targetLocale,\n texts,\n })\n\n // Apply translations to document\n const updatedData = applyTranslations(\n document as Record<string, unknown>,\n translatableFields,\n translations,\n )\n\n // Update document in target locale\n await payload.update({\n id: documentId,\n collection,\n data: updatedData,\n locale: targetLocale,\n })\n\n return Response.json({\n message: `Successfully translated ${translatableFields.length} field(s)`,\n success: true,\n translatedFields: translatableFields.length,\n } as TranslateResponse)\n } catch (error) {\n console.error('Translation error:', error)\n return Response.json(\n {\n error: error instanceof Error ? error.message : 'Translation failed',\n success: false,\n } as TranslateResponse,\n { status: 500 },\n )\n }\n}\n"],"names":["translateWithGemini","applyTranslations","extractTranslatableFields","translateHandler","req","payload","user","Response","json","error","success","status","body","collection","documentId","sourceLocale","targetLocale","apiKey","config","custom","translateApiKey","document","findByID","id","depth","locale","collectionConfig","collections","translatableFields","fields","length","message","translatedFields","texts","map","f","value","translations","updatedData","update","data","console","Error"],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export { TranslateButton } from '../components/TranslateButton/index.js';
@@ -0,0 +1,3 @@
1
+ export { TranslateButton } from '../components/TranslateButton/index.js';
2
+
3
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["export { TranslateButton } from '../components/TranslateButton/index.js'\n"],"names":["TranslateButton"],"mappings":"AAAA,SAASA,eAAe,QAAQ,yCAAwC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ // RSC exports - currently not used by this plugin
2
+ export { };
3
+
4
+ //# sourceMappingURL=rsc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["// RSC exports - currently not used by this plugin\nexport {}\n"],"names":[],"mappings":"AAAA,kDAAkD;AAClD,WAAS"}
@@ -0,0 +1,4 @@
1
+ import type { Config } from 'payload';
2
+ import type { PayloadTranslateConfig } from './types.js';
3
+ export type { PayloadTranslateConfig };
4
+ export declare const payloadTranslate: (pluginOptions: PayloadTranslateConfig) => (config: Config) => Config;
package/dist/index.js ADDED
@@ -0,0 +1,46 @@
1
+ import { translateHandler } from './endpoints/translateHandler.js';
2
+ export const payloadTranslate = (pluginOptions)=>(config)=>{
3
+ if (pluginOptions.disabled) {
4
+ return config;
5
+ }
6
+ // Store API key in custom config for endpoint access
7
+ if (!config.custom) {
8
+ config.custom = {};
9
+ }
10
+ ;
11
+ config.custom.translateApiKey = pluginOptions.apiKey;
12
+ // Add TranslateButton to specified collections
13
+ if (!config.collections) {
14
+ config.collections = [];
15
+ }
16
+ for (const collectionSlug of pluginOptions.collections){
17
+ const collection = config.collections.find((c)=>c.slug === collectionSlug);
18
+ if (collection) {
19
+ if (!collection.admin) {
20
+ collection.admin = {};
21
+ }
22
+ if (!collection.admin.components) {
23
+ collection.admin.components = {};
24
+ }
25
+ if (!collection.admin.components.edit) {
26
+ collection.admin.components.edit = {};
27
+ }
28
+ if (!collection.admin.components.edit.beforeDocumentControls) {
29
+ collection.admin.components.edit.beforeDocumentControls = [];
30
+ }
31
+ collection.admin.components.edit.beforeDocumentControls.push('payload-translate/client#TranslateButton');
32
+ }
33
+ }
34
+ // Register translation endpoint
35
+ if (!config.endpoints) {
36
+ config.endpoints = [];
37
+ }
38
+ config.endpoints.push({
39
+ handler: translateHandler,
40
+ method: 'post',
41
+ path: '/translate'
42
+ });
43
+ return config;
44
+ };
45
+
46
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Config } from 'payload'\n\nimport type { PayloadTranslateConfig } from './types.js'\n\nimport { translateHandler } from './endpoints/translateHandler.js'\n\nexport type { PayloadTranslateConfig }\n\nexport const payloadTranslate =\n (pluginOptions: PayloadTranslateConfig) =>\n (config: Config): Config => {\n if (pluginOptions.disabled) {\n return config\n }\n\n // Store API key in custom config for endpoint access\n if (!config.custom) {\n config.custom = {}\n }\n ;(config.custom as Record<string, unknown>).translateApiKey = pluginOptions.apiKey\n\n // Add TranslateButton to specified collections\n if (!config.collections) {\n config.collections = []\n }\n\n for (const collectionSlug of pluginOptions.collections) {\n const collection = config.collections.find((c) => c.slug === collectionSlug)\n\n if (collection) {\n if (!collection.admin) {\n collection.admin = {}\n }\n if (!collection.admin.components) {\n collection.admin.components = {}\n }\n if (!collection.admin.components.edit) {\n collection.admin.components.edit = {}\n }\n if (!collection.admin.components.edit.beforeDocumentControls) {\n collection.admin.components.edit.beforeDocumentControls = []\n }\n\n collection.admin.components.edit.beforeDocumentControls.push(\n 'payload-translate/client#TranslateButton',\n )\n }\n }\n\n // Register translation endpoint\n if (!config.endpoints) {\n config.endpoints = []\n }\n\n config.endpoints.push({\n handler: translateHandler,\n method: 'post',\n path: '/translate',\n })\n\n return config\n }\n"],"names":["translateHandler","payloadTranslate","pluginOptions","config","disabled","custom","translateApiKey","apiKey","collections","collectionSlug","collection","find","c","slug","admin","components","edit","beforeDocumentControls","push","endpoints","handler","method","path"],"mappings":"AAIA,SAASA,gBAAgB,QAAQ,kCAAiC;AAIlE,OAAO,MAAMC,mBACX,CAACC,gBACD,CAACC;QACC,IAAID,cAAcE,QAAQ,EAAE;YAC1B,OAAOD;QACT;QAEA,qDAAqD;QACrD,IAAI,CAACA,OAAOE,MAAM,EAAE;YAClBF,OAAOE,MAAM,GAAG,CAAC;QACnB;;QACEF,OAAOE,MAAM,CAA6BC,eAAe,GAAGJ,cAAcK,MAAM;QAElF,+CAA+C;QAC/C,IAAI,CAACJ,OAAOK,WAAW,EAAE;YACvBL,OAAOK,WAAW,GAAG,EAAE;QACzB;QAEA,KAAK,MAAMC,kBAAkBP,cAAcM,WAAW,CAAE;YACtD,MAAME,aAAaP,OAAOK,WAAW,CAACG,IAAI,CAAC,CAACC,IAAMA,EAAEC,IAAI,KAAKJ;YAE7D,IAAIC,YAAY;gBACd,IAAI,CAACA,WAAWI,KAAK,EAAE;oBACrBJ,WAAWI,KAAK,GAAG,CAAC;gBACtB;gBACA,IAAI,CAACJ,WAAWI,KAAK,CAACC,UAAU,EAAE;oBAChCL,WAAWI,KAAK,CAACC,UAAU,GAAG,CAAC;gBACjC;gBACA,IAAI,CAACL,WAAWI,KAAK,CAACC,UAAU,CAACC,IAAI,EAAE;oBACrCN,WAAWI,KAAK,CAACC,UAAU,CAACC,IAAI,GAAG,CAAC;gBACtC;gBACA,IAAI,CAACN,WAAWI,KAAK,CAACC,UAAU,CAACC,IAAI,CAACC,sBAAsB,EAAE;oBAC5DP,WAAWI,KAAK,CAACC,UAAU,CAACC,IAAI,CAACC,sBAAsB,GAAG,EAAE;gBAC9D;gBAEAP,WAAWI,KAAK,CAACC,UAAU,CAACC,IAAI,CAACC,sBAAsB,CAACC,IAAI,CAC1D;YAEJ;QACF;QAEA,gCAAgC;QAChC,IAAI,CAACf,OAAOgB,SAAS,EAAE;YACrBhB,OAAOgB,SAAS,GAAG,EAAE;QACvB;QAEAhB,OAAOgB,SAAS,CAACD,IAAI,CAAC;YACpBE,SAASpB;YACTqB,QAAQ;YACRC,MAAM;QACR;QAEA,OAAOnB;IACT,EAAC"}
@@ -0,0 +1,6 @@
1
+ import type { GeminiTranslationRequest } from '../types.js';
2
+ type TranslateWithGeminiArgs = {
3
+ apiKey: string;
4
+ } & GeminiTranslationRequest;
5
+ export declare function translateWithGemini({ apiKey, sourceLocale, targetLocale, texts, }: TranslateWithGeminiArgs): Promise<string[]>;
6
+ export {};
@@ -0,0 +1,84 @@
1
+ const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
2
+ export async function translateWithGemini({ apiKey, sourceLocale, targetLocale, texts }) {
3
+ if (texts.length === 0) {
4
+ return [];
5
+ }
6
+ const prompt = buildTranslationPrompt(texts, sourceLocale, targetLocale);
7
+ const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, {
8
+ body: JSON.stringify({
9
+ contents: [
10
+ {
11
+ parts: [
12
+ {
13
+ text: prompt
14
+ }
15
+ ]
16
+ }
17
+ ],
18
+ generationConfig: {
19
+ maxOutputTokens: 8192,
20
+ temperature: 0.1,
21
+ thinkingConfig: {
22
+ thinkingBudget: 0
23
+ },
24
+ topP: 0.95
25
+ }
26
+ }),
27
+ headers: {
28
+ 'Content-Type': 'application/json'
29
+ },
30
+ method: 'POST'
31
+ });
32
+ if (!response.ok) {
33
+ const error = await response.text();
34
+ throw new Error(`Gemini API error: ${error}`);
35
+ }
36
+ const result = await response.json();
37
+ // Handle response - find the text part
38
+ const parts = result.candidates?.[0]?.content?.parts || [];
39
+ const textPart = parts.find((p)=>p.text !== undefined);
40
+ const generatedText = textPart?.text;
41
+ if (!generatedText) {
42
+ throw new Error(`No translation returned from Gemini. Response: ${JSON.stringify(result)}`);
43
+ }
44
+ return parseTranslationResponse(generatedText, texts.length);
45
+ }
46
+ function buildTranslationPrompt(texts, sourceLocale, targetLocale) {
47
+ const textsJson = JSON.stringify(texts);
48
+ return `You are a professional translator. Translate the following texts from ${sourceLocale} to ${targetLocale}.
49
+
50
+ IMPORTANT RULES:
51
+ 1. Maintain the exact same formatting, including HTML tags, markdown, and special characters
52
+ 2. Do not translate proper nouns, brand names, or code/technical terms unless they have standard translations
53
+ 3. Preserve any placeholder variables like {{name}} or {0}
54
+ 4. Return ONLY a valid JSON array with the translations in the same order
55
+ 5. Each translation should correspond to the input at the same index
56
+
57
+ Input texts (JSON array):
58
+ ${textsJson}
59
+
60
+ Return ONLY the JSON array of translations, nothing else. Example format:
61
+ ["translated text 1", "translated text 2"]`;
62
+ }
63
+ function parseTranslationResponse(response, expectedCount) {
64
+ let jsonStr = response.trim();
65
+ // Handle code blocks if present
66
+ if (jsonStr.startsWith('```')) {
67
+ jsonStr = jsonStr.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
68
+ }
69
+ try {
70
+ const translations = JSON.parse(jsonStr);
71
+ if (!Array.isArray(translations)) {
72
+ throw new Error('Response is not an array');
73
+ }
74
+ if (translations.length !== expectedCount) {
75
+ console.warn(`Expected ${expectedCount} translations, got ${translations.length}`);
76
+ }
77
+ return translations;
78
+ } catch (_error) {
79
+ console.error('Failed to parse translation response:', response);
80
+ throw new Error('Failed to parse translation response from Gemini');
81
+ }
82
+ }
83
+
84
+ //# sourceMappingURL=gemini.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/services/gemini.ts"],"sourcesContent":["import type { GeminiTranslationRequest } from '../types.js'\n\ntype TranslateWithGeminiArgs = {\n apiKey: string\n} & GeminiTranslationRequest\n\nconst GEMINI_API_URL =\n 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent'\n\nexport async function translateWithGemini({\n apiKey,\n sourceLocale,\n targetLocale,\n texts,\n}: TranslateWithGeminiArgs): Promise<string[]> {\n if (texts.length === 0) {\n return []\n }\n\n const prompt = buildTranslationPrompt(texts, sourceLocale, targetLocale)\n\n const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, {\n body: JSON.stringify({\n contents: [\n {\n parts: [{ text: prompt }],\n },\n ],\n generationConfig: {\n maxOutputTokens: 8192,\n temperature: 0.1,\n thinkingConfig: {\n thinkingBudget: 0,\n },\n topP: 0.95,\n },\n }),\n headers: {\n 'Content-Type': 'application/json',\n },\n method: 'POST',\n })\n\n if (!response.ok) {\n const error = await response.text()\n throw new Error(`Gemini API error: ${error}`)\n }\n\n const result = await response.json()\n\n // Handle response - find the text part\n const parts = result.candidates?.[0]?.content?.parts || []\n const textPart = parts.find((p: { text?: string }) => p.text !== undefined)\n const generatedText = textPart?.text\n\n if (!generatedText) {\n throw new Error(`No translation returned from Gemini. Response: ${JSON.stringify(result)}`)\n }\n\n return parseTranslationResponse(generatedText, texts.length)\n}\n\nfunction buildTranslationPrompt(\n texts: string[],\n sourceLocale: string,\n targetLocale: string,\n): string {\n const textsJson = JSON.stringify(texts)\n\n return `You are a professional translator. Translate the following texts from ${sourceLocale} to ${targetLocale}.\n\nIMPORTANT RULES:\n1. Maintain the exact same formatting, including HTML tags, markdown, and special characters\n2. Do not translate proper nouns, brand names, or code/technical terms unless they have standard translations\n3. Preserve any placeholder variables like {{name}} or {0}\n4. Return ONLY a valid JSON array with the translations in the same order\n5. Each translation should correspond to the input at the same index\n\nInput texts (JSON array):\n${textsJson}\n\nReturn ONLY the JSON array of translations, nothing else. Example format:\n[\"translated text 1\", \"translated text 2\"]`\n}\n\nfunction parseTranslationResponse(response: string, expectedCount: number): string[] {\n let jsonStr = response.trim()\n\n // Handle code blocks if present\n if (jsonStr.startsWith('```')) {\n jsonStr = jsonStr.replace(/```json?\\n?/g, '').replace(/```/g, '').trim()\n }\n\n try {\n const translations = JSON.parse(jsonStr)\n\n if (!Array.isArray(translations)) {\n throw new Error('Response is not an array')\n }\n\n if (translations.length !== expectedCount) {\n console.warn(`Expected ${expectedCount} translations, got ${translations.length}`)\n }\n\n return translations\n } catch (_error) {\n console.error('Failed to parse translation response:', response)\n throw new Error('Failed to parse translation response from Gemini')\n }\n}\n"],"names":["GEMINI_API_URL","translateWithGemini","apiKey","sourceLocale","targetLocale","texts","length","prompt","buildTranslationPrompt","response","fetch","body","JSON","stringify","contents","parts","text","generationConfig","maxOutputTokens","temperature","thinkingConfig","thinkingBudget","topP","headers","method","ok","error","Error","result","json","candidates","content","textPart","find","p","undefined","generatedText","parseTranslationResponse","textsJson","expectedCount","jsonStr","trim","startsWith","replace","translations","parse","Array","isArray","console","warn","_error"],"mappings":"AAMA,MAAMA,iBACJ;AAEF,OAAO,eAAeC,oBAAoB,EACxCC,MAAM,EACNC,YAAY,EACZC,YAAY,EACZC,KAAK,EACmB;IACxB,IAAIA,MAAMC,MAAM,KAAK,GAAG;QACtB,OAAO,EAAE;IACX;IAEA,MAAMC,SAASC,uBAAuBH,OAAOF,cAAcC;IAE3D,MAAMK,WAAW,MAAMC,MAAM,GAAGV,eAAe,KAAK,EAAEE,QAAQ,EAAE;QAC9DS,MAAMC,KAAKC,SAAS,CAAC;YACnBC,UAAU;gBACR;oBACEC,OAAO;wBAAC;4BAAEC,MAAMT;wBAAO;qBAAE;gBAC3B;aACD;YACDU,kBAAkB;gBAChBC,iBAAiB;gBACjBC,aAAa;gBACbC,gBAAgB;oBACdC,gBAAgB;gBAClB;gBACAC,MAAM;YACR;QACF;QACAC,SAAS;YACP,gBAAgB;QAClB;QACAC,QAAQ;IACV;IAEA,IAAI,CAACf,SAASgB,EAAE,EAAE;QAChB,MAAMC,QAAQ,MAAMjB,SAASO,IAAI;QACjC,MAAM,IAAIW,MAAM,CAAC,kBAAkB,EAAED,OAAO;IAC9C;IAEA,MAAME,SAAS,MAAMnB,SAASoB,IAAI;IAElC,uCAAuC;IACvC,MAAMd,QAAQa,OAAOE,UAAU,EAAE,CAAC,EAAE,EAAEC,SAAShB,SAAS,EAAE;IAC1D,MAAMiB,WAAWjB,MAAMkB,IAAI,CAAC,CAACC,IAAyBA,EAAElB,IAAI,KAAKmB;IACjE,MAAMC,gBAAgBJ,UAAUhB;IAEhC,IAAI,CAACoB,eAAe;QAClB,MAAM,IAAIT,MAAM,CAAC,+CAA+C,EAAEf,KAAKC,SAAS,CAACe,SAAS;IAC5F;IAEA,OAAOS,yBAAyBD,eAAe/B,MAAMC,MAAM;AAC7D;AAEA,SAASE,uBACPH,KAAe,EACfF,YAAoB,EACpBC,YAAoB;IAEpB,MAAMkC,YAAY1B,KAAKC,SAAS,CAACR;IAEjC,OAAO,CAAC,sEAAsE,EAAEF,aAAa,IAAI,EAAEC,aAAa;;;;;;;;;;AAUlH,EAAEkC,UAAU;;;0CAG8B,CAAC;AAC3C;AAEA,SAASD,yBAAyB5B,QAAgB,EAAE8B,aAAqB;IACvE,IAAIC,UAAU/B,SAASgC,IAAI;IAE3B,gCAAgC;IAChC,IAAID,QAAQE,UAAU,CAAC,QAAQ;QAC7BF,UAAUA,QAAQG,OAAO,CAAC,gBAAgB,IAAIA,OAAO,CAAC,QAAQ,IAAIF,IAAI;IACxE;IAEA,IAAI;QACF,MAAMG,eAAehC,KAAKiC,KAAK,CAACL;QAEhC,IAAI,CAACM,MAAMC,OAAO,CAACH,eAAe;YAChC,MAAM,IAAIjB,MAAM;QAClB;QAEA,IAAIiB,aAAatC,MAAM,KAAKiC,eAAe;YACzCS,QAAQC,IAAI,CAAC,CAAC,SAAS,EAAEV,cAAc,mBAAmB,EAAEK,aAAatC,MAAM,EAAE;QACnF;QAEA,OAAOsC;IACT,EAAE,OAAOM,QAAQ;QACfF,QAAQtB,KAAK,CAAC,yCAAyCjB;QACvD,MAAM,IAAIkB,MAAM;IAClB;AACF"}
@@ -0,0 +1,42 @@
1
+ import type { CollectionSlug } from 'payload';
2
+ export type PayloadTranslateConfig = {
3
+ /**
4
+ * Google Gemini API key for translations
5
+ */
6
+ apiKey: string;
7
+ /**
8
+ * List of collection slugs to enable translation for
9
+ */
10
+ collections: CollectionSlug[];
11
+ /**
12
+ * Whether the plugin is disabled
13
+ */
14
+ disabled?: boolean;
15
+ };
16
+ export type TranslateRequestBody = {
17
+ collection: string;
18
+ documentId: number | string;
19
+ sourceLocale: string;
20
+ targetLocale: string;
21
+ };
22
+ export type TranslateResponse = {
23
+ error?: string;
24
+ message?: string;
25
+ success: boolean;
26
+ translatedFields?: number;
27
+ };
28
+ export type TranslatableField = {
29
+ /**
30
+ * For richText fields, the path within the Lexical structure to the text node
31
+ * e.g., "root.children.0.children.1"
32
+ */
33
+ lexicalPath?: string;
34
+ path: string;
35
+ type: 'richText' | 'text' | 'textarea';
36
+ value: string;
37
+ };
38
+ export type GeminiTranslationRequest = {
39
+ sourceLocale: string;
40
+ targetLocale: string;
41
+ texts: string[];
42
+ };
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ export { };
2
+
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nexport type PayloadTranslateConfig = {\n /**\n * Google Gemini API key for translations\n */\n apiKey: string\n /**\n * List of collection slugs to enable translation for\n */\n collections: CollectionSlug[]\n /**\n * Whether the plugin is disabled\n */\n disabled?: boolean\n}\n\nexport type TranslateRequestBody = {\n collection: string\n documentId: number | string\n sourceLocale: string\n targetLocale: string\n}\n\nexport type TranslateResponse = {\n error?: string\n message?: string\n success: boolean\n translatedFields?: number\n}\n\nexport type TranslatableField = {\n /**\n * For richText fields, the path within the Lexical structure to the text node\n * e.g., \"root.children.0.children.1\"\n */\n lexicalPath?: string\n path: string\n type: 'richText' | 'text' | 'textarea'\n value: string\n}\n\nexport type GeminiTranslationRequest = {\n sourceLocale: string\n targetLocale: string\n texts: string[]\n}\n"],"names":[],"mappings":"AA0CA,WAIC"}
@@ -0,0 +1,2 @@
1
+ import type { TranslatableField } from '../types.js';
2
+ export declare function applyTranslations(originalData: Record<string, unknown>, fields: TranslatableField[], translations: string[]): Record<string, unknown>;
@@ -0,0 +1,80 @@
1
+ export function applyTranslations(originalData, fields, translations) {
2
+ // Deep clone the original data
3
+ const result = JSON.parse(JSON.stringify(originalData));
4
+ fields.forEach((field, index)=>{
5
+ const translation = translations[index];
6
+ if (translation === undefined) {
7
+ return;
8
+ }
9
+ if (field.type === 'richText' && field.lexicalPath) {
10
+ // For rich text, apply translation to the specific text node in the Lexical structure
11
+ applyLexicalTranslation(result, field.path, field.lexicalPath, translation);
12
+ } else {
13
+ // For text/textarea, directly set the translation
14
+ setNestedValue(result, field.path, translation);
15
+ }
16
+ });
17
+ return result;
18
+ }
19
+ /**
20
+ * Apply a translation to a specific text node within a Lexical rich text field.
21
+ * @param data - The document data object
22
+ * @param fieldPath - Path to the rich text field (e.g., "content")
23
+ * @param lexicalPath - Path within the Lexical structure (e.g., "root.children.0.children.1")
24
+ * @param translation - The translated text
25
+ */ function applyLexicalTranslation(data, fieldPath, lexicalPath, translation) {
26
+ // Get the Lexical editor state object
27
+ const lexicalState = getNestedValue(data, fieldPath);
28
+ if (!lexicalState || typeof lexicalState !== 'object') {
29
+ return;
30
+ }
31
+ // Navigate to the text node and update its text property
32
+ const pathParts = lexicalPath.split('.');
33
+ let current = lexicalState;
34
+ for(let i = 0; i < pathParts.length; i++){
35
+ const key = pathParts[i];
36
+ if (current && typeof current === 'object' && key in current) {
37
+ if (i === pathParts.length - 1) {
38
+ // We're at the text node, update the text property
39
+ ;
40
+ current[key] = current[key];
41
+ }
42
+ current = current[key];
43
+ } else {
44
+ return; // Path not found
45
+ }
46
+ }
47
+ // current should now be the text node
48
+ if (current && typeof current === 'object' && 'text' in current) {
49
+ ;
50
+ current.text = translation;
51
+ }
52
+ }
53
+ function getNestedValue(obj, path) {
54
+ const keys = path.split('.');
55
+ let current = obj;
56
+ for (const key of keys){
57
+ if (current && typeof current === 'object' && key in current) {
58
+ current = current[key];
59
+ } else {
60
+ return undefined;
61
+ }
62
+ }
63
+ return current;
64
+ }
65
+ function setNestedValue(obj, path, value) {
66
+ const keys = path.split('.');
67
+ let current = obj;
68
+ for(let i = 0; i < keys.length - 1; i++){
69
+ const key = keys[i];
70
+ const nextKey = keys[i + 1];
71
+ if (!(key in current)) {
72
+ // Create array or object based on next key
73
+ current[key] = isNaN(Number(nextKey)) ? {} : [];
74
+ }
75
+ current = current[key];
76
+ }
77
+ current[keys[keys.length - 1]] = value;
78
+ }
79
+
80
+ //# sourceMappingURL=applyTranslations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/applyTranslations.ts"],"sourcesContent":["import type { TranslatableField } from '../types.js'\n\nexport function applyTranslations(\n originalData: Record<string, unknown>,\n fields: TranslatableField[],\n translations: string[],\n): Record<string, unknown> {\n // Deep clone the original data\n const result = JSON.parse(JSON.stringify(originalData)) as Record<string, unknown>\n\n fields.forEach((field, index) => {\n const translation = translations[index]\n if (translation === undefined) {\n return\n }\n\n if (field.type === 'richText' && field.lexicalPath) {\n // For rich text, apply translation to the specific text node in the Lexical structure\n applyLexicalTranslation(result, field.path, field.lexicalPath, translation)\n } else {\n // For text/textarea, directly set the translation\n setNestedValue(result, field.path, translation)\n }\n })\n\n return result\n}\n\n/**\n * Apply a translation to a specific text node within a Lexical rich text field.\n * @param data - The document data object\n * @param fieldPath - Path to the rich text field (e.g., \"content\")\n * @param lexicalPath - Path within the Lexical structure (e.g., \"root.children.0.children.1\")\n * @param translation - The translated text\n */\nfunction applyLexicalTranslation(\n data: Record<string, unknown>,\n fieldPath: string,\n lexicalPath: string,\n translation: string,\n): void {\n // Get the Lexical editor state object\n const lexicalState = getNestedValue(data, fieldPath)\n if (!lexicalState || typeof lexicalState !== 'object') {\n return\n }\n\n // Navigate to the text node and update its text property\n const pathParts = lexicalPath.split('.')\n let current: unknown = lexicalState\n\n for (let i = 0; i < pathParts.length; i++) {\n const key = pathParts[i]\n if (current && typeof current === 'object' && key in current) {\n if (i === pathParts.length - 1) {\n // We're at the text node, update the text property\n ;(current as Record<string, unknown>)[key] = current[key as keyof typeof current]\n }\n current = (current as Record<string, unknown>)[key]\n } else {\n return // Path not found\n }\n }\n\n // current should now be the text node\n if (current && typeof current === 'object' && 'text' in current) {\n ;(current as Record<string, unknown>).text = translation\n }\n}\n\nfunction getNestedValue(obj: Record<string, unknown>, path: string): unknown {\n const keys = path.split('.')\n let current: unknown = obj\n\n for (const key of keys) {\n if (current && typeof current === 'object' && key in current) {\n current = (current as Record<string, unknown>)[key]\n } else {\n return undefined\n }\n }\n\n return current\n}\n\nfunction setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {\n const keys = path.split('.')\n let current: Record<string, unknown> = obj\n\n for (let i = 0; i < keys.length - 1; i++) {\n const key = keys[i]\n const nextKey = keys[i + 1]\n\n if (!(key in current)) {\n // Create array or object based on next key\n current[key] = isNaN(Number(nextKey)) ? {} : []\n }\n\n current = current[key] as Record<string, unknown>\n }\n\n current[keys[keys.length - 1]] = value\n}\n"],"names":["applyTranslations","originalData","fields","translations","result","JSON","parse","stringify","forEach","field","index","translation","undefined","type","lexicalPath","applyLexicalTranslation","path","setNestedValue","data","fieldPath","lexicalState","getNestedValue","pathParts","split","current","i","length","key","text","obj","keys","value","nextKey","isNaN","Number"],"mappings":"AAEA,OAAO,SAASA,kBACdC,YAAqC,EACrCC,MAA2B,EAC3BC,YAAsB;IAEtB,+BAA+B;IAC/B,MAAMC,SAASC,KAAKC,KAAK,CAACD,KAAKE,SAAS,CAACN;IAEzCC,OAAOM,OAAO,CAAC,CAACC,OAAOC;QACrB,MAAMC,cAAcR,YAAY,CAACO,MAAM;QACvC,IAAIC,gBAAgBC,WAAW;YAC7B;QACF;QAEA,IAAIH,MAAMI,IAAI,KAAK,cAAcJ,MAAMK,WAAW,EAAE;YAClD,sFAAsF;YACtFC,wBAAwBX,QAAQK,MAAMO,IAAI,EAAEP,MAAMK,WAAW,EAAEH;QACjE,OAAO;YACL,kDAAkD;YAClDM,eAAeb,QAAQK,MAAMO,IAAI,EAAEL;QACrC;IACF;IAEA,OAAOP;AACT;AAEA;;;;;;CAMC,GACD,SAASW,wBACPG,IAA6B,EAC7BC,SAAiB,EACjBL,WAAmB,EACnBH,WAAmB;IAEnB,sCAAsC;IACtC,MAAMS,eAAeC,eAAeH,MAAMC;IAC1C,IAAI,CAACC,gBAAgB,OAAOA,iBAAiB,UAAU;QACrD;IACF;IAEA,yDAAyD;IACzD,MAAME,YAAYR,YAAYS,KAAK,CAAC;IACpC,IAAIC,UAAmBJ;IAEvB,IAAK,IAAIK,IAAI,GAAGA,IAAIH,UAAUI,MAAM,EAAED,IAAK;QACzC,MAAME,MAAML,SAAS,CAACG,EAAE;QACxB,IAAID,WAAW,OAAOA,YAAY,YAAYG,OAAOH,SAAS;YAC5D,IAAIC,MAAMH,UAAUI,MAAM,GAAG,GAAG;gBAC9B,mDAAmD;;gBACjDF,OAAmC,CAACG,IAAI,GAAGH,OAAO,CAACG,IAA4B;YACnF;YACAH,UAAU,AAACA,OAAmC,CAACG,IAAI;QACrD,OAAO;YACL,QAAO,iBAAiB;QAC1B;IACF;IAEA,sCAAsC;IACtC,IAAIH,WAAW,OAAOA,YAAY,YAAY,UAAUA,SAAS;;QAC7DA,QAAoCI,IAAI,GAAGjB;IAC/C;AACF;AAEA,SAASU,eAAeQ,GAA4B,EAAEb,IAAY;IAChE,MAAMc,OAAOd,KAAKO,KAAK,CAAC;IACxB,IAAIC,UAAmBK;IAEvB,KAAK,MAAMF,OAAOG,KAAM;QACtB,IAAIN,WAAW,OAAOA,YAAY,YAAYG,OAAOH,SAAS;YAC5DA,UAAU,AAACA,OAAmC,CAACG,IAAI;QACrD,OAAO;YACL,OAAOf;QACT;IACF;IAEA,OAAOY;AACT;AAEA,SAASP,eAAeY,GAA4B,EAAEb,IAAY,EAAEe,KAAc;IAChF,MAAMD,OAAOd,KAAKO,KAAK,CAAC;IACxB,IAAIC,UAAmCK;IAEvC,IAAK,IAAIJ,IAAI,GAAGA,IAAIK,KAAKJ,MAAM,GAAG,GAAGD,IAAK;QACxC,MAAME,MAAMG,IAAI,CAACL,EAAE;QACnB,MAAMO,UAAUF,IAAI,CAACL,IAAI,EAAE;QAE3B,IAAI,CAAEE,CAAAA,OAAOH,OAAM,GAAI;YACrB,2CAA2C;YAC3CA,OAAO,CAACG,IAAI,GAAGM,MAAMC,OAAOF,YAAY,CAAC,IAAI,EAAE;QACjD;QAEAR,UAAUA,OAAO,CAACG,IAAI;IACxB;IAEAH,OAAO,CAACM,IAAI,CAACA,KAAKJ,MAAM,GAAG,EAAE,CAAC,GAAGK;AACnC"}
@@ -0,0 +1,3 @@
1
+ import type { Field } from 'payload';
2
+ import type { TranslatableField } from '../types.js';
3
+ export declare function extractTranslatableFields(data: Record<string, unknown>, fields: Field[], basePath?: string): TranslatableField[];
@@ -0,0 +1,149 @@
1
+ export function extractTranslatableFields(data, fields, basePath = '') {
2
+ const translatableFields = [];
3
+ for (const field of fields){
4
+ // Skip fields without names (like row, collapsible without name)
5
+ if (!('name' in field)) {
6
+ // Handle layout fields that contain nested fields
7
+ if ('fields' in field) {
8
+ const nestedFields = extractTranslatableFields(data, field.fields, basePath);
9
+ translatableFields.push(...nestedFields);
10
+ }
11
+ // Handle tabs
12
+ if ('tabs' in field) {
13
+ for (const tab of field.tabs){
14
+ if ('fields' in tab) {
15
+ const tabFields = extractTranslatableFields(data, tab.fields, basePath);
16
+ translatableFields.push(...tabFields);
17
+ }
18
+ }
19
+ }
20
+ continue;
21
+ }
22
+ const fieldPath = basePath ? `${basePath}.${field.name}` : field.name;
23
+ const value = data[field.name];
24
+ // Skip if no value
25
+ if (value === undefined || value === null) {
26
+ continue;
27
+ }
28
+ // Only process localized fields (UIField doesn't have localized property)
29
+ const isLocalized = 'localized' in field && field.localized;
30
+ if (!isLocalized) {
31
+ // But still recurse into nested structures for localized children
32
+ if (field.type === 'group' && 'fields' in field && typeof value === 'object') {
33
+ const groupFields = extractTranslatableFields(value, field.fields, fieldPath);
34
+ translatableFields.push(...groupFields);
35
+ }
36
+ if (field.type === 'array' && 'fields' in field && Array.isArray(value)) {
37
+ value.forEach((item, index)=>{
38
+ if (typeof item === 'object' && item !== null) {
39
+ const itemFields = extractTranslatableFields(item, field.fields, `${fieldPath}.${index}`);
40
+ translatableFields.push(...itemFields);
41
+ }
42
+ });
43
+ }
44
+ if (field.type === 'blocks' && 'blocks' in field && Array.isArray(value)) {
45
+ value.forEach((block, index)=>{
46
+ if (typeof block === 'object' && block !== null && 'blockType' in block) {
47
+ const blockConfig = field.blocks.find((b)=>b.slug === block.blockType);
48
+ if (blockConfig) {
49
+ const blockFields = extractTranslatableFields(block, blockConfig.fields, `${fieldPath}.${index}`);
50
+ translatableFields.push(...blockFields);
51
+ }
52
+ }
53
+ });
54
+ }
55
+ continue;
56
+ }
57
+ // Process localized fields
58
+ switch(field.type){
59
+ case 'array':
60
+ if ('fields' in field && Array.isArray(value)) {
61
+ value.forEach((item, index)=>{
62
+ if (typeof item === 'object' && item !== null) {
63
+ const itemFields = extractTranslatableFields(item, field.fields, `${fieldPath}.${index}`);
64
+ translatableFields.push(...itemFields);
65
+ }
66
+ });
67
+ }
68
+ break;
69
+ case 'blocks':
70
+ if ('blocks' in field && Array.isArray(value)) {
71
+ value.forEach((block, index)=>{
72
+ if (typeof block === 'object' && block !== null && 'blockType' in block) {
73
+ const blockConfig = field.blocks.find((b)=>b.slug === block.blockType);
74
+ if (blockConfig) {
75
+ const blockFields = extractTranslatableFields(block, blockConfig.fields, `${fieldPath}.${index}`);
76
+ translatableFields.push(...blockFields);
77
+ }
78
+ }
79
+ });
80
+ }
81
+ break;
82
+ case 'group':
83
+ if ('fields' in field && typeof value === 'object') {
84
+ const groupFields = extractTranslatableFields(value, field.fields, fieldPath);
85
+ translatableFields.push(...groupFields);
86
+ }
87
+ break;
88
+ case 'richText':
89
+ if (value && typeof value === 'object') {
90
+ // Extract each text node individually with its path in the Lexical tree
91
+ const textNodes = extractLexicalTextNodes(value);
92
+ for (const textNode of textNodes){
93
+ if (textNode.text.trim()) {
94
+ translatableFields.push({
95
+ type: 'richText',
96
+ lexicalPath: textNode.path,
97
+ path: fieldPath,
98
+ value: textNode.text
99
+ });
100
+ }
101
+ }
102
+ }
103
+ break;
104
+ case 'text':
105
+ // falls through
106
+ case 'textarea':
107
+ if (typeof value === 'string' && value.trim()) {
108
+ translatableFields.push({
109
+ type: field.type,
110
+ path: fieldPath,
111
+ value
112
+ });
113
+ }
114
+ break;
115
+ }
116
+ }
117
+ return translatableFields;
118
+ }
119
+ /**
120
+ * Extracts all text nodes from a Lexical editor state with their paths.
121
+ * This allows us to translate each text node individually and apply
122
+ * translations back to the exact same location.
123
+ */ function extractLexicalTextNodes(editorState) {
124
+ const textNodes = [];
125
+ if (!editorState || typeof editorState !== 'object') {
126
+ return textNodes;
127
+ }
128
+ const state = editorState;
129
+ if (!state.root) {
130
+ return textNodes;
131
+ }
132
+ function traverse(node, currentPath) {
133
+ if (node.type === 'text' && typeof node.text === 'string') {
134
+ textNodes.push({
135
+ path: currentPath,
136
+ text: node.text
137
+ });
138
+ }
139
+ if (node.children && Array.isArray(node.children)) {
140
+ node.children.forEach((child, index)=>{
141
+ traverse(child, `${currentPath}.children.${index}`);
142
+ });
143
+ }
144
+ }
145
+ traverse(state.root, 'root');
146
+ return textNodes;
147
+ }
148
+
149
+ //# sourceMappingURL=extractTranslatableFields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/extractTranslatableFields.ts"],"sourcesContent":["import type { Field } from 'payload'\n\nimport type { TranslatableField } from '../types.js'\n\nexport function extractTranslatableFields(\n data: Record<string, unknown>,\n fields: Field[],\n basePath: string = '',\n): TranslatableField[] {\n const translatableFields: TranslatableField[] = []\n\n for (const field of fields) {\n // Skip fields without names (like row, collapsible without name)\n if (!('name' in field)) {\n // Handle layout fields that contain nested fields\n if ('fields' in field) {\n const nestedFields = extractTranslatableFields(data, field.fields, basePath)\n translatableFields.push(...nestedFields)\n }\n // Handle tabs\n if ('tabs' in field) {\n for (const tab of field.tabs) {\n if ('fields' in tab) {\n const tabFields = extractTranslatableFields(data, tab.fields, basePath)\n translatableFields.push(...tabFields)\n }\n }\n }\n continue\n }\n\n const fieldPath = basePath ? `${basePath}.${field.name}` : field.name\n const value = data[field.name]\n\n // Skip if no value\n if (value === undefined || value === null) {continue}\n\n // Only process localized fields (UIField doesn't have localized property)\n const isLocalized = 'localized' in field && field.localized\n if (!isLocalized) {\n // But still recurse into nested structures for localized children\n if (field.type === 'group' && 'fields' in field && typeof value === 'object') {\n const groupFields = extractTranslatableFields(\n value as Record<string, unknown>,\n field.fields,\n fieldPath,\n )\n translatableFields.push(...groupFields)\n }\n if (field.type === 'array' && 'fields' in field && Array.isArray(value)) {\n value.forEach((item, index) => {\n if (typeof item === 'object' && item !== null) {\n const itemFields = extractTranslatableFields(\n item as Record<string, unknown>,\n field.fields,\n `${fieldPath}.${index}`,\n )\n translatableFields.push(...itemFields)\n }\n })\n }\n if (field.type === 'blocks' && 'blocks' in field && Array.isArray(value)) {\n value.forEach((block, index) => {\n if (typeof block === 'object' && block !== null && 'blockType' in block) {\n const blockConfig = field.blocks.find((b) => b.slug === block.blockType)\n if (blockConfig) {\n const blockFields = extractTranslatableFields(\n block as Record<string, unknown>,\n blockConfig.fields,\n `${fieldPath}.${index}`,\n )\n translatableFields.push(...blockFields)\n }\n }\n })\n }\n continue\n }\n\n // Process localized fields\n switch (field.type) {\n case 'array':\n if ('fields' in field && Array.isArray(value)) {\n value.forEach((item, index) => {\n if (typeof item === 'object' && item !== null) {\n const itemFields = extractTranslatableFields(\n item as Record<string, unknown>,\n field.fields,\n `${fieldPath}.${index}`,\n )\n translatableFields.push(...itemFields)\n }\n })\n }\n break\n case 'blocks':\n if ('blocks' in field && Array.isArray(value)) {\n value.forEach((block, index) => {\n if (typeof block === 'object' && block !== null && 'blockType' in block) {\n const blockConfig = field.blocks.find((b) => b.slug === block.blockType)\n if (blockConfig) {\n const blockFields = extractTranslatableFields(\n block as Record<string, unknown>,\n blockConfig.fields,\n `${fieldPath}.${index}`,\n )\n translatableFields.push(...blockFields)\n }\n }\n })\n }\n break\n\n case 'group':\n if ('fields' in field && typeof value === 'object') {\n const groupFields = extractTranslatableFields(\n value as Record<string, unknown>,\n field.fields,\n fieldPath,\n )\n translatableFields.push(...groupFields)\n }\n break\n\n case 'richText':\n if (value && typeof value === 'object') {\n // Extract each text node individually with its path in the Lexical tree\n const textNodes = extractLexicalTextNodes(value)\n for (const textNode of textNodes) {\n if (textNode.text.trim()) {\n translatableFields.push({\n type: 'richText',\n lexicalPath: textNode.path,\n path: fieldPath,\n value: textNode.text,\n })\n }\n }\n }\n break\n\n case 'text':\n // falls through\n case 'textarea':\n if (typeof value === 'string' && value.trim()) {\n translatableFields.push({\n type: field.type,\n path: fieldPath,\n value,\n })\n }\n break\n }\n }\n\n return translatableFields\n}\n\ninterface LexicalNode {\n [key: string]: unknown\n children?: LexicalNode[]\n text?: string\n type?: string\n}\n\ninterface LexicalTextNode {\n path: string\n text: string\n}\n\n/**\n * Extracts all text nodes from a Lexical editor state with their paths.\n * This allows us to translate each text node individually and apply\n * translations back to the exact same location.\n */\nfunction extractLexicalTextNodes(editorState: unknown): LexicalTextNode[] {\n const textNodes: LexicalTextNode[] = []\n\n if (!editorState || typeof editorState !== 'object') {\n return textNodes\n }\n\n const state = editorState as { root?: LexicalNode }\n if (!state.root) {\n return textNodes\n }\n\n function traverse(node: LexicalNode, currentPath: string): void {\n if (node.type === 'text' && typeof node.text === 'string') {\n textNodes.push({\n path: currentPath,\n text: node.text,\n })\n }\n if (node.children && Array.isArray(node.children)) {\n node.children.forEach((child, index) => {\n traverse(child, `${currentPath}.children.${index}`)\n })\n }\n }\n\n traverse(state.root, 'root')\n return textNodes\n}\n"],"names":["extractTranslatableFields","data","fields","basePath","translatableFields","field","nestedFields","push","tab","tabs","tabFields","fieldPath","name","value","undefined","isLocalized","localized","type","groupFields","Array","isArray","forEach","item","index","itemFields","block","blockConfig","blocks","find","b","slug","blockType","blockFields","textNodes","extractLexicalTextNodes","textNode","text","trim","lexicalPath","path","editorState","state","root","traverse","node","currentPath","children","child"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,141 @@
1
+ {
2
+ "name": "payload-translate",
3
+ "version": "1.0.0",
4
+ "description": "AI-powered translation plugin for Payload",
5
+ "license": "MIT",
6
+ "author": "Bridger Tower",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/brijr/payload-translate.git"
10
+ },
11
+ "homepage": "https://github.com/brijr/payload-translate#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/brijr/payload-translate/issues"
14
+ },
15
+ "keywords": [
16
+ "payload",
17
+ "payloadcms",
18
+ "plugin",
19
+ "translation",
20
+ "i18n",
21
+ "localization",
22
+ "gemini",
23
+ "ai"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "import": "./src/index.ts",
29
+ "types": "./src/index.ts",
30
+ "default": "./src/index.ts"
31
+ },
32
+ "./client": {
33
+ "import": "./src/exports/client.ts",
34
+ "types": "./src/exports/client.ts",
35
+ "default": "./src/exports/client.ts"
36
+ },
37
+ "./rsc": {
38
+ "import": "./src/exports/rsc.ts",
39
+ "types": "./src/exports/rsc.ts",
40
+ "default": "./src/exports/rsc.ts"
41
+ }
42
+ },
43
+ "main": "./src/index.ts",
44
+ "types": "./src/index.ts",
45
+ "files": [
46
+ "dist"
47
+ ],
48
+ "scripts": {
49
+ "build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
50
+ "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
51
+ "build:types": "tsc --outDir dist --rootDir ./src",
52
+ "clean": "rimraf {dist,*.tsbuildinfo}",
53
+ "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
54
+ "dev": "next dev dev --turbo",
55
+ "dev:generate-importmap": "pnpm dev:payload generate:importmap",
56
+ "dev:generate-types": "pnpm dev:payload generate:types",
57
+ "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
58
+ "generate:importmap": "pnpm dev:generate-importmap",
59
+ "generate:types": "pnpm dev:generate-types",
60
+ "lint": "eslint",
61
+ "lint:fix": "eslint ./src --fix",
62
+ "prepublishOnly": "pnpm clean && pnpm build",
63
+ "test": "pnpm test:int && pnpm test:e2e",
64
+ "test:e2e": "playwright test",
65
+ "test:int": "vitest"
66
+ },
67
+ "devDependencies": {
68
+ "@eslint/eslintrc": "^3.2.0",
69
+ "@payloadcms/db-mongodb": "3.37.0",
70
+ "@payloadcms/db-postgres": "3.37.0",
71
+ "@payloadcms/db-sqlite": "3.37.0",
72
+ "@payloadcms/eslint-config": "3.9.0",
73
+ "@payloadcms/next": "3.37.0",
74
+ "@payloadcms/richtext-lexical": "3.37.0",
75
+ "@payloadcms/ui": "3.37.0",
76
+ "@playwright/test": "1.56.1",
77
+ "@swc-node/register": "1.10.9",
78
+ "@swc/cli": "0.6.0",
79
+ "@types/node": "^22.5.4",
80
+ "@types/react": "19.2.1",
81
+ "@types/react-dom": "19.2.1",
82
+ "copyfiles": "2.4.1",
83
+ "cross-env": "^7.0.3",
84
+ "eslint": "^9.23.0",
85
+ "eslint-config-next": "15.4.7",
86
+ "graphql": "^16.8.1",
87
+ "mongodb-memory-server": "10.1.4",
88
+ "next": "15.5.7",
89
+ "open": "^10.1.0",
90
+ "payload": "3.37.0",
91
+ "prettier": "^3.4.2",
92
+ "qs-esm": "7.0.2",
93
+ "react": "19.2.1",
94
+ "react-dom": "19.2.1",
95
+ "rimraf": "3.0.2",
96
+ "sharp": "0.34.2",
97
+ "sort-package-json": "^2.10.0",
98
+ "typescript": "5.7.3",
99
+ "vite-tsconfig-paths": "^5.1.4",
100
+ "vitest": "^3.1.2"
101
+ },
102
+ "peerDependencies": {
103
+ "payload": "^3.37.0"
104
+ },
105
+ "engines": {
106
+ "node": "^18.20.2 || >=20.9.0",
107
+ "pnpm": "^9 || ^10"
108
+ },
109
+ "publishConfig": {
110
+ "exports": {
111
+ ".": {
112
+ "import": "./dist/index.js",
113
+ "types": "./dist/index.d.ts",
114
+ "default": "./dist/index.js"
115
+ },
116
+ "./client": {
117
+ "import": "./dist/exports/client.js",
118
+ "types": "./dist/exports/client.d.ts",
119
+ "default": "./dist/exports/client.js"
120
+ },
121
+ "./rsc": {
122
+ "import": "./dist/exports/rsc.js",
123
+ "types": "./dist/exports/rsc.d.ts",
124
+ "default": "./dist/exports/rsc.js"
125
+ }
126
+ },
127
+ "main": "./dist/index.js",
128
+ "types": "./dist/index.d.ts"
129
+ },
130
+ "pnpm": {
131
+ "onlyBuiltDependencies": [
132
+ "sharp",
133
+ "esbuild",
134
+ "unrs-resolver"
135
+ ]
136
+ },
137
+ "registry": "https://registry.npmjs.org/",
138
+ "dependencies": {
139
+ "sonner": "^2.0.7"
140
+ }
141
+ }