@mrtimmy/payload-connector 0.1.0 → 0.2.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 CHANGED
@@ -63,6 +63,11 @@ x-api-key: timmy_xxx
63
63
  mrTimmyPayloadConnector({
64
64
  postsCollection: "posts",
65
65
  contentMode: "lexical",
66
+ media: {
67
+ collection: "media",
68
+ uploadFeaturedImage: true,
69
+ uploadInlineImages: true,
70
+ },
66
71
  fields: {
67
72
  title: "title",
68
73
  content: "content",
@@ -75,6 +80,18 @@ mrTimmyPayloadConnector({
75
80
  featuredImageUrl: "featuredImage",
76
81
  imageAlt: "imageAlt",
77
82
  contentAssets: "contentAssets",
83
+ categories: "categories",
84
+ tags: "tags",
85
+ },
86
+ taxonomies: {
87
+ categories: {
88
+ collection: "categories",
89
+ create: true,
90
+ },
91
+ tags: {
92
+ collection: "tags",
93
+ create: true,
94
+ },
78
95
  },
79
96
  })
80
97
  ```
@@ -87,10 +104,80 @@ mrTimmyPayloadConnector({
87
104
 
88
105
  If your Payload post schema uses different field names, override `fields`.
89
106
 
107
+ ## Media Uploads
108
+
109
+ If the configured `featuredImageUrl` field points to a Payload `upload` field,
110
+ the connector downloads the incoming image URL, uploads it to the configured
111
+ media collection, and stores the new media document ID on the post.
112
+
113
+ ```ts
114
+ mrTimmyPayloadConnector({
115
+ media: {
116
+ collection: "media",
117
+ fields: {
118
+ alt: "alt",
119
+ title: "title",
120
+ },
121
+ },
122
+ fields: {
123
+ featuredImageUrl: "featuredImage",
124
+ },
125
+ })
126
+ ```
127
+
128
+ If `featuredImageUrl` is a plain text/URL field, the connector keeps the old
129
+ behavior and stores the image URL instead.
130
+
131
+ Inline Markdown images in the article body are also uploaded to the media
132
+ collection when possible. Their URLs are rewritten before the content is
133
+ converted to HTML or Lexical JSON.
134
+
135
+ To disable media handling:
136
+
137
+ ```ts
138
+ mrTimmyPayloadConnector({
139
+ media: false,
140
+ })
141
+ ```
142
+
143
+ ## Categories and Tags
144
+
145
+ The connector accepts category/tag input from fields like `categories`,
146
+ `categoryNames`, `categorySlugs`, `tags`, `tagNames`, and `tagSlugs`.
147
+
148
+ 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.
151
+
152
+ ```ts
153
+ mrTimmyPayloadConnector({
154
+ fields: {
155
+ categories: "categories",
156
+ tags: "tags",
157
+ },
158
+ taxonomies: {
159
+ categories: {
160
+ collection: "categories",
161
+ labelField: "title",
162
+ slugField: "slug",
163
+ create: true,
164
+ },
165
+ tags: {
166
+ collection: "tags",
167
+ labelField: "title",
168
+ slugField: "slug",
169
+ create: true,
170
+ },
171
+ },
172
+ })
173
+ ```
174
+
175
+ If your taxonomy collections use another required field, provide `createData`.
176
+
90
177
  ## Notes
91
178
 
92
179
  - The plugin uses Payload Custom Endpoints and Payload Local API.
93
180
  - Custom endpoints are authenticated by this plugin, not by Payload automatically.
94
181
  - Draft/publish defaults to `_status: "draft" | "published"`, matching Payload draft-enabled collections.
95
182
  - 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.
183
+ - Uploads use Payload's Local API with `filePath`; hooks and storage adapters on your media collection still run.
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.0",
4
4
  "description": "Payload CMS connector for publishing mr.timmy blog posts.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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
@@ -12,7 +12,7 @@ import {
12
12
  readConnectorHeader,
13
13
  } from "./utils.js"
14
14
 
15
- const VERSION = "0.1.0"
15
+ const VERSION = "0.2.0"
16
16
 
17
17
  export function mrTimmyPayloadConnector(pluginOptions = {}) {
18
18
  return (incomingConfig = {}) => {
package/src/utils.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import crypto from "node:crypto"
2
+ import fs from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
2
5
 
3
6
  const optionalImport = (specifier) => import(/* @vite-ignore */ specifier)
4
7
 
@@ -18,6 +21,8 @@ export const DEFAULT_FIELDS = {
18
21
  featuredImageUrl: "featuredImage",
19
22
  imageAlt: "imageAlt",
20
23
  contentAssets: "contentAssets",
24
+ categories: "categories",
25
+ tags: "tags",
21
26
  }
22
27
 
23
28
  export const DEFAULT_STATUSES = {
@@ -25,6 +30,34 @@ export const DEFAULT_STATUSES = {
25
30
  published: "published",
26
31
  }
27
32
 
33
+ export const DEFAULT_MEDIA = {
34
+ collection: "media",
35
+ uploadFeaturedImage: true,
36
+ uploadInlineImages: true,
37
+ maxBytes: 15 * 1024 * 1024,
38
+ fields: {
39
+ alt: "alt",
40
+ title: "title",
41
+ },
42
+ }
43
+
44
+ export const DEFAULT_TAXONOMIES = {
45
+ categories: {
46
+ create: true,
47
+ inputFields: ["categories", "categoryNames", "categorySlugs"],
48
+ labelField: "title",
49
+ slugField: "slug",
50
+ matchFields: ["slug", "title", "name"],
51
+ },
52
+ tags: {
53
+ create: true,
54
+ inputFields: ["tags", "tagNames", "tagSlugs"],
55
+ labelField: "title",
56
+ slugField: "slug",
57
+ matchFields: ["slug", "title", "name"],
58
+ },
59
+ }
60
+
28
61
  export function generateConnectorKey(prefix = "timmy") {
29
62
  return `${prefix}_${crypto.randomBytes(32).toString("hex")}`
30
63
  }
@@ -50,6 +83,38 @@ export function normalizeFields(fields = {}) {
50
83
  return { ...DEFAULT_FIELDS, ...fields }
51
84
  }
52
85
 
86
+ export function normalizeMedia(media = {}) {
87
+ if (media === false) return { enabled: false }
88
+ return {
89
+ enabled: true,
90
+ ...DEFAULT_MEDIA,
91
+ ...media,
92
+ fields: {
93
+ ...DEFAULT_MEDIA.fields,
94
+ ...(media.fields ?? {}),
95
+ },
96
+ }
97
+ }
98
+
99
+ export function normalizeTaxonomies(taxonomies = {}) {
100
+ return {
101
+ categories:
102
+ taxonomies.categories === false
103
+ ? false
104
+ : {
105
+ ...DEFAULT_TAXONOMIES.categories,
106
+ ...(taxonomies.categories ?? {}),
107
+ },
108
+ tags:
109
+ taxonomies.tags === false
110
+ ? false
111
+ : {
112
+ ...DEFAULT_TAXONOMIES.tags,
113
+ ...(taxonomies.tags ?? {}),
114
+ },
115
+ }
116
+ }
117
+
53
118
  export function setPath(target, path, value) {
54
119
  if (!path || value === undefined || value === null) return target
55
120
  const parts = String(path).split(".").filter(Boolean)
@@ -69,6 +134,81 @@ export function setPath(target, path, value) {
69
134
  return target
70
135
  }
71
136
 
137
+ export function slugify(value = "") {
138
+ return String(value)
139
+ .trim()
140
+ .toLowerCase()
141
+ .normalize("NFKD")
142
+ .replace(/[\u0300-\u036f]/g, "")
143
+ .replace(/ä/g, "ae")
144
+ .replace(/ö/g, "oe")
145
+ .replace(/ü/g, "ue")
146
+ .replace(/ß/g, "ss")
147
+ .replace(/[^a-z0-9]+/g, "-")
148
+ .replace(/^-+|-+$/g, "")
149
+ }
150
+
151
+ export function normalizeTermList(value) {
152
+ const values = Array.isArray(value) ? value : value ? [value] : []
153
+ return values
154
+ .map((entry) => {
155
+ if (entry && typeof entry === "object") {
156
+ const id = entry.id ?? entry._id
157
+ const label = entry.label ?? entry.title ?? entry.name ?? entry.slug
158
+ const slug = entry.slug ?? (label ? slugify(label) : undefined)
159
+ return id || label || slug ? { id, label, slug } : null
160
+ }
161
+ const label = String(entry ?? "").trim()
162
+ return label ? { label, slug: slugify(label) } : null
163
+ })
164
+ .filter(Boolean)
165
+ }
166
+
167
+ export function getCollectionConfig(req, slug) {
168
+ return req?.payload?.config?.collections?.find(
169
+ (collection) => collection?.slug === slug,
170
+ )
171
+ }
172
+
173
+ function findFieldInFields(fields = [], parts = []) {
174
+ for (const field of fields) {
175
+ if (!field) continue
176
+
177
+ if (field.type === "tabs") {
178
+ for (const tab of field.tabs ?? []) {
179
+ const found = findFieldInFields(tab.fields ?? [], parts)
180
+ if (found) return found
181
+ }
182
+ continue
183
+ }
184
+
185
+ if (!field.name) {
186
+ const found = findFieldInFields(field.fields ?? [], parts)
187
+ if (found) return found
188
+ continue
189
+ }
190
+
191
+ if (field.name !== parts[0]) continue
192
+ if (parts.length === 1) return field
193
+ return findFieldInFields(field.fields ?? [], parts.slice(1))
194
+ }
195
+ return undefined
196
+ }
197
+
198
+ export function getFieldConfig(req, collectionSlug, fieldPath) {
199
+ if (!fieldPath) return undefined
200
+ const collection = getCollectionConfig(req, collectionSlug)
201
+ return findFieldInFields(collection?.fields ?? [], String(fieldPath).split("."))
202
+ }
203
+
204
+ export function fieldExpectsUpload(field) {
205
+ return field?.type === "upload"
206
+ }
207
+
208
+ export function fieldExpectsRelationship(field) {
209
+ return field?.type === "relationship"
210
+ }
211
+
72
212
  export function extractExcerpt(markdown = "") {
73
213
  const lines = String(markdown).split("\n")
74
214
  for (const line of lines) {
@@ -95,6 +235,133 @@ export function buildApiUrlHint(req, endpointPath) {
95
235
  return origin ? `${origin}/api${endpointPath}` : `/api${endpointPath}`
96
236
  }
97
237
 
238
+ function absolutizeUrl(url, req) {
239
+ if (!url || typeof url !== "string") return url
240
+ if (/^https?:\/\//i.test(url)) return url
241
+ if (!url.startsWith("/")) return url
242
+ const origin = buildOriginFromRequest(req)
243
+ return origin ? `${origin}${url}` : url
244
+ }
245
+
246
+ function isHttpUrl(value) {
247
+ return /^https?:\/\//i.test(String(value ?? ""))
248
+ }
249
+
250
+ function extensionFor(contentType, sourceUrl) {
251
+ const fromType = {
252
+ "image/jpeg": ".jpg",
253
+ "image/jpg": ".jpg",
254
+ "image/png": ".png",
255
+ "image/webp": ".webp",
256
+ "image/gif": ".gif",
257
+ "image/avif": ".avif",
258
+ "image/svg+xml": ".svg",
259
+ }[String(contentType ?? "").split(";")[0].trim().toLowerCase()]
260
+ if (fromType) return fromType
261
+ const ext = path.extname(new URL(sourceUrl).pathname)
262
+ return ext || ".jpg"
263
+ }
264
+
265
+ function safeFileBase(value) {
266
+ const base = slugify(value) || `mrtimmy-${Date.now()}`
267
+ return base.slice(0, 80)
268
+ }
269
+
270
+ async function downloadImageToTempFile(sourceUrl, media) {
271
+ const response = await fetch(sourceUrl)
272
+ if (!response.ok) {
273
+ throw new Error(`Bild konnte nicht geladen werden: ${response.status}`)
274
+ }
275
+
276
+ const contentType =
277
+ response.headers.get("content-type") ?? "application/octet-stream"
278
+ if (!contentType.toLowerCase().startsWith("image/")) {
279
+ throw new Error(`URL ist kein Bild: ${contentType}`)
280
+ }
281
+
282
+ const buffer = Buffer.from(await response.arrayBuffer())
283
+ if (buffer.byteLength > media.maxBytes) {
284
+ throw new Error(
285
+ `Bild ist zu groß (${buffer.byteLength} Bytes, Limit ${media.maxBytes})`,
286
+ )
287
+ }
288
+
289
+ const extension = extensionFor(contentType, sourceUrl)
290
+ const filePath = path.join(
291
+ os.tmpdir(),
292
+ `${safeFileBase(path.basename(new URL(sourceUrl).pathname, extension))}-${crypto.randomBytes(8).toString("hex")}${extension}`,
293
+ )
294
+ await fs.writeFile(filePath, buffer)
295
+ return { filePath, contentType }
296
+ }
297
+
298
+ function buildMediaData(media, alt, title) {
299
+ const data = {}
300
+ setPath(data, media.fields.alt, alt)
301
+ setPath(data, media.fields.title, title)
302
+ return data
303
+ }
304
+
305
+ export async function uploadImageFromUrl(sourceUrl, media, req, meta = {}) {
306
+ if (!media.enabled || !isHttpUrl(sourceUrl)) return null
307
+ if (!getCollectionConfig(req, media.collection)?.upload) return null
308
+
309
+ const { filePath } = await downloadImageToTempFile(sourceUrl, media)
310
+ try {
311
+ const doc = await req.payload.create({
312
+ collection: media.collection,
313
+ data: buildMediaData(media, meta.alt, meta.title),
314
+ filePath,
315
+ depth: 0,
316
+ overrideAccess: true,
317
+ })
318
+ return {
319
+ id: doc.id ?? doc._id,
320
+ url: absolutizeUrl(doc.url, req),
321
+ doc,
322
+ originalUrl: sourceUrl,
323
+ }
324
+ } finally {
325
+ await fs.unlink(filePath).catch(() => {})
326
+ }
327
+ }
328
+
329
+ export function extractMarkdownImages(markdown = "") {
330
+ const images = []
331
+ String(markdown).replace(
332
+ /!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g,
333
+ (match, alt, url) => {
334
+ if (isHttpUrl(url)) images.push({ match, alt, url })
335
+ return match
336
+ },
337
+ )
338
+ return images
339
+ }
340
+
341
+ async function rewriteInlineImages(markdown, media, req, cache) {
342
+ if (!media.enabled || !media.uploadInlineImages) return markdown
343
+ const images = extractMarkdownImages(markdown)
344
+ if (images.length === 0) return markdown
345
+
346
+ let next = String(markdown)
347
+ for (const image of images) {
348
+ const asset = await getUploadedImage(image.url, media, req, cache, {
349
+ alt: image.alt,
350
+ title: image.alt,
351
+ })
352
+ if (!asset?.url) continue
353
+ next = next.replace(image.url, asset.url)
354
+ }
355
+ return next
356
+ }
357
+
358
+ async function getUploadedImage(sourceUrl, media, req, cache, meta) {
359
+ if (!cache.has(sourceUrl)) {
360
+ cache.set(sourceUrl, uploadImageFromUrl(sourceUrl, media, req, meta))
361
+ }
362
+ return cache.get(sourceUrl)
363
+ }
364
+
98
365
  export function readConnectorHeader(req, headerNames = DEFAULT_HEADER_NAMES) {
99
366
  for (const name of headerNames) {
100
367
  const value = req?.headers?.get?.(name)
@@ -128,12 +395,128 @@ export async function convertContent(markdown, mode, req) {
128
395
  })
129
396
  }
130
397
 
398
+ function taxonomyInput(input, config) {
399
+ for (const name of config.inputFields ?? []) {
400
+ const terms = normalizeTermList(input[name])
401
+ if (terms.length > 0) return terms
402
+ }
403
+ return []
404
+ }
405
+
406
+ async function findOrCreateTaxonomyDoc(term, config, collection, req) {
407
+ if (term.id) return term.id
408
+
409
+ const label = term.label ?? term.slug
410
+ const slug = term.slug ?? slugify(label)
411
+ const matchFields = config.matchFields ?? []
412
+ const where = {
413
+ or: matchFields.flatMap((field) => {
414
+ const values = field === config.slugField ? [slug, label] : [label, slug]
415
+ return [...new Set(values.filter(Boolean))].map((value) => ({
416
+ [field]: { equals: value },
417
+ }))
418
+ }),
419
+ }
420
+
421
+ if (where.or.length > 0) {
422
+ const result = await req.payload.find({
423
+ collection,
424
+ where,
425
+ depth: 0,
426
+ limit: 1,
427
+ overrideAccess: true,
428
+ })
429
+ const existing = result.docs?.[0]
430
+ if (existing) return existing.id ?? existing._id
431
+ }
432
+
433
+ if (!config.create) return undefined
434
+
435
+ const data =
436
+ typeof config.createData === "function"
437
+ ? await config.createData({ label, slug, term }, req)
438
+ : {}
439
+ setPath(data, config.labelField, label)
440
+ setPath(data, config.slugField, slug)
441
+
442
+ const doc = await req.payload.create({
443
+ collection,
444
+ data,
445
+ depth: 0,
446
+ overrideAccess: true,
447
+ })
448
+ return doc.id ?? doc._id
449
+ }
450
+
451
+ async function resolveTaxonomyValues(input, options, req, key, fieldConfig) {
452
+ const config = options.taxonomies[key]
453
+ if (!config) return undefined
454
+ const terms = taxonomyInput(input, config)
455
+ if (terms.length === 0) return undefined
456
+
457
+ if (fieldExpectsRelationship(fieldConfig)) {
458
+ const relationTo = Array.isArray(fieldConfig.relationTo)
459
+ ? fieldConfig.relationTo[0]
460
+ : fieldConfig.relationTo
461
+ const collection = config.collection ?? relationTo
462
+ if (!collection) return undefined
463
+
464
+ const ids = []
465
+ for (const term of terms) {
466
+ const id = await findOrCreateTaxonomyDoc(term, config, collection, req)
467
+ if (id) ids.push(id)
468
+ }
469
+ if (ids.length === 0) return undefined
470
+ return fieldConfig.hasMany === false ? ids[0] : ids
471
+ }
472
+
473
+ const values = terms.map((term) => term.slug ?? term.label).filter(Boolean)
474
+ if (fieldConfig?.hasMany === false) return values[0]
475
+ return values
476
+ }
477
+
131
478
  export async function buildPostData(input, options, req) {
132
479
  const fields = normalizeFields(options.fields)
133
480
  const statuses = { ...DEFAULT_STATUSES, ...(options.statuses ?? {}) }
481
+ const media = normalizeMedia(options.media)
482
+ const taxonomies = normalizeTaxonomies(options.taxonomies)
483
+ const resolvedOptions = { ...options, taxonomies }
134
484
  const contentMode = options.contentMode ?? "lexical"
135
- const markdown = input.content ?? input.body ?? ""
485
+ const uploadCache = new Map()
486
+ const markdown = await rewriteInlineImages(
487
+ input.content ?? input.body ?? "",
488
+ media,
489
+ req,
490
+ uploadCache,
491
+ )
136
492
  const data = {}
493
+ const featuredImageField = getFieldConfig(
494
+ req,
495
+ options.postsCollection,
496
+ fields.featuredImageUrl,
497
+ )
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
+ )
137
520
 
138
521
  setPath(data, fields.title, input.title)
139
522
  setPath(data, fields.content, await convertContent(markdown, contentMode, req))
@@ -142,9 +525,17 @@ export async function buildPostData(input, options, req) {
142
525
  setPath(data, fields.metaTitle, input.metaTitle)
143
526
  setPath(data, fields.metaDescription, input.metaDescription)
144
527
  setPath(data, fields.focusKeyword, input.focusKeyword)
145
- setPath(data, fields.featuredImageUrl, input.featuredImage)
528
+ setPath(
529
+ data,
530
+ fields.featuredImageUrl,
531
+ fieldExpectsUpload(featuredImageField)
532
+ ? featuredImage?.id
533
+ : featuredImage?.url,
534
+ )
146
535
  setPath(data, fields.imageAlt, input.imageAlt)
147
536
  setPath(data, fields.contentAssets, input.contentAssets)
537
+ setPath(data, fields.categories, categories)
538
+ setPath(data, fields.tags, tags)
148
539
  setPath(data, fields.status, statuses.draft)
149
540
 
150
541
  return typeof options.mapCreateData === "function"