@mrtimmy/payload-connector 0.1.0 → 0.2.1
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 +111 -1
- package/package.json +2 -1
- package/src/index.d.ts +42 -0
- package/src/index.js +70 -18
- package/src/lexical.js +12 -0
- package/src/utils.js +455 -16
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ export default buildConfig({
|
|
|
18
18
|
plugins: [
|
|
19
19
|
mrTimmyPayloadConnector({
|
|
20
20
|
postsCollection: "posts",
|
|
21
|
+
taxonomies: {
|
|
22
|
+
categories: { matchFields: ["slug", "title"] },
|
|
23
|
+
tags: { matchFields: ["slug", "title"] },
|
|
24
|
+
},
|
|
21
25
|
}),
|
|
22
26
|
],
|
|
23
27
|
})
|
|
@@ -25,6 +29,17 @@ export default buildConfig({
|
|
|
25
29
|
|
|
26
30
|
Deploy the Payload site after changing the config.
|
|
27
31
|
|
|
32
|
+
If your Payload app runs inside Next.js and external packages are bundled too
|
|
33
|
+
aggressively, keep the connector external in `next.config.js`:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
const nextConfig = {
|
|
37
|
+
serverExternalPackages: ["@mrtimmy/payload-connector"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default nextConfig
|
|
41
|
+
```
|
|
42
|
+
|
|
28
43
|
## Create a Connector Key
|
|
29
44
|
|
|
30
45
|
After deployment, open the Payload admin panel:
|
|
@@ -63,6 +78,11 @@ x-api-key: timmy_xxx
|
|
|
63
78
|
mrTimmyPayloadConnector({
|
|
64
79
|
postsCollection: "posts",
|
|
65
80
|
contentMode: "lexical",
|
|
81
|
+
media: {
|
|
82
|
+
collection: "media",
|
|
83
|
+
uploadFeaturedImage: true,
|
|
84
|
+
uploadInlineImages: true,
|
|
85
|
+
},
|
|
66
86
|
fields: {
|
|
67
87
|
title: "title",
|
|
68
88
|
content: "content",
|
|
@@ -75,6 +95,20 @@ mrTimmyPayloadConnector({
|
|
|
75
95
|
featuredImageUrl: "featuredImage",
|
|
76
96
|
imageAlt: "imageAlt",
|
|
77
97
|
contentAssets: "contentAssets",
|
|
98
|
+
categories: "categories",
|
|
99
|
+
tags: "tags",
|
|
100
|
+
},
|
|
101
|
+
taxonomies: {
|
|
102
|
+
categories: {
|
|
103
|
+
collection: "categories",
|
|
104
|
+
create: true,
|
|
105
|
+
matchFields: ["slug", "title"],
|
|
106
|
+
},
|
|
107
|
+
tags: {
|
|
108
|
+
collection: "tags",
|
|
109
|
+
create: true,
|
|
110
|
+
matchFields: ["slug", "title"],
|
|
111
|
+
},
|
|
78
112
|
},
|
|
79
113
|
})
|
|
80
114
|
```
|
|
@@ -87,10 +121,86 @@ mrTimmyPayloadConnector({
|
|
|
87
121
|
|
|
88
122
|
If your Payload post schema uses different field names, override `fields`.
|
|
89
123
|
|
|
124
|
+
## Media Uploads
|
|
125
|
+
|
|
126
|
+
If the configured `featuredImageUrl` field points to a Payload `upload` field,
|
|
127
|
+
the connector downloads the incoming image URL, uploads it to the configured
|
|
128
|
+
media collection, and stores the new media document ID on the post.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
mrTimmyPayloadConnector({
|
|
132
|
+
media: {
|
|
133
|
+
collection: "media",
|
|
134
|
+
fields: {
|
|
135
|
+
alt: "alt",
|
|
136
|
+
title: "title",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
fields: {
|
|
140
|
+
featuredImageUrl: "featuredImage",
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
If `featuredImageUrl` is a plain text/URL field, the connector keeps the old
|
|
146
|
+
behavior and stores the image URL instead.
|
|
147
|
+
|
|
148
|
+
Inline Markdown images in the article body are also uploaded to the media
|
|
149
|
+
collection when possible. Their URLs are rewritten before the content is
|
|
150
|
+
converted to HTML or Lexical JSON.
|
|
151
|
+
|
|
152
|
+
To disable media handling:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
mrTimmyPayloadConnector({
|
|
156
|
+
media: false,
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Categories and Tags
|
|
161
|
+
|
|
162
|
+
The connector accepts category/tag input from fields like `categories`,
|
|
163
|
+
`categoryNames`, `categorySlugs`, `tags`, `tagNames`, and `tagSlugs`.
|
|
164
|
+
|
|
165
|
+
If the target Payload fields are relationship fields, the connector looks up
|
|
166
|
+
matching documents by `slug` or `title`. Missing terms are created by default.
|
|
167
|
+
Configured `matchFields` that do not exist in the target Payload collection are
|
|
168
|
+
ignored.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
mrTimmyPayloadConnector({
|
|
172
|
+
fields: {
|
|
173
|
+
categories: "categories",
|
|
174
|
+
tags: "tags",
|
|
175
|
+
},
|
|
176
|
+
taxonomies: {
|
|
177
|
+
categories: {
|
|
178
|
+
collection: "categories",
|
|
179
|
+
labelField: "title",
|
|
180
|
+
slugField: "slug",
|
|
181
|
+
matchFields: ["slug", "title"],
|
|
182
|
+
create: true,
|
|
183
|
+
},
|
|
184
|
+
tags: {
|
|
185
|
+
collection: "tags",
|
|
186
|
+
labelField: "title",
|
|
187
|
+
slugField: "slug",
|
|
188
|
+
matchFields: ["slug", "title"],
|
|
189
|
+
create: true,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
If your taxonomy collections use another required field, provide `createData`.
|
|
196
|
+
|
|
90
197
|
## Notes
|
|
91
198
|
|
|
92
199
|
- The plugin uses Payload Custom Endpoints and Payload Local API.
|
|
93
200
|
- Custom endpoints are authenticated by this plugin, not by Payload automatically.
|
|
94
201
|
- Draft/publish defaults to `_status: "draft" | "published"`, matching Payload draft-enabled collections.
|
|
95
202
|
- If your collection does not use drafts or uses a custom status field, configure `fields.status` and `statuses`, or set `fields.status` to `null`.
|
|
96
|
-
-
|
|
203
|
+
- Uploads use Payload's Local API with `filePath`; hooks and storage adapters on your media collection still run.
|
|
204
|
+
- Connector errors include the failing step (`RichText-Konvertierung`,
|
|
205
|
+
`Taxonomie-Mapping`, `Media-Upload` or `Payload-Create`) so mr.timmy can show a
|
|
206
|
+
useful error instead of a generic Payload failure.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrtimmy/payload-connector",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Payload CMS connector for publishing mr.timmy blog posts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"src/index.js",
|
|
24
24
|
"src/index.d.ts",
|
|
25
|
+
"src/lexical.js",
|
|
25
26
|
"src/utils.js",
|
|
26
27
|
"README.md"
|
|
27
28
|
],
|
package/src/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type FieldMap = {
|
|
|
12
12
|
featuredImageUrl?: string | null
|
|
13
13
|
imageAlt?: string | null
|
|
14
14
|
contentAssets?: string | null
|
|
15
|
+
categories?: string | null
|
|
16
|
+
tags?: string | null
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export type ConnectorStatuses = {
|
|
@@ -19,6 +21,44 @@ export type ConnectorStatuses = {
|
|
|
19
21
|
published?: string
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
export type MediaOptions = false | {
|
|
25
|
+
collection?: string
|
|
26
|
+
uploadFeaturedImage?: boolean
|
|
27
|
+
uploadInlineImages?: boolean
|
|
28
|
+
maxBytes?: number
|
|
29
|
+
fields?: {
|
|
30
|
+
alt?: string | null
|
|
31
|
+
title?: string | null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TaxonomyTerm = {
|
|
36
|
+
id?: string | number
|
|
37
|
+
_id?: string | number
|
|
38
|
+
label?: string
|
|
39
|
+
title?: string
|
|
40
|
+
name?: string
|
|
41
|
+
slug?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type TaxonomyOptions = false | {
|
|
45
|
+
collection?: string
|
|
46
|
+
create?: boolean
|
|
47
|
+
inputFields?: string[]
|
|
48
|
+
labelField?: string | null
|
|
49
|
+
slugField?: string | null
|
|
50
|
+
matchFields?: string[]
|
|
51
|
+
createData?: (
|
|
52
|
+
args: { label?: string; slug?: string; term: TaxonomyTerm },
|
|
53
|
+
req: unknown,
|
|
54
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type TaxonomiesOptions = {
|
|
58
|
+
categories?: TaxonomyOptions
|
|
59
|
+
tags?: TaxonomyOptions
|
|
60
|
+
}
|
|
61
|
+
|
|
22
62
|
export type MrTimmyPayloadConnectorOptions = {
|
|
23
63
|
enabled?: boolean
|
|
24
64
|
postsCollection?: string
|
|
@@ -29,6 +69,8 @@ export type MrTimmyPayloadConnectorOptions = {
|
|
|
29
69
|
contentMode?: ContentMode
|
|
30
70
|
fields?: FieldMap
|
|
31
71
|
statuses?: ConnectorStatuses
|
|
72
|
+
media?: MediaOptions
|
|
73
|
+
taxonomies?: TaxonomiesOptions
|
|
32
74
|
mapCreateData?: (
|
|
33
75
|
data: Record<string, unknown>,
|
|
34
76
|
input: Record<string, unknown>,
|
package/src/index.js
CHANGED
|
@@ -2,17 +2,19 @@ import {
|
|
|
2
2
|
DEFAULT_ENDPOINT_PATH,
|
|
3
3
|
DEFAULT_HEADER_NAMES,
|
|
4
4
|
DEFAULT_KEYS_COLLECTION,
|
|
5
|
+
ConnectorStepError,
|
|
5
6
|
buildApiUrlHint,
|
|
6
7
|
buildPostData,
|
|
7
8
|
buildPublishData,
|
|
8
9
|
constantTimeEquals,
|
|
10
|
+
errorMessage,
|
|
9
11
|
generateConnectorKey,
|
|
10
12
|
maskKey,
|
|
11
13
|
normalizeEndpointPath,
|
|
12
14
|
readConnectorHeader,
|
|
13
15
|
} from "./utils.js"
|
|
14
16
|
|
|
15
|
-
const VERSION = "0.1
|
|
17
|
+
const VERSION = "0.2.1"
|
|
16
18
|
|
|
17
19
|
export function mrTimmyPayloadConnector(pluginOptions = {}) {
|
|
18
20
|
return (incomingConfig = {}) => {
|
|
@@ -169,14 +171,25 @@ function createConnectorEndpoints(options) {
|
|
|
169
171
|
return connectorError("Titel fehlt.", 400)
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
174
|
+
let data
|
|
175
|
+
try {
|
|
176
|
+
data = await buildPostData(input, options, req)
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return connectorException(req, "Datenaufbereitung", error)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let doc
|
|
182
|
+
try {
|
|
183
|
+
doc = await req.payload.create({
|
|
184
|
+
collection: options.postsCollection,
|
|
185
|
+
data,
|
|
186
|
+
depth: 0,
|
|
187
|
+
draft: true,
|
|
188
|
+
overrideAccess: true,
|
|
189
|
+
})
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return connectorException(req, "Payload-Create", error)
|
|
192
|
+
}
|
|
180
193
|
|
|
181
194
|
return Response.json({
|
|
182
195
|
success: true,
|
|
@@ -199,15 +212,26 @@ function createConnectorEndpoints(options) {
|
|
|
199
212
|
}
|
|
200
213
|
|
|
201
214
|
const input = await req.json().catch(() => ({}))
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
215
|
+
let data
|
|
216
|
+
try {
|
|
217
|
+
data = await buildPublishData(options, input, req)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return connectorException(req, "Publish-Datenaufbereitung", error)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let doc
|
|
223
|
+
try {
|
|
224
|
+
doc = await req.payload.update({
|
|
225
|
+
collection: options.postsCollection,
|
|
226
|
+
id,
|
|
227
|
+
data,
|
|
228
|
+
depth: 0,
|
|
229
|
+
draft: false,
|
|
230
|
+
overrideAccess: true,
|
|
231
|
+
})
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return connectorException(req, "Payload-Publish", error)
|
|
234
|
+
}
|
|
211
235
|
|
|
212
236
|
const url =
|
|
213
237
|
typeof options.resolvePublishedUrl === "function"
|
|
@@ -257,6 +281,34 @@ function connectorError(message, status = 500) {
|
|
|
257
281
|
return Response.json({ success: false, error: message, message }, { status })
|
|
258
282
|
}
|
|
259
283
|
|
|
284
|
+
function connectorException(req, step, error, status = 500) {
|
|
285
|
+
const finalStep = error instanceof ConnectorStepError ? error.step : step
|
|
286
|
+
const message =
|
|
287
|
+
error instanceof ConnectorStepError
|
|
288
|
+
? error.message
|
|
289
|
+
: `${step}: ${errorMessage(error)}`
|
|
290
|
+
|
|
291
|
+
logConnectorException(req, finalStep, error, message)
|
|
292
|
+
return Response.json(
|
|
293
|
+
{ success: false, error: message, message, step: finalStep },
|
|
294
|
+
{ status },
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function logConnectorException(req, step, error, message) {
|
|
299
|
+
const line = `[mr.timmy Payload Connector] ${step} fehlgeschlagen: ${message}`
|
|
300
|
+
const logger = req?.payload?.logger
|
|
301
|
+
if (logger?.error) {
|
|
302
|
+
try {
|
|
303
|
+
logger.error(line)
|
|
304
|
+
return
|
|
305
|
+
} catch {
|
|
306
|
+
// Fall through to console logging.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
console.error(line, error)
|
|
310
|
+
}
|
|
311
|
+
|
|
260
312
|
function defaultPublishedUrl(doc, req) {
|
|
261
313
|
if (typeof doc.url === "string") return doc.url
|
|
262
314
|
if (typeof doc.publishedUrl === "string") return doc.publishedUrl
|
package/src/lexical.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { convertHTMLToLexical, editorConfigFactory } from "@payloadcms/richtext-lexical"
|
|
2
|
+
import { JSDOM } from "jsdom"
|
|
3
|
+
|
|
4
|
+
export async function convertHtmlToLexicalContent(html, req) {
|
|
5
|
+
return convertHTMLToLexical({
|
|
6
|
+
editorConfig: await editorConfigFactory.default({
|
|
7
|
+
config: req.payload.config,
|
|
8
|
+
}),
|
|
9
|
+
html,
|
|
10
|
+
JSDOM,
|
|
11
|
+
})
|
|
12
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto"
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import fs from "node:fs/promises"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { marked } from "marked"
|
|
4
6
|
|
|
5
7
|
export const DEFAULT_ENDPOINT_PATH = "/mrtimmy/create-post"
|
|
6
8
|
export const DEFAULT_KEYS_COLLECTION = "mrtimmy-connector-keys"
|
|
@@ -18,6 +20,8 @@ export const DEFAULT_FIELDS = {
|
|
|
18
20
|
featuredImageUrl: "featuredImage",
|
|
19
21
|
imageAlt: "imageAlt",
|
|
20
22
|
contentAssets: "contentAssets",
|
|
23
|
+
categories: "categories",
|
|
24
|
+
tags: "tags",
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export const DEFAULT_STATUSES = {
|
|
@@ -25,6 +29,59 @@ export const DEFAULT_STATUSES = {
|
|
|
25
29
|
published: "published",
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
export const DEFAULT_MEDIA = {
|
|
33
|
+
collection: "media",
|
|
34
|
+
uploadFeaturedImage: true,
|
|
35
|
+
uploadInlineImages: true,
|
|
36
|
+
maxBytes: 15 * 1024 * 1024,
|
|
37
|
+
fields: {
|
|
38
|
+
alt: "alt",
|
|
39
|
+
title: "title",
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_TAXONOMIES = {
|
|
44
|
+
categories: {
|
|
45
|
+
create: true,
|
|
46
|
+
inputFields: ["categories", "categoryNames", "categorySlugs"],
|
|
47
|
+
labelField: "title",
|
|
48
|
+
slugField: "slug",
|
|
49
|
+
matchFields: ["slug", "title"],
|
|
50
|
+
},
|
|
51
|
+
tags: {
|
|
52
|
+
create: true,
|
|
53
|
+
inputFields: ["tags", "tagNames", "tagSlugs"],
|
|
54
|
+
labelField: "title",
|
|
55
|
+
slugField: "slug",
|
|
56
|
+
matchFields: ["slug", "title"],
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ConnectorStepError extends Error {
|
|
61
|
+
constructor(step, error) {
|
|
62
|
+
super(`${step}: ${errorMessage(error)}`)
|
|
63
|
+
this.name = "ConnectorStepError"
|
|
64
|
+
this.step = step
|
|
65
|
+
this.cause = error
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function errorMessage(error) {
|
|
70
|
+
if (error instanceof Error) return error.message
|
|
71
|
+
if (typeof error === "string") return error
|
|
72
|
+
try {
|
|
73
|
+
return JSON.stringify(error)
|
|
74
|
+
} catch {
|
|
75
|
+
return String(error)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function wrapStepError(step, error) {
|
|
80
|
+
return error instanceof ConnectorStepError
|
|
81
|
+
? error
|
|
82
|
+
: new ConnectorStepError(step, error)
|
|
83
|
+
}
|
|
84
|
+
|
|
28
85
|
export function generateConnectorKey(prefix = "timmy") {
|
|
29
86
|
return `${prefix}_${crypto.randomBytes(32).toString("hex")}`
|
|
30
87
|
}
|
|
@@ -50,6 +107,38 @@ export function normalizeFields(fields = {}) {
|
|
|
50
107
|
return { ...DEFAULT_FIELDS, ...fields }
|
|
51
108
|
}
|
|
52
109
|
|
|
110
|
+
export function normalizeMedia(media = {}) {
|
|
111
|
+
if (media === false) return { enabled: false }
|
|
112
|
+
return {
|
|
113
|
+
enabled: true,
|
|
114
|
+
...DEFAULT_MEDIA,
|
|
115
|
+
...media,
|
|
116
|
+
fields: {
|
|
117
|
+
...DEFAULT_MEDIA.fields,
|
|
118
|
+
...(media.fields ?? {}),
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function normalizeTaxonomies(taxonomies = {}) {
|
|
124
|
+
return {
|
|
125
|
+
categories:
|
|
126
|
+
taxonomies.categories === false
|
|
127
|
+
? false
|
|
128
|
+
: {
|
|
129
|
+
...DEFAULT_TAXONOMIES.categories,
|
|
130
|
+
...(taxonomies.categories ?? {}),
|
|
131
|
+
},
|
|
132
|
+
tags:
|
|
133
|
+
taxonomies.tags === false
|
|
134
|
+
? false
|
|
135
|
+
: {
|
|
136
|
+
...DEFAULT_TAXONOMIES.tags,
|
|
137
|
+
...(taxonomies.tags ?? {}),
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
53
142
|
export function setPath(target, path, value) {
|
|
54
143
|
if (!path || value === undefined || value === null) return target
|
|
55
144
|
const parts = String(path).split(".").filter(Boolean)
|
|
@@ -69,6 +158,81 @@ export function setPath(target, path, value) {
|
|
|
69
158
|
return target
|
|
70
159
|
}
|
|
71
160
|
|
|
161
|
+
export function slugify(value = "") {
|
|
162
|
+
return String(value)
|
|
163
|
+
.trim()
|
|
164
|
+
.toLowerCase()
|
|
165
|
+
.normalize("NFKD")
|
|
166
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
167
|
+
.replace(/ä/g, "ae")
|
|
168
|
+
.replace(/ö/g, "oe")
|
|
169
|
+
.replace(/ü/g, "ue")
|
|
170
|
+
.replace(/ß/g, "ss")
|
|
171
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
172
|
+
.replace(/^-+|-+$/g, "")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function normalizeTermList(value) {
|
|
176
|
+
const values = Array.isArray(value) ? value : value ? [value] : []
|
|
177
|
+
return values
|
|
178
|
+
.map((entry) => {
|
|
179
|
+
if (entry && typeof entry === "object") {
|
|
180
|
+
const id = entry.id ?? entry._id
|
|
181
|
+
const label = entry.label ?? entry.title ?? entry.name ?? entry.slug
|
|
182
|
+
const slug = entry.slug ?? (label ? slugify(label) : undefined)
|
|
183
|
+
return id || label || slug ? { id, label, slug } : null
|
|
184
|
+
}
|
|
185
|
+
const label = String(entry ?? "").trim()
|
|
186
|
+
return label ? { label, slug: slugify(label) } : null
|
|
187
|
+
})
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function getCollectionConfig(req, slug) {
|
|
192
|
+
return req?.payload?.config?.collections?.find(
|
|
193
|
+
(collection) => collection?.slug === slug,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function findFieldInFields(fields = [], parts = []) {
|
|
198
|
+
for (const field of fields) {
|
|
199
|
+
if (!field) continue
|
|
200
|
+
|
|
201
|
+
if (field.type === "tabs") {
|
|
202
|
+
for (const tab of field.tabs ?? []) {
|
|
203
|
+
const found = findFieldInFields(tab.fields ?? [], parts)
|
|
204
|
+
if (found) return found
|
|
205
|
+
}
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!field.name) {
|
|
210
|
+
const found = findFieldInFields(field.fields ?? [], parts)
|
|
211
|
+
if (found) return found
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (field.name !== parts[0]) continue
|
|
216
|
+
if (parts.length === 1) return field
|
|
217
|
+
return findFieldInFields(field.fields ?? [], parts.slice(1))
|
|
218
|
+
}
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function getFieldConfig(req, collectionSlug, fieldPath) {
|
|
223
|
+
if (!fieldPath) return undefined
|
|
224
|
+
const collection = getCollectionConfig(req, collectionSlug)
|
|
225
|
+
return findFieldInFields(collection?.fields ?? [], String(fieldPath).split("."))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function fieldExpectsUpload(field) {
|
|
229
|
+
return field?.type === "upload"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function fieldExpectsRelationship(field) {
|
|
233
|
+
return field?.type === "relationship"
|
|
234
|
+
}
|
|
235
|
+
|
|
72
236
|
export function extractExcerpt(markdown = "") {
|
|
73
237
|
const lines = String(markdown).split("\n")
|
|
74
238
|
for (const line of lines) {
|
|
@@ -95,6 +259,133 @@ export function buildApiUrlHint(req, endpointPath) {
|
|
|
95
259
|
return origin ? `${origin}/api${endpointPath}` : `/api${endpointPath}`
|
|
96
260
|
}
|
|
97
261
|
|
|
262
|
+
function absolutizeUrl(url, req) {
|
|
263
|
+
if (!url || typeof url !== "string") return url
|
|
264
|
+
if (/^https?:\/\//i.test(url)) return url
|
|
265
|
+
if (!url.startsWith("/")) return url
|
|
266
|
+
const origin = buildOriginFromRequest(req)
|
|
267
|
+
return origin ? `${origin}${url}` : url
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isHttpUrl(value) {
|
|
271
|
+
return /^https?:\/\//i.test(String(value ?? ""))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function extensionFor(contentType, sourceUrl) {
|
|
275
|
+
const fromType = {
|
|
276
|
+
"image/jpeg": ".jpg",
|
|
277
|
+
"image/jpg": ".jpg",
|
|
278
|
+
"image/png": ".png",
|
|
279
|
+
"image/webp": ".webp",
|
|
280
|
+
"image/gif": ".gif",
|
|
281
|
+
"image/avif": ".avif",
|
|
282
|
+
"image/svg+xml": ".svg",
|
|
283
|
+
}[String(contentType ?? "").split(";")[0].trim().toLowerCase()]
|
|
284
|
+
if (fromType) return fromType
|
|
285
|
+
const ext = path.extname(new URL(sourceUrl).pathname)
|
|
286
|
+
return ext || ".jpg"
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function safeFileBase(value) {
|
|
290
|
+
const base = slugify(value) || `mrtimmy-${Date.now()}`
|
|
291
|
+
return base.slice(0, 80)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function downloadImageToTempFile(sourceUrl, media) {
|
|
295
|
+
const response = await fetch(sourceUrl)
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
throw new Error(`Bild konnte nicht geladen werden: ${response.status}`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const contentType =
|
|
301
|
+
response.headers.get("content-type") ?? "application/octet-stream"
|
|
302
|
+
if (!contentType.toLowerCase().startsWith("image/")) {
|
|
303
|
+
throw new Error(`URL ist kein Bild: ${contentType}`)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
307
|
+
if (buffer.byteLength > media.maxBytes) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Bild ist zu groß (${buffer.byteLength} Bytes, Limit ${media.maxBytes})`,
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const extension = extensionFor(contentType, sourceUrl)
|
|
314
|
+
const filePath = path.join(
|
|
315
|
+
os.tmpdir(),
|
|
316
|
+
`${safeFileBase(path.basename(new URL(sourceUrl).pathname, extension))}-${crypto.randomBytes(8).toString("hex")}${extension}`,
|
|
317
|
+
)
|
|
318
|
+
await fs.writeFile(filePath, buffer)
|
|
319
|
+
return { filePath, contentType }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildMediaData(media, alt, title) {
|
|
323
|
+
const data = {}
|
|
324
|
+
setPath(data, media.fields.alt, alt)
|
|
325
|
+
setPath(data, media.fields.title, title)
|
|
326
|
+
return data
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function uploadImageFromUrl(sourceUrl, media, req, meta = {}) {
|
|
330
|
+
if (!media.enabled || !isHttpUrl(sourceUrl)) return null
|
|
331
|
+
if (!getCollectionConfig(req, media.collection)?.upload) return null
|
|
332
|
+
|
|
333
|
+
const { filePath } = await downloadImageToTempFile(sourceUrl, media)
|
|
334
|
+
try {
|
|
335
|
+
const doc = await req.payload.create({
|
|
336
|
+
collection: media.collection,
|
|
337
|
+
data: buildMediaData(media, meta.alt, meta.title),
|
|
338
|
+
filePath,
|
|
339
|
+
depth: 0,
|
|
340
|
+
overrideAccess: true,
|
|
341
|
+
})
|
|
342
|
+
return {
|
|
343
|
+
id: doc.id ?? doc._id,
|
|
344
|
+
url: absolutizeUrl(doc.url, req),
|
|
345
|
+
doc,
|
|
346
|
+
originalUrl: sourceUrl,
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
await fs.unlink(filePath).catch(() => {})
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function extractMarkdownImages(markdown = "") {
|
|
354
|
+
const images = []
|
|
355
|
+
String(markdown).replace(
|
|
356
|
+
/!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g,
|
|
357
|
+
(match, alt, url) => {
|
|
358
|
+
if (isHttpUrl(url)) images.push({ match, alt, url })
|
|
359
|
+
return match
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
return images
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function rewriteInlineImages(markdown, media, req, cache) {
|
|
366
|
+
if (!media.enabled || !media.uploadInlineImages) return markdown
|
|
367
|
+
const images = extractMarkdownImages(markdown)
|
|
368
|
+
if (images.length === 0) return markdown
|
|
369
|
+
|
|
370
|
+
let next = String(markdown)
|
|
371
|
+
for (const image of images) {
|
|
372
|
+
const asset = await getUploadedImage(image.url, media, req, cache, {
|
|
373
|
+
alt: image.alt,
|
|
374
|
+
title: image.alt,
|
|
375
|
+
})
|
|
376
|
+
if (!asset?.url) continue
|
|
377
|
+
next = next.replace(image.url, asset.url)
|
|
378
|
+
}
|
|
379
|
+
return next
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function getUploadedImage(sourceUrl, media, req, cache, meta) {
|
|
383
|
+
if (!cache.has(sourceUrl)) {
|
|
384
|
+
cache.set(sourceUrl, uploadImageFromUrl(sourceUrl, media, req, meta))
|
|
385
|
+
}
|
|
386
|
+
return cache.get(sourceUrl)
|
|
387
|
+
}
|
|
388
|
+
|
|
98
389
|
export function readConnectorHeader(req, headerNames = DEFAULT_HEADER_NAMES) {
|
|
99
390
|
for (const name of headerNames) {
|
|
100
391
|
const value = req?.headers?.get?.(name)
|
|
@@ -104,7 +395,6 @@ export function readConnectorHeader(req, headerNames = DEFAULT_HEADER_NAMES) {
|
|
|
104
395
|
}
|
|
105
396
|
|
|
106
397
|
export async function markdownToHtml(markdown) {
|
|
107
|
-
const { marked } = await import("marked")
|
|
108
398
|
return marked.parse(String(markdown ?? ""))
|
|
109
399
|
}
|
|
110
400
|
|
|
@@ -113,38 +403,187 @@ export async function convertContent(markdown, mode, req) {
|
|
|
113
403
|
const html = await markdownToHtml(markdown)
|
|
114
404
|
if (mode === "html") return html
|
|
115
405
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
optionalImport("jsdom"),
|
|
120
|
-
])
|
|
406
|
+
const { convertHtmlToLexicalContent } = await import("./lexical.js")
|
|
407
|
+
return convertHtmlToLexicalContent(html, req)
|
|
408
|
+
}
|
|
121
409
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
410
|
+
function taxonomyInput(input, config) {
|
|
411
|
+
for (const name of config.inputFields ?? []) {
|
|
412
|
+
const terms = normalizeTermList(input[name])
|
|
413
|
+
if (terms.length > 0) return terms
|
|
414
|
+
}
|
|
415
|
+
return []
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function findOrCreateTaxonomyDoc(term, config, collection, req) {
|
|
419
|
+
if (term.id) return term.id
|
|
420
|
+
|
|
421
|
+
const label = term.label ?? term.slug
|
|
422
|
+
const slug = term.slug ?? slugify(label)
|
|
423
|
+
const matchFields = existingMatchFields(req, collection, config.matchFields ?? [])
|
|
424
|
+
const where = {
|
|
425
|
+
or: matchFields.flatMap((field) => {
|
|
426
|
+
const values = field === config.slugField ? [slug, label] : [label, slug]
|
|
427
|
+
return [...new Set(values.filter(Boolean))].map((value) => ({
|
|
428
|
+
[field]: { equals: value },
|
|
429
|
+
}))
|
|
125
430
|
}),
|
|
126
|
-
|
|
127
|
-
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (where.or.length > 0) {
|
|
434
|
+
const result = await req.payload.find({
|
|
435
|
+
collection,
|
|
436
|
+
where,
|
|
437
|
+
depth: 0,
|
|
438
|
+
limit: 1,
|
|
439
|
+
overrideAccess: true,
|
|
440
|
+
})
|
|
441
|
+
const existing = result.docs?.[0]
|
|
442
|
+
if (existing) return existing.id ?? existing._id
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!config.create) return undefined
|
|
446
|
+
|
|
447
|
+
const data =
|
|
448
|
+
typeof config.createData === "function"
|
|
449
|
+
? await config.createData({ label, slug, term }, req)
|
|
450
|
+
: {}
|
|
451
|
+
setPath(data, config.labelField, label)
|
|
452
|
+
setPath(data, config.slugField, slug)
|
|
453
|
+
|
|
454
|
+
const doc = await req.payload.create({
|
|
455
|
+
collection,
|
|
456
|
+
data,
|
|
457
|
+
depth: 0,
|
|
458
|
+
overrideAccess: true,
|
|
128
459
|
})
|
|
460
|
+
return doc.id ?? doc._id
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function existingMatchFields(req, collection, matchFields) {
|
|
464
|
+
const collectionConfig = getCollectionConfig(req, collection)
|
|
465
|
+
const unique = [...new Set(matchFields.filter(Boolean))]
|
|
466
|
+
if (!collectionConfig) return unique
|
|
467
|
+
return unique.filter(
|
|
468
|
+
(field) =>
|
|
469
|
+
field === "id" ||
|
|
470
|
+
field === "_id" ||
|
|
471
|
+
Boolean(getFieldConfig(req, collection, field)),
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function resolveTaxonomyValues(input, options, req, key, fieldConfig) {
|
|
476
|
+
const config = options.taxonomies[key]
|
|
477
|
+
if (!config) return undefined
|
|
478
|
+
const terms = taxonomyInput(input, config)
|
|
479
|
+
if (terms.length === 0) return undefined
|
|
480
|
+
|
|
481
|
+
if (fieldExpectsRelationship(fieldConfig)) {
|
|
482
|
+
const relationTo = Array.isArray(fieldConfig.relationTo)
|
|
483
|
+
? fieldConfig.relationTo[0]
|
|
484
|
+
: fieldConfig.relationTo
|
|
485
|
+
const collection = config.collection ?? relationTo
|
|
486
|
+
if (!collection) return undefined
|
|
487
|
+
|
|
488
|
+
const ids = []
|
|
489
|
+
for (const term of terms) {
|
|
490
|
+
const id = await findOrCreateTaxonomyDoc(term, config, collection, req)
|
|
491
|
+
if (id) ids.push(id)
|
|
492
|
+
}
|
|
493
|
+
if (ids.length === 0) return undefined
|
|
494
|
+
return fieldConfig.hasMany === false ? ids[0] : ids
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const values = terms.map((term) => term.slug ?? term.label).filter(Boolean)
|
|
498
|
+
if (fieldConfig?.hasMany === false) return values[0]
|
|
499
|
+
return values
|
|
129
500
|
}
|
|
130
501
|
|
|
131
502
|
export async function buildPostData(input, options, req) {
|
|
132
503
|
const fields = normalizeFields(options.fields)
|
|
133
504
|
const statuses = { ...DEFAULT_STATUSES, ...(options.statuses ?? {}) }
|
|
505
|
+
const media = normalizeMedia(options.media)
|
|
506
|
+
const taxonomies = normalizeTaxonomies(options.taxonomies)
|
|
507
|
+
const resolvedOptions = { ...options, taxonomies }
|
|
134
508
|
const contentMode = options.contentMode ?? "lexical"
|
|
135
|
-
const
|
|
509
|
+
const uploadCache = new Map()
|
|
510
|
+
let markdown
|
|
511
|
+
try {
|
|
512
|
+
markdown = await rewriteInlineImages(
|
|
513
|
+
input.content ?? input.body ?? "",
|
|
514
|
+
media,
|
|
515
|
+
req,
|
|
516
|
+
uploadCache,
|
|
517
|
+
)
|
|
518
|
+
} catch (error) {
|
|
519
|
+
throw wrapStepError("Media-Upload/Inline-Bilder", error)
|
|
520
|
+
}
|
|
136
521
|
const data = {}
|
|
522
|
+
const featuredImageField = getFieldConfig(
|
|
523
|
+
req,
|
|
524
|
+
options.postsCollection,
|
|
525
|
+
fields.featuredImageUrl,
|
|
526
|
+
)
|
|
527
|
+
let featuredImage
|
|
528
|
+
try {
|
|
529
|
+
featuredImage = input.featuredImage
|
|
530
|
+
? fieldExpectsUpload(featuredImageField) && media.uploadFeaturedImage
|
|
531
|
+
? await getUploadedImage(input.featuredImage, media, req, uploadCache, {
|
|
532
|
+
alt: input.imageAlt ?? input.title,
|
|
533
|
+
title: input.title,
|
|
534
|
+
})
|
|
535
|
+
: { url: input.featuredImage }
|
|
536
|
+
: null
|
|
537
|
+
} catch (error) {
|
|
538
|
+
throw wrapStepError("Media-Upload/Beitragsbild", error)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
let categories
|
|
542
|
+
let tags
|
|
543
|
+
try {
|
|
544
|
+
categories = await resolveTaxonomyValues(
|
|
545
|
+
input,
|
|
546
|
+
resolvedOptions,
|
|
547
|
+
req,
|
|
548
|
+
"categories",
|
|
549
|
+
getFieldConfig(req, options.postsCollection, fields.categories),
|
|
550
|
+
)
|
|
551
|
+
tags = await resolveTaxonomyValues(
|
|
552
|
+
input,
|
|
553
|
+
resolvedOptions,
|
|
554
|
+
req,
|
|
555
|
+
"tags",
|
|
556
|
+
getFieldConfig(req, options.postsCollection, fields.tags),
|
|
557
|
+
)
|
|
558
|
+
} catch (error) {
|
|
559
|
+
throw wrapStepError("Taxonomie-Mapping", error)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let content
|
|
563
|
+
try {
|
|
564
|
+
content = await convertContent(markdown, contentMode, req)
|
|
565
|
+
} catch (error) {
|
|
566
|
+
throw wrapStepError("RichText-Konvertierung", error)
|
|
567
|
+
}
|
|
137
568
|
|
|
138
569
|
setPath(data, fields.title, input.title)
|
|
139
|
-
setPath(data, fields.content,
|
|
570
|
+
setPath(data, fields.content, content)
|
|
140
571
|
setPath(data, fields.excerpt, input.excerpt ?? extractExcerpt(markdown))
|
|
141
572
|
setPath(data, fields.slug, input.slug)
|
|
142
573
|
setPath(data, fields.metaTitle, input.metaTitle)
|
|
143
574
|
setPath(data, fields.metaDescription, input.metaDescription)
|
|
144
575
|
setPath(data, fields.focusKeyword, input.focusKeyword)
|
|
145
|
-
setPath(
|
|
576
|
+
setPath(
|
|
577
|
+
data,
|
|
578
|
+
fields.featuredImageUrl,
|
|
579
|
+
fieldExpectsUpload(featuredImageField)
|
|
580
|
+
? featuredImage?.id
|
|
581
|
+
: featuredImage?.url,
|
|
582
|
+
)
|
|
146
583
|
setPath(data, fields.imageAlt, input.imageAlt)
|
|
147
584
|
setPath(data, fields.contentAssets, input.contentAssets)
|
|
585
|
+
setPath(data, fields.categories, categories)
|
|
586
|
+
setPath(data, fields.tags, tags)
|
|
148
587
|
setPath(data, fields.status, statuses.draft)
|
|
149
588
|
|
|
150
589
|
return typeof options.mapCreateData === "function"
|