Skip to content

Commit 627f02b

Browse files
authored
feat(content-translator): add field level beforeTranslate and afterTranslate hooks (#168)
1 parent f912b75 commit 627f02b

21 files changed

Lines changed: 1619 additions & 96 deletions

content-translator/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- feat: add a per-field `custom['content-translator']` config (typed via module augmentation) with orthogonal `skip`, `beforeTranslate`, and `afterTranslate` hooks, so a slug can either be derived from the translated title (skip + derive) or translated and then slugified (translate + normalize)
6+
- **BREAKING**: the `custom.translatorSkip` flag is removed — move it to `custom: { 'content-translator': { skip: true } }`
57
- **BREAKING**: serve the translate endpoint at `/api/content-translator/translate` (previously `/api/translator/translate`) so the endpoint prefix matches the plugin slug. Any API client calling the old path must be updated.
68

79
## 0.3.0

content-translator/README.md

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,84 @@ export default buildConfig({
5454
| `enabled` | `boolean` | No | Whether to enable the plugin. |
5555
| `access` | `(args: { req }) => boolean \| Promise<boolean>` | No | Access control for the translate endpoint. Defaults to `({ req }) => !!req.user` (any authenticated user). |
5656

57+
### Per-field control
58+
59+
Any field can declare how the translator should treat it through a
60+
`content-translator` entry in its `custom` config. This is field-local and
61+
provider-agnostic: a field opts into special handling regardless of its name or
62+
type. The config is fully typed via module augmentation of Payload's
63+
`FieldCustom`, so the keys below autocomplete.
64+
65+
| Key | Type | Description |
66+
| ----------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
67+
| `skip` | `boolean` | Exclude the field from the resolver — its value is not translated. Use alone to let the app own it, or with `afterTranslate`. |
68+
| `beforeTranslate` | `(args) => string` | Transform the source string right before it is sent to the resolver. The translated result is written back as usual. |
69+
| `afterTranslate` | `(args) => unknown \| Promise<...>` | Post-process the field _after_ the rest of the document is translated. Runs independently of `skip`. |
70+
71+
The three keys are orthogonal: `skip` decides whether the field is translated,
72+
`beforeTranslate` pre-processes what is sent, and `afterTranslate` post-processes
73+
the result (or derives a value from translated siblings).
74+
75+
#### Translating slug fields
76+
77+
A slug must never be stored with the raw output of an LLM — it has to stay
78+
URL-safe. There are two strategies, depending on whether the slug should mirror
79+
the title:
80+
81+
**Derive from the title** — the slug always follows the title, so skip
82+
translation and re-slugify the already-translated title (e.g. "Travel Tips" →
83+
`reisetipps`):
84+
85+
```ts
86+
{
87+
name: 'slug',
88+
type: 'text',
89+
localized: true,
90+
custom: {
91+
'content-translator': {
92+
skip: true,
93+
afterTranslate: ({ siblingData }) => slugify(siblingData.title),
94+
},
95+
},
96+
// validation and other field options...
97+
}
98+
```
99+
100+
**Translate, then normalize** — for a slug intentionally different from the
101+
title, translate the slug text and then slugify it to strip any special
102+
characters the translation introduced:
103+
104+
```ts
105+
{
106+
name: 'slug',
107+
type: 'text',
108+
localized: true,
109+
custom: {
110+
'content-translator': {
111+
// No `skip`: the slug is translated, then cleaned.
112+
afterTranslate: ({ value }) => slugify(value),
113+
},
114+
},
115+
// validation and other field options...
116+
}
117+
```
118+
119+
`afterTranslate` receives the field's own (translated) `value`, the translated
120+
`siblingData`, the full translated `data`, `sourceValue`, `localeFrom`/`localeTo`,
121+
and `req`. Because it is field-local, this works for any derived or normalized
122+
field under any name — slugs, URL paths, computed keys.
123+
124+
#### With the Pages plugin
125+
126+
The [Pages plugin](https://www.npmjs.com/package/@jhb.software/payload-pages-plugin)
127+
injects the `slug` field into its page collections, so you attach the config with
128+
a small plugin that runs after `payloadPagesPlugin` and derives the slug from the
129+
translated title — normalized with the pages plugin's `formatSlug`, the same rule
130+
the slug field validates against, so the result is always accepted.
131+
132+
See the runnable example: [`makeSlugTranslatable`](https://github.com/jhb-software/payload-plugins/blob/main/content-translator/dev/src/helpers/makeSlugTranslatable.ts)
133+
and [its wiring](https://github.com/jhb-software/payload-plugins/blob/main/content-translator/dev/src/payload.config.ts) in the dev app.
134+
57135
### Resolvers
58136

59137
This plugin is designed to work seamlessly with various translation services by accepting a customizable translation resolver as a configuration option.
@@ -75,9 +153,9 @@ openAIResolver({
75153

76154
The plugin registers a single REST endpoint that the admin UI calls to translate a document or global. It computes the translated values and returns them in the response — it never writes to the database, so changes still go through Payload's normal save/publish flow.
77155

78-
| Method | Path | Description |
79-
| ------ | --------------------------- | -------------------------------------------------------------------- |
80-
| `POST` | `/api/translator/translate` | Translates the given entity's fields and returns the translated data |
156+
| Method | Path | Description |
157+
| ------ | ----------------------------------- | -------------------------------------------------------------------- |
158+
| `POST` | `/api/content-translator/translate` | Translates the given entity's fields and returns the translated data |
81159

82160
### Authentication
83161

content-translator/dev/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
},
2020
"dependencies": {
2121
"@jhb.software/payload-content-translator-plugin": "workspace:*",
22+
"@jhb.software/payload-pages-plugin": "workspace:*",
2223
"@payloadcms/db-mongodb": "^3.85.1",
2324
"@payloadcms/next": "^3.85.1",
2425
"@payloadcms/richtext-lexical": "^3.85.1",

content-translator/dev/payload-types.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66
* and re-run `payload generate:types` to regenerate this file.
77
*/
88

9+
/**
10+
* This interface was referenced by `Config`'s JSON-Schema
11+
* via the `definition` "Breadcrumbs".
12+
*/
13+
export type Breadcrumbs = {
14+
slug: string;
15+
path: string;
16+
label: string;
17+
id?: string | null;
18+
}[];
919
/**
1020
* Supported timezones in IANA format.
1121
*
@@ -68,6 +78,7 @@ export interface Config {
6878
blocks: {};
6979
collections: {
7080
pages: Page;
81+
docs: Doc;
7182
posts: Post;
7283
authors: Author;
7384
media: Media;
@@ -80,6 +91,7 @@ export interface Config {
8091
collectionsJoins: {};
8192
collectionsSelect: {
8293
pages: PagesSelect<false> | PagesSelect<true>;
94+
docs: DocsSelect<false> | DocsSelect<true>;
8395
posts: PostsSelect<false> | PostsSelect<true>;
8496
authors: AuthorsSelect<false> | AuthorsSelect<true>;
8597
media: MediaSelect<false> | MediaSelect<true>;
@@ -128,6 +140,36 @@ export interface UserAuthOperations {
128140
* via the `definition` "pages".
129141
*/
130142
export interface Page {
143+
id: string;
144+
isRootPage?: boolean | null;
145+
slug: string;
146+
parent?: (string | null) | Page;
147+
path: string;
148+
breadcrumbs: Breadcrumbs;
149+
title: string;
150+
content?: {
151+
root: {
152+
type: string;
153+
children: {
154+
type: any;
155+
version: number;
156+
[k: string]: unknown;
157+
}[];
158+
direction: ('ltr' | 'rtl') | null;
159+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
160+
indent: number;
161+
version: number;
162+
};
163+
[k: string]: unknown;
164+
} | null;
165+
updatedAt: string;
166+
createdAt: string;
167+
}
168+
/**
169+
* This interface was referenced by `Config`'s JSON-Schema
170+
* via the `definition` "docs".
171+
*/
172+
export interface Doc {
131173
id: string;
132174
title: string;
133175
slug: string;
@@ -151,6 +193,7 @@ export interface Page {
151193
title?: string | null;
152194
description?: string | null;
153195
};
196+
ctaLabel?: string | null;
154197
seo?: {
155198
ogTitle?: string | null;
156199
ogDescription?: string | null;
@@ -267,6 +310,10 @@ export interface PayloadLockedDocument {
267310
relationTo: 'pages';
268311
value: string | Page;
269312
} | null)
313+
| ({
314+
relationTo: 'docs';
315+
value: string | Doc;
316+
} | null)
270317
| ({
271318
relationTo: 'posts';
272319
value: string | Post;
@@ -330,6 +377,31 @@ export interface PayloadMigration {
330377
* via the `definition` "pages_select".
331378
*/
332379
export interface PagesSelect<T extends boolean = true> {
380+
isRootPage?: T;
381+
slug?: T;
382+
parent?: T;
383+
path?: T;
384+
breadcrumbs?: T | BreadcrumbsSelect<T>;
385+
title?: T;
386+
content?: T;
387+
updatedAt?: T;
388+
createdAt?: T;
389+
}
390+
/**
391+
* This interface was referenced by `Config`'s JSON-Schema
392+
* via the `definition` "Breadcrumbs_select".
393+
*/
394+
export interface BreadcrumbsSelect<T extends boolean = true> {
395+
slug?: T;
396+
path?: T;
397+
label?: T;
398+
id?: T;
399+
}
400+
/**
401+
* This interface was referenced by `Config`'s JSON-Schema
402+
* via the `definition` "docs_select".
403+
*/
404+
export interface DocsSelect<T extends boolean = true> {
333405
title?: T;
334406
slug?: T;
335407
content?: T;
@@ -340,6 +412,7 @@ export interface PagesSelect<T extends boolean = true> {
340412
title?: T;
341413
description?: T;
342414
};
415+
ctaLabel?: T;
343416
seo?:
344417
| T
345418
| {

content-translator/dev/src/app/(payload)/admin/importMap.js

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
/** Minimal slugifier for the demo (lowercase, non-word runs → hyphens). */
4+
const slugify = (value: string): string =>
5+
value
6+
.toLowerCase()
7+
.replace(/[^\w]+/g, '-')
8+
.replace(/^-+|-+$/g, '')
9+
10+
export const docsSchema: CollectionConfig = {
11+
slug: 'docs',
12+
fields: [
13+
{
14+
name: 'title',
15+
type: 'text',
16+
required: true,
17+
localized: true,
18+
},
19+
{
20+
// Slug derived from the title: skip translation entirely and re-slugify
21+
// the already-translated title, so translating an English page produces a
22+
// localized German slug (e.g. "Travel Tips" → "reisetipps").
23+
name: 'slug',
24+
type: 'text',
25+
custom: {
26+
'content-translator': {
27+
afterTranslate: ({ siblingData }) => slugify(String(siblingData.title ?? '')),
28+
skip: true,
29+
},
30+
},
31+
index: true,
32+
localized: true,
33+
required: true,
34+
},
35+
{
36+
name: 'content',
37+
type: 'richText',
38+
required: false,
39+
localized: true,
40+
},
41+
{
42+
// hasMany text field: each keyword is translated individually
43+
name: 'keywords',
44+
type: 'text',
45+
hasMany: true,
46+
localized: true,
47+
},
48+
{
49+
name: 'meta',
50+
type: 'group',
51+
fields: [
52+
{
53+
name: 'title',
54+
type: 'text',
55+
localized: true,
56+
},
57+
{
58+
name: 'description',
59+
type: 'textarea',
60+
localized: true,
61+
},
62+
],
63+
},
64+
{
65+
// Unnamed (presentational) group: its fields are stored on the document
66+
// root, not under a key. Used here to demonstrate that the translator
67+
// traverses into unnamed groups instead of throwing.
68+
type: 'group',
69+
label: 'Call to action',
70+
fields: [
71+
{
72+
name: 'ctaLabel',
73+
type: 'text',
74+
localized: true,
75+
},
76+
],
77+
},
78+
{
79+
type: 'tabs',
80+
tabs: [
81+
{
82+
name: 'seo',
83+
fields: [
84+
{
85+
name: 'ogTitle',
86+
type: 'text',
87+
localized: true,
88+
},
89+
{
90+
name: 'ogDescription',
91+
type: 'textarea',
92+
localized: true,
93+
},
94+
],
95+
},
96+
],
97+
},
98+
],
99+
}

0 commit comments

Comments
 (0)