@mrtimmy/payload-connector 0.2.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:
@@ -87,10 +102,12 @@ mrTimmyPayloadConnector({
87
102
  categories: {
88
103
  collection: "categories",
89
104
  create: true,
105
+ matchFields: ["slug", "title"],
90
106
  },
91
107
  tags: {
92
108
  collection: "tags",
93
109
  create: true,
110
+ matchFields: ["slug", "title"],
94
111
  },
95
112
  },
96
113
  })
@@ -146,8 +163,9 @@ The connector accepts category/tag input from fields like `categories`,
146
163
  `categoryNames`, `categorySlugs`, `tags`, `tagNames`, and `tagSlugs`.
147
164
 
148
165
  If the target Payload fields are relationship fields, the connector looks up
149
- matching documents by `slug`, `title`, or `name`. Missing terms are created by
150
- default.
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.
151
169
 
152
170
  ```ts
153
171
  mrTimmyPayloadConnector({
@@ -160,12 +178,14 @@ mrTimmyPayloadConnector({
160
178
  collection: "categories",
161
179
  labelField: "title",
162
180
  slugField: "slug",
181
+ matchFields: ["slug", "title"],
163
182
  create: true,
164
183
  },
165
184
  tags: {
166
185
  collection: "tags",
167
186
  labelField: "title",
168
187
  slugField: "slug",
188
+ matchFields: ["slug", "title"],
169
189
  create: true,
170
190
  },
171
191
  },
@@ -181,3 +201,6 @@ If your taxonomy collections use another required field, provide `createData`.
181
201
  - Draft/publish defaults to `_status: "draft" | "published"`, matching Payload draft-enabled collections.
182
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`.
183
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.2.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.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.2.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
@@ -2,8 +2,7 @@ import crypto from "node:crypto"
2
2
  import fs from "node:fs/promises"
3
3
  import os from "node:os"
4
4
  import path from "node:path"
5
-
6
- const optionalImport = (specifier) => import(/* @vite-ignore */ specifier)
5
+ import { marked } from "marked"
7
6
 
8
7
  export const DEFAULT_ENDPOINT_PATH = "/mrtimmy/create-post"
9
8
  export const DEFAULT_KEYS_COLLECTION = "mrtimmy-connector-keys"
@@ -47,17 +46,42 @@ export const DEFAULT_TAXONOMIES = {
47
46
  inputFields: ["categories", "categoryNames", "categorySlugs"],
48
47
  labelField: "title",
49
48
  slugField: "slug",
50
- matchFields: ["slug", "title", "name"],
49
+ matchFields: ["slug", "title"],
51
50
  },
52
51
  tags: {
53
52
  create: true,
54
53
  inputFields: ["tags", "tagNames", "tagSlugs"],
55
54
  labelField: "title",
56
55
  slugField: "slug",
57
- matchFields: ["slug", "title", "name"],
56
+ matchFields: ["slug", "title"],
58
57
  },
59
58
  }
60
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
+
61
85
  export function generateConnectorKey(prefix = "timmy") {
62
86
  return `${prefix}_${crypto.randomBytes(32).toString("hex")}`
63
87
  }
@@ -371,7 +395,6 @@ export function readConnectorHeader(req, headerNames = DEFAULT_HEADER_NAMES) {
371
395
  }
372
396
 
373
397
  export async function markdownToHtml(markdown) {
374
- const { marked } = await import("marked")
375
398
  return marked.parse(String(markdown ?? ""))
376
399
  }
377
400
 
@@ -380,19 +403,8 @@ export async function convertContent(markdown, mode, req) {
380
403
  const html = await markdownToHtml(markdown)
381
404
  if (mode === "html") return html
382
405
 
383
- const [{ convertHTMLToLexical, editorConfigFactory }, { JSDOM }] =
384
- await Promise.all([
385
- optionalImport("@payloadcms/richtext-lexical"),
386
- optionalImport("jsdom"),
387
- ])
388
-
389
- return convertHTMLToLexical({
390
- editorConfig: await editorConfigFactory.default({
391
- config: req.payload.config,
392
- }),
393
- html,
394
- JSDOM,
395
- })
406
+ const { convertHtmlToLexicalContent } = await import("./lexical.js")
407
+ return convertHtmlToLexicalContent(html, req)
396
408
  }
397
409
 
398
410
  function taxonomyInput(input, config) {
@@ -408,7 +420,7 @@ async function findOrCreateTaxonomyDoc(term, config, collection, req) {
408
420
 
409
421
  const label = term.label ?? term.slug
410
422
  const slug = term.slug ?? slugify(label)
411
- const matchFields = config.matchFields ?? []
423
+ const matchFields = existingMatchFields(req, collection, config.matchFields ?? [])
412
424
  const where = {
413
425
  or: matchFields.flatMap((field) => {
414
426
  const values = field === config.slugField ? [slug, label] : [label, slug]
@@ -448,6 +460,18 @@ async function findOrCreateTaxonomyDoc(term, config, collection, req) {
448
460
  return doc.id ?? doc._id
449
461
  }
450
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
+
451
475
  async function resolveTaxonomyValues(input, options, req, key, fieldConfig) {
452
476
  const config = options.taxonomies[key]
453
477
  if (!config) return undefined
@@ -483,43 +507,67 @@ export async function buildPostData(input, options, req) {
483
507
  const resolvedOptions = { ...options, taxonomies }
484
508
  const contentMode = options.contentMode ?? "lexical"
485
509
  const uploadCache = new Map()
486
- const markdown = await rewriteInlineImages(
487
- input.content ?? input.body ?? "",
488
- media,
489
- req,
490
- uploadCache,
491
- )
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
+ }
492
521
  const data = {}
493
522
  const featuredImageField = getFieldConfig(
494
523
  req,
495
524
  options.postsCollection,
496
525
  fields.featuredImageUrl,
497
526
  )
498
- const featuredImage = input.featuredImage
499
- ? fieldExpectsUpload(featuredImageField) && media.uploadFeaturedImage
500
- ? await getUploadedImage(input.featuredImage, media, req, uploadCache, {
501
- alt: input.imageAlt ?? input.title,
502
- title: input.title,
503
- })
504
- : { url: input.featuredImage }
505
- : null
506
- const categories = await resolveTaxonomyValues(
507
- input,
508
- resolvedOptions,
509
- req,
510
- "categories",
511
- getFieldConfig(req, options.postsCollection, fields.categories),
512
- )
513
- const tags = await resolveTaxonomyValues(
514
- input,
515
- resolvedOptions,
516
- req,
517
- "tags",
518
- getFieldConfig(req, options.postsCollection, fields.tags),
519
- )
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
+ }
520
568
 
521
569
  setPath(data, fields.title, input.title)
522
- setPath(data, fields.content, await convertContent(markdown, contentMode, req))
570
+ setPath(data, fields.content, content)
523
571
  setPath(data, fields.excerpt, input.excerpt ?? extractExcerpt(markdown))
524
572
  setPath(data, fields.slug, input.slug)
525
573
  setPath(data, fields.metaTitle, input.metaTitle)