@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 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
- - Featured images are passed as URL fields in this MVP. Upload-collection integration is project-specific and should be added with a custom mapper later.
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.0",
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.0"
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
- const data = await buildPostData(input, options, req)
173
- const doc = await req.payload.create({
174
- collection: options.postsCollection,
175
- data,
176
- depth: 0,
177
- draft: true,
178
- overrideAccess: true,
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
- const data = await buildPublishData(options, input, req)
203
- const doc = await req.payload.update({
204
- collection: options.postsCollection,
205
- id,
206
- data,
207
- depth: 0,
208
- draft: false,
209
- overrideAccess: true,
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
- const optionalImport = (specifier) => import(/* @vite-ignore */ specifier)
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 [{ convertHTMLToLexical, editorConfigFactory }, { JSDOM }] =
117
- await Promise.all([
118
- optionalImport("@payloadcms/richtext-lexical"),
119
- optionalImport("jsdom"),
120
- ])
406
+ const { convertHtmlToLexicalContent } = await import("./lexical.js")
407
+ return convertHtmlToLexicalContent(html, req)
408
+ }
121
409
 
122
- return convertHTMLToLexical({
123
- editorConfig: await editorConfigFactory.default({
124
- config: req.payload.config,
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
- html,
127
- JSDOM,
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 markdown = input.content ?? input.body ?? ""
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, await convertContent(markdown, contentMode, req))
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(data, fields.featuredImageUrl, input.featuredImage)
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"