@bison-lab/payload-core 3.10.0 → 3.12.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 +57 -25
- package/dist/admin.d.mts +41 -14
- package/dist/admin.d.mts.map +1 -1
- package/dist/admin.mjs +973 -461
- package/dist/admin.mjs.map +1 -1
- package/dist/identity-DZOb3_Gk.mjs +383 -0
- package/dist/identity-DZOb3_Gk.mjs.map +1 -0
- package/dist/index.d.mts +278 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +939 -188
- package/dist/index.mjs.map +1 -1
- package/dist/{map-BUDkX8g1.mjs → looks-BbL309kO.mjs} +94 -6
- package/dist/looks-BbL309kO.mjs.map +1 -0
- package/dist/{library-BGq4wGif.d.mts → looks-CLxXwASa.d.mts} +64 -11
- package/dist/looks-CLxXwASa.d.mts.map +1 -0
- package/dist/react.mjs +28 -3
- package/dist/react.mjs.map +1 -1
- package/dist/theme.d.mts +11 -2
- package/dist/theme.d.mts.map +1 -1
- package/dist/theme.mjs +40 -4
- package/dist/theme.mjs.map +1 -1
- package/package.json +4 -4
- package/dist/identity-Bg-G05mb.mjs +0 -199
- package/dist/identity-Bg-G05mb.mjs.map +0 -1
- package/dist/library-BGq4wGif.d.mts.map +0 -1
- package/dist/map-BUDkX8g1.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { n as documentTitle, r as titleTemplate, t as SHARE_IMAGE_SIZE } from "./share-image-C4ILz4p2.mjs";
|
|
2
|
-
import { C as headingSelectValue, S as
|
|
2
|
+
import { A as THEME_IDENTITY_FALLBACK, C as THEME_APPEARANCE_FIELD, D as THEME_DOCUMENT_CONTROLS, E as THEME_CONTRAST_REPORT, F as THEME_SECTION_HEADING, I as headingSelectValue, M as THEME_PAIRING_FIELD, N as THEME_PUBLISH_FIELD, O as THEME_FONT_FIELD, P as THEME_SAVE_BUTTON, S as LOOK_FIELD, T as THEME_COLOR_SCALE_FIELD, _ as docFromConfig, a as pageEditorLooks, b as validateSourceIncluded, c as deleteLibraryColor, d as SYSTEM_COLOR_KEYS, f as THEME_APPEARANCE_SLUG, g as THEME_TYPOGRAPHY_SLUG, h as THEME_SLUG, i as PAGE_EDITOR_SYSTEM_KEYS, j as THEME_LIBRARY_FIELD, k as THEME_GREY_SCALE_FIELD, l as rewriteColorToken, m as THEME_IDENTITY_SLUG, n as colorTokenField, o as pageEditorTokens, p as THEME_COLORS_SLUG, r as lookField, s as themeLibraryFromDoc, t as resolveThemeIdentity, u as themeColorKeys, w as THEME_COLOR_FIELD, x as validateThemeHex, y as themeConfigFromDoc } from "./identity-DZOb3_Gk.mjs";
|
|
3
3
|
import { seoPlugin as seoPlugin$1 } from "@payloadcms/plugin-seo";
|
|
4
4
|
import { catalog } from "@bison-lab/fonts";
|
|
5
|
-
import { SHADE_STEPS, presetHints } from "@bison-lab/tokens";
|
|
5
|
+
import { DESTRUCTIVE_SCALE_HEX, SHADE_STEPS, presetHints } from "@bison-lab/tokens";
|
|
6
|
+
import { APIError, ValidationError, definePlugin, validations } from "payload";
|
|
7
|
+
import { select, text } from "payload/shared";
|
|
6
8
|
//#region src/seo/fields.ts
|
|
7
9
|
/**
|
|
8
10
|
* The index switch, last in the SEO tab. Off by default: a page is public
|
|
@@ -163,12 +165,6 @@ const APPEARANCE_CHOICES = {
|
|
|
163
165
|
};
|
|
164
166
|
//#endregion
|
|
165
167
|
//#region src/theme/publish.ts
|
|
166
|
-
const THEME_CHILDREN = [
|
|
167
|
-
"colors",
|
|
168
|
-
"typography",
|
|
169
|
-
"appearance",
|
|
170
|
-
"identity"
|
|
171
|
-
];
|
|
172
168
|
/**
|
|
173
169
|
* Publishing a child writes that slice only. Other responsibilities stay
|
|
174
170
|
* as they already are on the stored document. Unsaved form state on a
|
|
@@ -196,24 +192,63 @@ function publishThemeChild(stored, incoming, child) {
|
|
|
196
192
|
logoMark: incoming.logoMark
|
|
197
193
|
};
|
|
198
194
|
}
|
|
199
|
-
function
|
|
200
|
-
|
|
195
|
+
function sliceFromTheme(theme, child) {
|
|
196
|
+
if (child === "colors") return { colors: theme.colors };
|
|
197
|
+
if (child === "typography") return { typography: theme.typography };
|
|
198
|
+
if (child === "appearance") return { appearance: theme.appearance };
|
|
199
|
+
return {
|
|
200
|
+
logo: theme.logo,
|
|
201
|
+
favicon: theme.favicon,
|
|
202
|
+
logoMark: theme.logoMark
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function colorHex(color) {
|
|
206
|
+
if (typeof color === "string") return color;
|
|
207
|
+
return color?.hex ?? void 0;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* A Theme page is a real Global. Hydrate from the store only when this
|
|
211
|
+
* page has never been saved — otherwise afterRead would overwrite the form.
|
|
212
|
+
*/
|
|
213
|
+
function hasThemeSlice(doc, child) {
|
|
214
|
+
if (!doc) return false;
|
|
215
|
+
if (child === "colors") return Boolean(colorHex(doc.colors?.brand?.primary));
|
|
216
|
+
if (child === "typography") return Boolean(doc.typography?.body);
|
|
217
|
+
if (child === "appearance") return Boolean(doc.appearance?.radius);
|
|
218
|
+
return Boolean(doc.logo || doc.favicon || doc.logoMark);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Save on a Theme page writes that slice onto the hidden `theme` row.
|
|
222
|
+
* Other children stay as stored.
|
|
223
|
+
*/
|
|
224
|
+
async function persistThemeChild(payload, incoming, child) {
|
|
225
|
+
const next = publishThemeChild(await payload.findGlobal({
|
|
226
|
+
slug: "theme",
|
|
227
|
+
draft: false,
|
|
228
|
+
overrideAccess: true
|
|
229
|
+
}) ?? {}, incoming, child);
|
|
230
|
+
await payload.updateGlobal({
|
|
231
|
+
slug: THEME_SLUG,
|
|
232
|
+
data: next,
|
|
233
|
+
draft: false
|
|
234
|
+
});
|
|
235
|
+
return next;
|
|
201
236
|
}
|
|
202
237
|
//#endregion
|
|
203
238
|
//#region src/theme/global.ts
|
|
239
|
+
/** Same value as tokens `SUCCESS_SCALE_HEX`. Inlined so an older tokens peer still boots. */
|
|
240
|
+
const LIBRARY_SUCCESS_HEX = "#22c55e";
|
|
204
241
|
function requireAccess$1(options) {
|
|
205
242
|
const read = options.access?.read;
|
|
206
243
|
const update = options.access?.update;
|
|
207
|
-
if (!read || !update) throw new Error("createTheme requires access.read and access.update. Pass
|
|
244
|
+
if (!read || !update) throw new Error("createTheme requires access.read and access.update. Pass canManageBrand from this package as update.");
|
|
208
245
|
return {
|
|
209
246
|
read,
|
|
210
247
|
update
|
|
211
248
|
};
|
|
212
249
|
}
|
|
213
250
|
function requireDestructive(options) {
|
|
214
|
-
|
|
215
|
-
if (!hex) throw new Error("createTheme requires a destructive hex: pass options.destructive or seed.brandDestructive. There is no package default.");
|
|
216
|
-
return hex;
|
|
251
|
+
return options.destructive ?? options.seed.brandDestructive ?? DESTRUCTIVE_SCALE_HEX;
|
|
217
252
|
}
|
|
218
253
|
function namedSelect(name, label, choices, defaultValue, field) {
|
|
219
254
|
return {
|
|
@@ -306,7 +341,10 @@ function systemColorGroup(name, label, hexDefault) {
|
|
|
306
341
|
type: "group",
|
|
307
342
|
label,
|
|
308
343
|
fields: colorScaleFields(hexDefault),
|
|
309
|
-
admin: {
|
|
344
|
+
admin: {
|
|
345
|
+
components: { Field: THEME_COLOR_SCALE_FIELD },
|
|
346
|
+
hideGutter: true
|
|
347
|
+
}
|
|
310
348
|
};
|
|
311
349
|
}
|
|
312
350
|
function fontField(name, label, defaultValue, fonts, fontsBaseUrl, sameAsBody) {
|
|
@@ -334,205 +372,265 @@ function fontField(name, label, defaultValue, fonts, fontsBaseUrl, sameAsBody) {
|
|
|
334
372
|
}
|
|
335
373
|
};
|
|
336
374
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
label: "Colors",
|
|
350
|
-
fields: [{
|
|
351
|
-
name: "colors",
|
|
375
|
+
function colorsFields(seed, destructive) {
|
|
376
|
+
return [{
|
|
377
|
+
name: "colors",
|
|
378
|
+
type: "group",
|
|
379
|
+
label: false,
|
|
380
|
+
admin: {
|
|
381
|
+
hideGutter: true,
|
|
382
|
+
width: "100%"
|
|
383
|
+
},
|
|
384
|
+
fields: [
|
|
385
|
+
{
|
|
386
|
+
name: "brand",
|
|
352
387
|
type: "group",
|
|
353
388
|
label: false,
|
|
389
|
+
admin: {
|
|
390
|
+
hideGutter: true,
|
|
391
|
+
width: "100%"
|
|
392
|
+
},
|
|
354
393
|
fields: [
|
|
355
394
|
{
|
|
356
|
-
name: "
|
|
357
|
-
type: "
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
{
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
custom: {
|
|
366
|
-
title: "Theme colors",
|
|
367
|
-
hint: "House semantic fills page editors can pick. Custom colors sit with these."
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
},
|
|
371
|
-
systemColorGroup("primary", "Primary", seed.brandPrimary),
|
|
372
|
-
systemColorGroup("secondary", "Secondary", seed.brandSecondary),
|
|
373
|
-
systemColorGroup("accent", "Accent", seed.brandAccent),
|
|
374
|
-
systemColorGroup("highlight", "Highlight", seed.brandHighlight),
|
|
375
|
-
{
|
|
376
|
-
name: "statusColorsHeading",
|
|
377
|
-
type: "ui",
|
|
378
|
-
admin: {
|
|
379
|
-
components: { Field: THEME_SECTION_HEADING },
|
|
380
|
-
custom: {
|
|
381
|
-
title: "Success and Destructive",
|
|
382
|
-
hint: "Theme chrome only. These are not offered as fills when someone is writing a page."
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
},
|
|
386
|
-
systemColorGroup("success", "Success", seed.brandSuccess),
|
|
387
|
-
systemColorGroup("destructive", "Destructive", destructive)
|
|
388
|
-
]
|
|
395
|
+
name: "themeColorsHeading",
|
|
396
|
+
type: "ui",
|
|
397
|
+
admin: {
|
|
398
|
+
components: { Field: THEME_SECTION_HEADING },
|
|
399
|
+
custom: {
|
|
400
|
+
title: "Brand colors",
|
|
401
|
+
hint: "These colors define the visual identity of the site. Adjust them freely to match the brand."
|
|
402
|
+
}
|
|
403
|
+
}
|
|
389
404
|
},
|
|
390
|
-
|
|
405
|
+
systemColorGroup("primary", "Primary", seed.brandPrimary),
|
|
406
|
+
systemColorGroup("secondary", "Secondary", seed.brandSecondary),
|
|
407
|
+
systemColorGroup("accent", "Accent", seed.brandAccent),
|
|
408
|
+
systemColorGroup("highlight", "Highlight", seed.brandHighlight),
|
|
391
409
|
{
|
|
392
|
-
name: "
|
|
393
|
-
type: "
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
fields: [
|
|
400
|
-
{
|
|
401
|
-
name: "key",
|
|
402
|
-
type: "text",
|
|
403
|
-
label: "Key",
|
|
404
|
-
required: true,
|
|
405
|
-
admin: { hidden: true }
|
|
406
|
-
},
|
|
407
|
-
{
|
|
408
|
-
name: "label",
|
|
409
|
-
type: "text",
|
|
410
|
-
label: "Name",
|
|
411
|
-
required: true,
|
|
412
|
-
admin: { hidden: true }
|
|
413
|
-
},
|
|
414
|
-
...colorScaleFields()
|
|
415
|
-
],
|
|
416
|
-
admin: { components: { Field: THEME_LIBRARY_FIELD } }
|
|
417
|
-
}
|
|
418
|
-
]
|
|
419
|
-
}, {
|
|
420
|
-
name: "contrast",
|
|
421
|
-
type: "ui",
|
|
422
|
-
admin: {
|
|
423
|
-
components: { Field: THEME_CONTRAST_REPORT },
|
|
424
|
-
custom: { target: contrastTarget }
|
|
425
|
-
}
|
|
426
|
-
}]
|
|
427
|
-
},
|
|
428
|
-
{
|
|
429
|
-
label: "Typography",
|
|
430
|
-
fields: [{
|
|
431
|
-
name: "typography",
|
|
432
|
-
type: "group",
|
|
433
|
-
label: false,
|
|
434
|
-
fields: [
|
|
435
|
-
fontField("heading", "Heading font", headingSelectValue(seed.fontHeading), fonts, fontsBaseUrl, true),
|
|
436
|
-
fontField("body", "Body font", seed.fontBody, fonts, fontsBaseUrl, false),
|
|
410
|
+
name: "libraryPlacement",
|
|
411
|
+
type: "ui",
|
|
412
|
+
admin: {
|
|
413
|
+
components: { Field: THEME_LIBRARY_FIELD },
|
|
414
|
+
custom: { dataPath: "colors.library" }
|
|
415
|
+
}
|
|
416
|
+
},
|
|
437
417
|
{
|
|
438
|
-
name: "
|
|
418
|
+
name: "greyPlacement",
|
|
439
419
|
type: "ui",
|
|
440
420
|
admin: {
|
|
441
|
-
components: { Field:
|
|
442
|
-
custom: {
|
|
421
|
+
components: { Field: THEME_GREY_SCALE_FIELD },
|
|
422
|
+
custom: { dataPath: "colors.greyScale" }
|
|
443
423
|
}
|
|
444
|
-
}
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
name: "statusColorsHeading",
|
|
427
|
+
type: "ui",
|
|
428
|
+
admin: {
|
|
429
|
+
components: { Field: THEME_SECTION_HEADING },
|
|
430
|
+
custom: {
|
|
431
|
+
title: "Status colors",
|
|
432
|
+
hint: "These colors communicate meaning across the site. You can adjust them to fit your brand, but we'll help keep them recognizable as success and error states."
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
systemColorGroup("success", "Success", seed.brandSuccess || LIBRARY_SUCCESS_HEX),
|
|
437
|
+
systemColorGroup("destructive", "Destructive", destructive)
|
|
445
438
|
]
|
|
446
|
-
}]
|
|
447
|
-
},
|
|
448
|
-
{
|
|
449
|
-
label: "Appearance",
|
|
450
|
-
fields: [{
|
|
451
|
-
name: "appearance",
|
|
452
|
-
type: "group",
|
|
453
|
-
label: false,
|
|
454
|
-
fields: [
|
|
455
|
-
namedSelect("defaultTheme", "Default theme", APPEARANCE_CHOICES.defaultTheme, seed.defaultTheme),
|
|
456
|
-
namedSelect("radius", "Corner style", APPEARANCE_CHOICES.radius, seed.radius),
|
|
457
|
-
namedSelect("shadow", "Depth", APPEARANCE_CHOICES.shadow, seed.shadow),
|
|
458
|
-
namedSelect("motion", "Motion", APPEARANCE_CHOICES.motion, seed.motion),
|
|
459
|
-
namedSelect("density", "Spacing", APPEARANCE_CHOICES.density, seed.density)
|
|
460
|
-
],
|
|
461
|
-
admin: { components: { Field: THEME_APPEARANCE_FIELD } }
|
|
462
|
-
}]
|
|
463
|
-
}
|
|
464
|
-
];
|
|
465
|
-
tabs.push({
|
|
466
|
-
label: "Identity",
|
|
467
|
-
fields: logo ? [
|
|
468
|
-
{
|
|
469
|
-
name: "logo",
|
|
470
|
-
type: "upload",
|
|
471
|
-
relationTo: logo.collection,
|
|
472
|
-
label: "Logo",
|
|
473
|
-
admin: { description: "The full wordmark used in the header and footer." }
|
|
474
439
|
},
|
|
475
440
|
{
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
441
|
+
...namedSelect("greyScale", "Gray family", presetHints.greyScale, seed.greyScale, THEME_GREY_SCALE_FIELD),
|
|
442
|
+
admin: {
|
|
443
|
+
hidden: true,
|
|
444
|
+
components: { Field: THEME_GREY_SCALE_FIELD }
|
|
445
|
+
}
|
|
481
446
|
},
|
|
482
447
|
{
|
|
483
|
-
name: "
|
|
484
|
-
type: "
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
448
|
+
name: "library",
|
|
449
|
+
type: "array",
|
|
450
|
+
label: "Additional colors",
|
|
451
|
+
labels: {
|
|
452
|
+
singular: "Color",
|
|
453
|
+
plural: "Colors"
|
|
454
|
+
},
|
|
455
|
+
fields: [
|
|
456
|
+
{
|
|
457
|
+
name: "key",
|
|
458
|
+
type: "text",
|
|
459
|
+
label: "Key",
|
|
460
|
+
required: true,
|
|
461
|
+
admin: { hidden: true }
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
name: "label",
|
|
465
|
+
type: "text",
|
|
466
|
+
label: "Name",
|
|
467
|
+
required: true,
|
|
468
|
+
admin: { hidden: true }
|
|
469
|
+
},
|
|
470
|
+
...colorScaleFields()
|
|
471
|
+
],
|
|
472
|
+
admin: {
|
|
473
|
+
hidden: true,
|
|
474
|
+
components: { Field: THEME_LIBRARY_FIELD },
|
|
475
|
+
width: "100%"
|
|
476
|
+
}
|
|
488
477
|
}
|
|
489
|
-
]
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
478
|
+
]
|
|
479
|
+
}];
|
|
480
|
+
}
|
|
481
|
+
function typographyFields(seed, fonts, fontsBaseUrl) {
|
|
482
|
+
return [{
|
|
483
|
+
name: "typography",
|
|
484
|
+
type: "group",
|
|
485
|
+
label: "Typography",
|
|
486
|
+
fields: [
|
|
487
|
+
fontField("heading", "Heading font", headingSelectValue(seed.fontHeading), fonts, fontsBaseUrl, true),
|
|
488
|
+
fontField("body", "Body font", seed.fontBody, fonts, fontsBaseUrl, false),
|
|
489
|
+
{
|
|
490
|
+
name: "pairing",
|
|
491
|
+
type: "ui",
|
|
492
|
+
admin: {
|
|
493
|
+
components: { Field: THEME_PAIRING_FIELD },
|
|
494
|
+
custom: { fontsBaseUrl }
|
|
495
|
+
}
|
|
495
496
|
}
|
|
496
|
-
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
497
|
+
]
|
|
498
|
+
}];
|
|
499
|
+
}
|
|
500
|
+
function appearanceFields(seed) {
|
|
501
|
+
return [{
|
|
502
|
+
name: "appearance",
|
|
503
|
+
type: "group",
|
|
504
|
+
label: "Appearance",
|
|
505
|
+
fields: [
|
|
506
|
+
namedSelect("defaultTheme", "Default theme", APPEARANCE_CHOICES.defaultTheme, seed.defaultTheme),
|
|
507
|
+
namedSelect("radius", "Corner style", APPEARANCE_CHOICES.radius, seed.radius),
|
|
508
|
+
namedSelect("shadow", "Depth", APPEARANCE_CHOICES.shadow, seed.shadow),
|
|
509
|
+
namedSelect("motion", "Motion", APPEARANCE_CHOICES.motion, seed.motion),
|
|
510
|
+
namedSelect("density", "Spacing", APPEARANCE_CHOICES.density, seed.density)
|
|
511
|
+
],
|
|
512
|
+
admin: { components: { Field: THEME_APPEARANCE_FIELD } }
|
|
505
513
|
}];
|
|
514
|
+
}
|
|
515
|
+
function identityFields(collection) {
|
|
516
|
+
return [
|
|
517
|
+
{
|
|
518
|
+
name: "logo",
|
|
519
|
+
type: "upload",
|
|
520
|
+
relationTo: collection,
|
|
521
|
+
label: "Logo",
|
|
522
|
+
admin: { description: "The full wordmark used in the header and footer." }
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
name: "favicon",
|
|
526
|
+
type: "upload",
|
|
527
|
+
relationTo: collection,
|
|
528
|
+
label: "Favicon",
|
|
529
|
+
admin: { description: "The small icon in the browser tab. PNG or SVG." }
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: "logoMark",
|
|
533
|
+
type: "upload",
|
|
534
|
+
relationTo: collection,
|
|
535
|
+
label: "Mobile menu logo",
|
|
536
|
+
admin: { description: "Optional. Used when the full logo is too wide for the mobile menu — typically the mark." }
|
|
537
|
+
}
|
|
538
|
+
];
|
|
539
|
+
}
|
|
540
|
+
function pageHooks(child) {
|
|
506
541
|
return {
|
|
542
|
+
beforeChange: [async ({ data, req }) => {
|
|
543
|
+
if (req?.payload) await persistThemeChild(req.payload, data, child);
|
|
544
|
+
return data;
|
|
545
|
+
}],
|
|
546
|
+
afterRead: [async ({ doc, req }) => {
|
|
547
|
+
if (hasThemeSlice(doc, child)) return doc;
|
|
548
|
+
const theme = await req?.payload?.findGlobal({
|
|
549
|
+
slug: THEME_SLUG,
|
|
550
|
+
draft: false,
|
|
551
|
+
depth: child === "identity" ? 1 : 0,
|
|
552
|
+
overrideAccess: true
|
|
553
|
+
});
|
|
554
|
+
if (!theme) return doc;
|
|
555
|
+
return {
|
|
556
|
+
...doc,
|
|
557
|
+
...sliceFromTheme(theme, child)
|
|
558
|
+
};
|
|
559
|
+
}]
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Standard Payload Globals in an `admin.group: "Theme"` nav section —
|
|
564
|
+
* the same pattern as Content and Settings. Each page is its own Global
|
|
565
|
+
* (Save, fields, access). A hidden `theme` store is the published read
|
|
566
|
+
* model `getPublishedTheme` already knows. Locking is off: these are
|
|
567
|
+
* settings forms, not collaborative documents. No draft mode, no preview
|
|
568
|
+
* pane.
|
|
569
|
+
*/
|
|
570
|
+
function createTheme(options) {
|
|
571
|
+
const access = requireAccess$1(options);
|
|
572
|
+
const destructive = requireDestructive(options);
|
|
573
|
+
const { seed, fonts = catalog, fontsBaseUrl = "/fonts", contrastTarget = 7, logo, identity, onPublish } = options;
|
|
574
|
+
const adminCustom = {
|
|
575
|
+
contrastTarget,
|
|
576
|
+
fontsBaseUrl,
|
|
577
|
+
identityFallback: identity?.fallback
|
|
578
|
+
};
|
|
579
|
+
const colorFields = colorsFields(seed, destructive);
|
|
580
|
+
const typeFields = typographyFields(seed, fonts, fontsBaseUrl);
|
|
581
|
+
const lookFields = appearanceFields(seed);
|
|
582
|
+
const markFields = logo ? identityFields(logo.collection) : [];
|
|
583
|
+
const store = {
|
|
507
584
|
slug: THEME_SLUG,
|
|
508
585
|
label: "Theme",
|
|
586
|
+
lockDocuments: false,
|
|
509
587
|
admin: {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
fontsBaseUrl,
|
|
514
|
-
identityFallback: identity?.fallback
|
|
515
|
-
},
|
|
516
|
-
components: { elements: { SaveButton: THEME_SAVE_BUTTON } }
|
|
588
|
+
hidden: true,
|
|
589
|
+
hideAPIURL: true,
|
|
590
|
+
custom: adminCustom
|
|
517
591
|
},
|
|
518
592
|
access: {
|
|
519
593
|
read: access.read,
|
|
520
594
|
update: access.update
|
|
521
595
|
},
|
|
522
|
-
fields
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
}]
|
|
534
|
-
}
|
|
596
|
+
fields: [
|
|
597
|
+
...colorFields,
|
|
598
|
+
...typeFields,
|
|
599
|
+
...lookFields,
|
|
600
|
+
...markFields
|
|
601
|
+
],
|
|
602
|
+
hooks: { afterChange: [async ({ doc }) => {
|
|
603
|
+
if (!onPublish) return;
|
|
604
|
+
const themeDoc = doc;
|
|
605
|
+
await onPublish(themeConfigFromDoc(themeDoc, seed), themeDoc);
|
|
606
|
+
}] }
|
|
535
607
|
};
|
|
608
|
+
function page(slug, label, child, fields, preview) {
|
|
609
|
+
return {
|
|
610
|
+
slug,
|
|
611
|
+
label,
|
|
612
|
+
lockDocuments: false,
|
|
613
|
+
admin: {
|
|
614
|
+
group: "Theme",
|
|
615
|
+
hideAPIURL: true,
|
|
616
|
+
custom: adminCustom,
|
|
617
|
+
components: preview ? { elements: { beforeDocumentControls: [THEME_DOCUMENT_CONTROLS] } } : void 0
|
|
618
|
+
},
|
|
619
|
+
access: {
|
|
620
|
+
read: access.read,
|
|
621
|
+
update: access.update
|
|
622
|
+
},
|
|
623
|
+
fields,
|
|
624
|
+
hooks: pageHooks(child)
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
return [
|
|
628
|
+
store,
|
|
629
|
+
page(THEME_COLORS_SLUG, "Colors", "colors", colorFields, true),
|
|
630
|
+
page(THEME_TYPOGRAPHY_SLUG, "Typography", "typography", typeFields, true),
|
|
631
|
+
page(THEME_APPEARANCE_SLUG, "Appearance", "appearance", lookFields, true),
|
|
632
|
+
...logo ? [page(THEME_IDENTITY_SLUG, "Identity", "identity", markFields, false)] : []
|
|
633
|
+
];
|
|
536
634
|
}
|
|
537
635
|
//#endregion
|
|
538
636
|
//#region src/brand-assets/sanitize.ts
|
|
@@ -578,7 +676,7 @@ const BRAND_ASSETS_MIME_TYPES = [
|
|
|
578
676
|
function requireAccess(options) {
|
|
579
677
|
const read = options.access?.read;
|
|
580
678
|
const update = options.access?.update;
|
|
581
|
-
if (!read || !update) throw new Error("createBrandAssets requires access.read and access.update. Pass
|
|
679
|
+
if (!read || !update) throw new Error("createBrandAssets requires access.read and access.update. Pass canManageBrand from this package as update.");
|
|
582
680
|
return {
|
|
583
681
|
read,
|
|
584
682
|
update
|
|
@@ -681,6 +779,659 @@ const THEME_PREVIEW_BREAKPOINTS = [
|
|
|
681
779
|
}
|
|
682
780
|
];
|
|
683
781
|
//#endregion
|
|
684
|
-
|
|
782
|
+
//#region src/roles/types.ts
|
|
783
|
+
/**
|
|
784
|
+
* The Roles Global a site's generated types will describe. Optional and
|
|
785
|
+
* nullable, no index signature: a generated Global is assignable to this,
|
|
786
|
+
* never the reverse.
|
|
787
|
+
*/
|
|
788
|
+
const ROLES = [
|
|
789
|
+
"developer",
|
|
790
|
+
"admin",
|
|
791
|
+
"designer",
|
|
792
|
+
"author"
|
|
793
|
+
];
|
|
794
|
+
const ROLE_LABELS = {
|
|
795
|
+
developer: "Developer",
|
|
796
|
+
admin: "Admin",
|
|
797
|
+
designer: "Designer",
|
|
798
|
+
author: "Author"
|
|
799
|
+
};
|
|
800
|
+
const CAPABILITIES = [
|
|
801
|
+
"content",
|
|
802
|
+
"brand",
|
|
803
|
+
"publish",
|
|
804
|
+
"users"
|
|
805
|
+
];
|
|
806
|
+
function isRole(value) {
|
|
807
|
+
return typeof value === "string" && ROLES.includes(value);
|
|
808
|
+
}
|
|
809
|
+
//#endregion
|
|
810
|
+
//#region src/roles/matrix.ts
|
|
811
|
+
const ROLES_SLUG = "roles";
|
|
812
|
+
/**
|
|
813
|
+
* Default rank and ticks. Brand is Designer only. The API tab is not a
|
|
814
|
+
* column — it is locked to Developer in `isDeveloper`.
|
|
815
|
+
*/
|
|
816
|
+
const DEFAULT_ROLE_MATRIX = [
|
|
817
|
+
{
|
|
818
|
+
role: "developer",
|
|
819
|
+
content: true,
|
|
820
|
+
brand: true,
|
|
821
|
+
publish: true,
|
|
822
|
+
users: true
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
role: "admin",
|
|
826
|
+
content: true,
|
|
827
|
+
brand: false,
|
|
828
|
+
publish: true,
|
|
829
|
+
users: true
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
role: "designer",
|
|
833
|
+
content: false,
|
|
834
|
+
brand: true,
|
|
835
|
+
publish: false,
|
|
836
|
+
users: false
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
role: "author",
|
|
840
|
+
content: true,
|
|
841
|
+
brand: false,
|
|
842
|
+
publish: false,
|
|
843
|
+
users: false
|
|
844
|
+
}
|
|
845
|
+
];
|
|
846
|
+
const DEVELOPER_DESCRIPTION = "Everything Admin can do, plus the document API tab.";
|
|
847
|
+
const LAST_USERS_TICK_MESSAGE = "Keep Users ticked on at least one of Admin or Developer.";
|
|
848
|
+
const CAPABILITY_LABELS = {
|
|
849
|
+
content: "Content",
|
|
850
|
+
brand: "Brand",
|
|
851
|
+
publish: "Publish",
|
|
852
|
+
users: "Users"
|
|
853
|
+
};
|
|
854
|
+
function defaultRolesFieldValue() {
|
|
855
|
+
return DEFAULT_ROLE_MATRIX.map((row) => ({
|
|
856
|
+
id: row.role,
|
|
857
|
+
...row
|
|
858
|
+
}));
|
|
859
|
+
}
|
|
860
|
+
function parseRolesMatrix(value) {
|
|
861
|
+
if (!Array.isArray(value)) return {
|
|
862
|
+
ok: false,
|
|
863
|
+
message: "Roles must be Developer, Admin, Designer, and Author."
|
|
864
|
+
};
|
|
865
|
+
const rows = [];
|
|
866
|
+
const seen = /* @__PURE__ */ new Set();
|
|
867
|
+
for (const item of value) {
|
|
868
|
+
if (!item || typeof item !== "object") continue;
|
|
869
|
+
const role = "role" in item ? item.role : void 0;
|
|
870
|
+
if (!isRole(role) || seen.has(role)) continue;
|
|
871
|
+
seen.add(role);
|
|
872
|
+
rows.push({
|
|
873
|
+
id: role,
|
|
874
|
+
role,
|
|
875
|
+
content: Boolean("content" in item && item.content),
|
|
876
|
+
brand: Boolean("brand" in item && item.brand),
|
|
877
|
+
publish: Boolean("publish" in item && item.publish),
|
|
878
|
+
users: Boolean("users" in item && item.users)
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
if (rows.length !== ROLES.length || ROLES.some((role) => !seen.has(role))) return {
|
|
882
|
+
ok: false,
|
|
883
|
+
message: "Roles must be Developer, Admin, Designer, and Author."
|
|
884
|
+
};
|
|
885
|
+
return {
|
|
886
|
+
ok: true,
|
|
887
|
+
rows
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
function hasPrivilegedUsersTick(rows) {
|
|
891
|
+
return rows.some((row) => (row.role === "admin" || row.role === "developer") && row.users);
|
|
892
|
+
}
|
|
893
|
+
function validateRolesMatrix(value) {
|
|
894
|
+
const parsed = parseRolesMatrix(value);
|
|
895
|
+
if (!parsed.ok) return parsed.message;
|
|
896
|
+
if (!hasPrivilegedUsersTick(parsed.rows)) return LAST_USERS_TICK_MESSAGE;
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Developer is exclusive. Admin does not swallow Designer: both store.
|
|
901
|
+
*/
|
|
902
|
+
function normalizeStoredRoles(value) {
|
|
903
|
+
if (!Array.isArray(value)) return [];
|
|
904
|
+
const roles = [...new Set(value.filter(isRole))];
|
|
905
|
+
if (roles.includes("developer")) return ["developer"];
|
|
906
|
+
return roles;
|
|
907
|
+
}
|
|
908
|
+
function roleSelectOptions(matrix = DEFAULT_ROLE_MATRIX) {
|
|
909
|
+
return matrix.map((row) => ({
|
|
910
|
+
label: ROLE_LABELS[row.role],
|
|
911
|
+
value: row.role
|
|
912
|
+
}));
|
|
913
|
+
}
|
|
914
|
+
/** Capability copy only. Developer names the API tab; nothing about MCP or seed. */
|
|
915
|
+
function roleDescription(role, matrix = DEFAULT_ROLE_MATRIX) {
|
|
916
|
+
if (role === "developer") return DEVELOPER_DESCRIPTION;
|
|
917
|
+
const row = matrix.find((entry) => entry.role === role);
|
|
918
|
+
if (!row) return "No capabilities";
|
|
919
|
+
return CAPABILITIES.filter((capability) => row[capability]).map((capability) => CAPABILITY_LABELS[capability]).join(", ") || "No capabilities";
|
|
920
|
+
}
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/roles/access.ts
|
|
923
|
+
function isAccessArgs(value) {
|
|
924
|
+
return typeof value === "object" && value !== null && "req" in value;
|
|
925
|
+
}
|
|
926
|
+
function storedRoles(user) {
|
|
927
|
+
return Array.isArray(user?.roles) ? user.roles : [];
|
|
928
|
+
}
|
|
929
|
+
function hasRole(user, role) {
|
|
930
|
+
return storedRoles(user).includes(role);
|
|
931
|
+
}
|
|
932
|
+
/** Not a tick. True only when `developer` is stored on the row. */
|
|
933
|
+
function isDeveloper(user) {
|
|
934
|
+
return hasRole(user, "developer");
|
|
935
|
+
}
|
|
936
|
+
function hasCapability(user, capability, matrix = DEFAULT_ROLE_MATRIX) {
|
|
937
|
+
return storedRoles(user).some((role) => {
|
|
938
|
+
return matrix.find((entry) => entry.role === role)?.[capability] === true;
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Reads the saved Roles Global, falling back to the seed when the row is
|
|
943
|
+
* empty, missing, or unreadable. Always override-access so a Designer
|
|
944
|
+
* evaluating Theme does not have to read Settings → Roles.
|
|
945
|
+
*/
|
|
946
|
+
async function getRolesMatrix(req = {}) {
|
|
947
|
+
const findGlobal = req.payload?.findGlobal;
|
|
948
|
+
if (typeof findGlobal !== "function") return [...DEFAULT_ROLE_MATRIX];
|
|
949
|
+
try {
|
|
950
|
+
const doc = await findGlobal({
|
|
951
|
+
slug: ROLES_SLUG,
|
|
952
|
+
overrideAccess: true,
|
|
953
|
+
req
|
|
954
|
+
});
|
|
955
|
+
const parsed = parseRolesMatrix(doc && typeof doc === "object" && "roles" in doc ? doc.roles : void 0);
|
|
956
|
+
return parsed.ok ? parsed.rows : [...DEFAULT_ROLE_MATRIX];
|
|
957
|
+
} catch {
|
|
958
|
+
return [...DEFAULT_ROLE_MATRIX];
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
function capabilityPredicate(capability) {
|
|
962
|
+
function predicate(userOrArgs, matrix) {
|
|
963
|
+
if (isAccessArgs(userOrArgs)) {
|
|
964
|
+
const user = userOrArgs.req.user;
|
|
965
|
+
if (matrix) return hasCapability(user, capability, matrix);
|
|
966
|
+
return getRolesMatrix(userOrArgs.req).then((rows) => hasCapability(user, capability, rows));
|
|
967
|
+
}
|
|
968
|
+
return hasCapability(userOrArgs, capability, matrix ?? DEFAULT_ROLE_MATRIX);
|
|
969
|
+
}
|
|
970
|
+
return predicate;
|
|
971
|
+
}
|
|
972
|
+
const canManageContent = capabilityPredicate("content");
|
|
973
|
+
const canManageBrand = capabilityPredicate("brand");
|
|
974
|
+
const canPublish = capabilityPredicate("publish");
|
|
975
|
+
/** Users tick on any held role. */
|
|
976
|
+
const isAdmin = capabilityPredicate("users");
|
|
977
|
+
/** This role id currently has the Users tick. */
|
|
978
|
+
function isPrivilegedRole(role, matrix = DEFAULT_ROLE_MATRIX) {
|
|
979
|
+
return isRole(role) && matrix.some((row) => row.role === role && row.users);
|
|
980
|
+
}
|
|
981
|
+
function isAuthenticated(userOrArgs) {
|
|
982
|
+
if (isAccessArgs(userOrArgs)) return Boolean(userOrArgs.req.user);
|
|
983
|
+
return Boolean(userOrArgs);
|
|
984
|
+
}
|
|
985
|
+
const isAdminOrSelf = async ({ req }) => {
|
|
986
|
+
if (!req.user) return false;
|
|
987
|
+
if (await isAdmin({ req })) return true;
|
|
988
|
+
return { id: { equals: req.user.id } };
|
|
989
|
+
};
|
|
990
|
+
const authenticatedOrPublished = ({ req: { user } }) => {
|
|
991
|
+
if (isAuthenticated(user)) return true;
|
|
992
|
+
return { _status: { equals: "published" } };
|
|
993
|
+
};
|
|
994
|
+
/** API tab condition: Developer only, not the Users tick. */
|
|
995
|
+
function isDeveloperTab({ req }) {
|
|
996
|
+
return isDeveloper(req.user);
|
|
997
|
+
}
|
|
998
|
+
//#endregion
|
|
999
|
+
//#region src/roles/fields.ts
|
|
1000
|
+
const ROLES_MATRIX_FIELD = "@bison-lab/payload-core/admin#RolesMatrixField";
|
|
1001
|
+
const ROLES_FIELD = "@bison-lab/payload-core/admin#RolesField";
|
|
1002
|
+
//#endregion
|
|
1003
|
+
//#region src/roles/global.ts
|
|
1004
|
+
/**
|
|
1005
|
+
* Settings → Roles. Rank is the array order (Payload's drag handle). Ticks
|
|
1006
|
+
* are Content, Brand, Publish, Users. The API tab is not a column.
|
|
1007
|
+
*/
|
|
1008
|
+
function createRoles() {
|
|
1009
|
+
return {
|
|
1010
|
+
slug: ROLES_SLUG,
|
|
1011
|
+
label: "Roles",
|
|
1012
|
+
admin: {
|
|
1013
|
+
group: "Settings",
|
|
1014
|
+
hidden: ({ user }) => !isAdmin(user),
|
|
1015
|
+
description: "Who may do what. Drag to change rank. The API tab is locked to Developer."
|
|
1016
|
+
},
|
|
1017
|
+
access: {
|
|
1018
|
+
read: (args) => isAdmin(args),
|
|
1019
|
+
update: (args) => isAdmin(args)
|
|
1020
|
+
},
|
|
1021
|
+
fields: [{
|
|
1022
|
+
name: "roles",
|
|
1023
|
+
type: "array",
|
|
1024
|
+
label: "Roles",
|
|
1025
|
+
labels: {
|
|
1026
|
+
singular: "Role",
|
|
1027
|
+
plural: "Roles"
|
|
1028
|
+
},
|
|
1029
|
+
minRows: 4,
|
|
1030
|
+
maxRows: 4,
|
|
1031
|
+
required: true,
|
|
1032
|
+
defaultValue: defaultRolesFieldValue(),
|
|
1033
|
+
validate: validateRolesMatrix,
|
|
1034
|
+
admin: {
|
|
1035
|
+
components: { Field: ROLES_MATRIX_FIELD },
|
|
1036
|
+
description: "Drag to change rank. Ticks are Content, Brand, Publish, and Users. The API tab is locked to Developer.",
|
|
1037
|
+
initCollapsed: false
|
|
1038
|
+
},
|
|
1039
|
+
fields: [
|
|
1040
|
+
{
|
|
1041
|
+
name: "role",
|
|
1042
|
+
type: "select",
|
|
1043
|
+
label: "Role",
|
|
1044
|
+
required: true,
|
|
1045
|
+
options: ROLES.map((value) => ({
|
|
1046
|
+
label: ROLE_LABELS[value],
|
|
1047
|
+
value
|
|
1048
|
+
})),
|
|
1049
|
+
admin: { readOnly: true }
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
name: "content",
|
|
1053
|
+
type: "checkbox",
|
|
1054
|
+
label: "Content"
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
name: "brand",
|
|
1058
|
+
type: "checkbox",
|
|
1059
|
+
label: "Brand"
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
name: "publish",
|
|
1063
|
+
type: "checkbox",
|
|
1064
|
+
label: "Publish"
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
name: "users",
|
|
1068
|
+
type: "checkbox",
|
|
1069
|
+
label: "Users"
|
|
1070
|
+
}
|
|
1071
|
+
]
|
|
1072
|
+
}]
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
//#endregion
|
|
1076
|
+
//#region src/roles/seed.ts
|
|
1077
|
+
/**
|
|
1078
|
+
* Writes the default matrix. For a site's migration `up()`. Never writes a
|
|
1079
|
+
* user row — seeding `developer` on a person stays a site concern.
|
|
1080
|
+
*/
|
|
1081
|
+
async function seedRoles(payload) {
|
|
1082
|
+
return payload.updateGlobal({
|
|
1083
|
+
slug: ROLES_SLUG,
|
|
1084
|
+
data: { roles: defaultRolesFieldValue() }
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
//#endregion
|
|
1088
|
+
//#region src/fields/slug.ts
|
|
1089
|
+
/**
|
|
1090
|
+
* A stored slug from whatever was typed: lowercased, with any run of
|
|
1091
|
+
* whitespace and slashes stripped from either end in one pass, so `/ about-us /`
|
|
1092
|
+
* does not keep the inner spaces a trim-then-strip would leave.
|
|
1093
|
+
*/
|
|
1094
|
+
function normalizeSlug(value) {
|
|
1095
|
+
return value.replace(/^[\s/]+|[\s/]+$/g, "").toLowerCase();
|
|
1096
|
+
}
|
|
1097
|
+
async function slugProblem(slug, { collection, id, isReserved, req }) {
|
|
1098
|
+
if (!slug) return void 0;
|
|
1099
|
+
if (isReserved(slug)) return `"/${slug}" is a built-in page on this site, so a page here cannot use it. Please choose a different address.`;
|
|
1100
|
+
if (typeof req?.payload?.find !== "function") return void 0;
|
|
1101
|
+
const where = { slug: { equals: slug } };
|
|
1102
|
+
if (id !== void 0) where.id = { not_equals: id };
|
|
1103
|
+
const query = {
|
|
1104
|
+
collection,
|
|
1105
|
+
depth: 0,
|
|
1106
|
+
limit: 1,
|
|
1107
|
+
overrideAccess: true,
|
|
1108
|
+
pagination: false,
|
|
1109
|
+
req,
|
|
1110
|
+
where
|
|
1111
|
+
};
|
|
1112
|
+
return (await req.payload.find(query)).docs.length > 0 || (await req.payload.find({
|
|
1113
|
+
...query,
|
|
1114
|
+
draft: true
|
|
1115
|
+
})).docs.length > 0 ? `Another page is already using "/${slug}". Please choose a different address.` : void 0;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* The path a document is published at, normalised on the way in. Unique
|
|
1119
|
+
* across the collection. `validate` is the message under the field;
|
|
1120
|
+
* `beforeChange` is the enforcement on a draft save, where Payload skips
|
|
1121
|
+
* field validation.
|
|
1122
|
+
*/
|
|
1123
|
+
function slugField({ collection, isReserved }) {
|
|
1124
|
+
const validate = async (value, options) => {
|
|
1125
|
+
try {
|
|
1126
|
+
const builtIn = await validations.text(value, options);
|
|
1127
|
+
if (builtIn !== true) return builtIn;
|
|
1128
|
+
} catch {}
|
|
1129
|
+
return await slugProblem(typeof value === "string" ? value : "", {
|
|
1130
|
+
collection,
|
|
1131
|
+
id: options.id,
|
|
1132
|
+
isReserved,
|
|
1133
|
+
req: options.req
|
|
1134
|
+
}) ?? true;
|
|
1135
|
+
};
|
|
1136
|
+
return {
|
|
1137
|
+
name: "slug",
|
|
1138
|
+
type: "text",
|
|
1139
|
+
required: true,
|
|
1140
|
+
unique: true,
|
|
1141
|
+
admin: { description: "Path under the site root, no leading slash: \"about-us\" or \"patients/stories\"." },
|
|
1142
|
+
hooks: {
|
|
1143
|
+
beforeValidate: [({ value }) => typeof value === "string" ? normalizeSlug(value) : value],
|
|
1144
|
+
beforeChange: [async ({ data, originalDoc, req, value }) => {
|
|
1145
|
+
if (typeof value !== "string") return value;
|
|
1146
|
+
const problem = await slugProblem(value, {
|
|
1147
|
+
collection,
|
|
1148
|
+
id: originalDoc?.id ?? data?.id,
|
|
1149
|
+
isReserved,
|
|
1150
|
+
req
|
|
1151
|
+
});
|
|
1152
|
+
if (problem) throw new ValidationError({
|
|
1153
|
+
collection,
|
|
1154
|
+
errors: [{
|
|
1155
|
+
message: problem,
|
|
1156
|
+
path: "slug"
|
|
1157
|
+
}],
|
|
1158
|
+
req
|
|
1159
|
+
}, req?.t);
|
|
1160
|
+
return value;
|
|
1161
|
+
}]
|
|
1162
|
+
},
|
|
1163
|
+
validate
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
//#endregion
|
|
1167
|
+
//#region src/collections/pages.ts
|
|
1168
|
+
/**
|
|
1169
|
+
* CMS-managed pages: one required hero, then a reorderable body of sections.
|
|
1170
|
+
* The CMS owns copy and the order of sections; what a section looks like is
|
|
1171
|
+
* code-owned, which is why the blocks are an argument.
|
|
1172
|
+
*/
|
|
1173
|
+
function createPages({ heroBlocks, isReservedSlug, layoutBlocks, previewPath, previewButton }) {
|
|
1174
|
+
const [defaultHero] = heroBlocks;
|
|
1175
|
+
if (!defaultHero) throw new Error("createPages needs at least one hero block");
|
|
1176
|
+
return {
|
|
1177
|
+
slug: "pages",
|
|
1178
|
+
admin: {
|
|
1179
|
+
useAsTitle: "title",
|
|
1180
|
+
group: "Content",
|
|
1181
|
+
defaultColumns: [
|
|
1182
|
+
"title",
|
|
1183
|
+
"slug",
|
|
1184
|
+
"_status",
|
|
1185
|
+
"updatedAt"
|
|
1186
|
+
],
|
|
1187
|
+
preview: (doc) => previewPath(typeof doc.slug === "string" ? doc.slug : ""),
|
|
1188
|
+
...previewButton ? { components: { edit: { PreviewButton: previewButton } } } : {}
|
|
1189
|
+
},
|
|
1190
|
+
access: {
|
|
1191
|
+
read: authenticatedOrPublished,
|
|
1192
|
+
readVersions: isAuthenticated,
|
|
1193
|
+
create: canManageContent,
|
|
1194
|
+
update: canManageContent,
|
|
1195
|
+
delete: isAdmin
|
|
1196
|
+
},
|
|
1197
|
+
versions: {
|
|
1198
|
+
drafts: { autosave: { interval: 375 } },
|
|
1199
|
+
maxPerDoc: 50
|
|
1200
|
+
},
|
|
1201
|
+
fields: [
|
|
1202
|
+
{
|
|
1203
|
+
name: "title",
|
|
1204
|
+
type: "text",
|
|
1205
|
+
required: true
|
|
1206
|
+
},
|
|
1207
|
+
slugField({
|
|
1208
|
+
collection: "pages",
|
|
1209
|
+
isReserved: isReservedSlug
|
|
1210
|
+
}),
|
|
1211
|
+
{
|
|
1212
|
+
name: "hero",
|
|
1213
|
+
type: "blocks",
|
|
1214
|
+
required: true,
|
|
1215
|
+
minRows: 1,
|
|
1216
|
+
maxRows: 1,
|
|
1217
|
+
blocks: heroBlocks,
|
|
1218
|
+
defaultValue: [{ blockType: defaultHero.slug }],
|
|
1219
|
+
admin: { description: "Every page opens with one hero. A page cannot be published without one." }
|
|
1220
|
+
},
|
|
1221
|
+
{
|
|
1222
|
+
name: "layout",
|
|
1223
|
+
type: "blocks",
|
|
1224
|
+
required: true,
|
|
1225
|
+
minRows: 1,
|
|
1226
|
+
blocks: layoutBlocks,
|
|
1227
|
+
labels: {
|
|
1228
|
+
singular: "Section",
|
|
1229
|
+
plural: "Sections"
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
]
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
//#endregion
|
|
1236
|
+
//#region src/collections/users.ts
|
|
1237
|
+
const nameValidate = (value, options) => {
|
|
1238
|
+
if (options.operation === "update" && value === "" && options.previousValue === "") return true;
|
|
1239
|
+
return text(value, options);
|
|
1240
|
+
};
|
|
1241
|
+
function nameField(name) {
|
|
1242
|
+
return {
|
|
1243
|
+
name,
|
|
1244
|
+
type: "text",
|
|
1245
|
+
required: true,
|
|
1246
|
+
validate: nameValidate
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
/** Refuses the save that would remove Users from the only privileged user. */
|
|
1250
|
+
const LAST_ADMIN_DEMOTE_MESSAGE = "Make another user an admin before removing it from this one.";
|
|
1251
|
+
/** Refuses the delete that would remove the only privileged user. */
|
|
1252
|
+
const LAST_ADMIN_DELETE_MESSAGE = "Make another user an admin before deleting this one.";
|
|
1253
|
+
const LAST_ADMIN_LOCK = "select pg_advisory_xact_lock(hashtext('users:last-admin'))";
|
|
1254
|
+
async function lockLastAdminDecision(req) {
|
|
1255
|
+
const { execute, sessions } = req.payload.db;
|
|
1256
|
+
const id = req.transactionID;
|
|
1257
|
+
const session = typeof id === "string" || typeof id === "number" ? sessions?.[id] : void 0;
|
|
1258
|
+
if (!session || typeof execute !== "function") return;
|
|
1259
|
+
await execute({
|
|
1260
|
+
db: session.db,
|
|
1261
|
+
raw: LAST_ADMIN_LOCK
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
function privilegedRoles(matrix) {
|
|
1265
|
+
return matrix.filter((row) => row.users).map((row) => row.role);
|
|
1266
|
+
}
|
|
1267
|
+
function holdsPrivilegedRole(roles, matrix) {
|
|
1268
|
+
return Array.isArray(roles) && roles.some((role) => isPrivilegedRole(role, matrix));
|
|
1269
|
+
}
|
|
1270
|
+
function privilegedWhere(matrix) {
|
|
1271
|
+
const roles = privilegedRoles(matrix);
|
|
1272
|
+
if (roles.length === 0) return { id: { equals: "__none__" } };
|
|
1273
|
+
return { or: roles.map((role) => ({ roles: { contains: role } })) };
|
|
1274
|
+
}
|
|
1275
|
+
async function adminCount(req, excluding) {
|
|
1276
|
+
await lockLastAdminDecision(req);
|
|
1277
|
+
const holdsPrivileged = privilegedWhere(await getRolesMatrix(req));
|
|
1278
|
+
const { totalDocs } = await req.payload.count({
|
|
1279
|
+
collection: "users",
|
|
1280
|
+
overrideAccess: true,
|
|
1281
|
+
req,
|
|
1282
|
+
where: excluding === void 0 ? holdsPrivileged : { and: [holdsPrivileged, { id: { not_equals: excluding } }] }
|
|
1283
|
+
});
|
|
1284
|
+
return totalDocs;
|
|
1285
|
+
}
|
|
1286
|
+
const rolesValidate = async (value, options) => {
|
|
1287
|
+
const builtIn = select(value, options);
|
|
1288
|
+
if (builtIn !== true) return builtIn;
|
|
1289
|
+
if (options.operation !== "update" || options.id === void 0) return true;
|
|
1290
|
+
const matrix = await getRolesMatrix(options.req);
|
|
1291
|
+
if (holdsPrivilegedRole(value, matrix) || !holdsPrivilegedRole(options.previousValue, matrix)) return true;
|
|
1292
|
+
return await adminCount(options.req, options.id) > 0 ? true : LAST_ADMIN_DEMOTE_MESSAGE;
|
|
1293
|
+
};
|
|
1294
|
+
function createUsers({ secureCookies, rolesField }) {
|
|
1295
|
+
return {
|
|
1296
|
+
slug: "users",
|
|
1297
|
+
auth: {
|
|
1298
|
+
maxLoginAttempts: 5,
|
|
1299
|
+
lockTime: 600 * 1e3,
|
|
1300
|
+
tokenExpiration: 7200,
|
|
1301
|
+
cookies: {
|
|
1302
|
+
sameSite: "Lax",
|
|
1303
|
+
secure: secureCookies
|
|
1304
|
+
}
|
|
1305
|
+
},
|
|
1306
|
+
admin: {
|
|
1307
|
+
useAsTitle: "email",
|
|
1308
|
+
defaultColumns: [
|
|
1309
|
+
"email",
|
|
1310
|
+
"firstName",
|
|
1311
|
+
"lastName",
|
|
1312
|
+
"roles"
|
|
1313
|
+
],
|
|
1314
|
+
hidden: ({ user }) => !isAdmin(user)
|
|
1315
|
+
},
|
|
1316
|
+
access: {
|
|
1317
|
+
create: isAdmin,
|
|
1318
|
+
delete: isAdmin,
|
|
1319
|
+
unlock: isAdmin,
|
|
1320
|
+
read: isAdminOrSelf,
|
|
1321
|
+
update: isAdminOrSelf
|
|
1322
|
+
},
|
|
1323
|
+
hooks: {
|
|
1324
|
+
beforeDelete: [async ({ id, req }) => {
|
|
1325
|
+
if (!isAdmin(await req.payload.findByID({
|
|
1326
|
+
collection: "users",
|
|
1327
|
+
id,
|
|
1328
|
+
depth: 0,
|
|
1329
|
+
disableErrors: true,
|
|
1330
|
+
overrideAccess: true,
|
|
1331
|
+
req
|
|
1332
|
+
})) || await adminCount(req, id) > 0) return;
|
|
1333
|
+
throw new APIError(LAST_ADMIN_DELETE_MESSAGE, 400);
|
|
1334
|
+
}],
|
|
1335
|
+
afterOperation: [async (arg) => {
|
|
1336
|
+
const { operation, req } = arg;
|
|
1337
|
+
const touchesRoles = (operation === "update" || operation === "updateByID") && arg.args.data?.roles !== void 0;
|
|
1338
|
+
const deletes = operation === "delete" || operation === "deleteByID";
|
|
1339
|
+
if ((touchesRoles || deletes) && await adminCount(req) === 0) throw new APIError(deletes ? LAST_ADMIN_DELETE_MESSAGE : LAST_ADMIN_DEMOTE_MESSAGE, 400);
|
|
1340
|
+
return arg.result;
|
|
1341
|
+
}]
|
|
1342
|
+
},
|
|
1343
|
+
fields: [{
|
|
1344
|
+
type: "row",
|
|
1345
|
+
fields: [nameField("firstName"), nameField("lastName")]
|
|
1346
|
+
}, {
|
|
1347
|
+
name: "roles",
|
|
1348
|
+
type: "select",
|
|
1349
|
+
hasMany: true,
|
|
1350
|
+
required: true,
|
|
1351
|
+
defaultValue: ["author"],
|
|
1352
|
+
options: roleSelectOptions(),
|
|
1353
|
+
admin: {
|
|
1354
|
+
components: { Field: rolesField ?? "@bison-lab/payload-core/admin#RolesField" },
|
|
1355
|
+
description: "One person can hold several, e.g. Author + Designer."
|
|
1356
|
+
},
|
|
1357
|
+
access: { update: isAdmin },
|
|
1358
|
+
validate: rolesValidate,
|
|
1359
|
+
hooks: { beforeValidate: [({ value }) => Array.isArray(value) ? normalizeStoredRoles(value) : value] }
|
|
1360
|
+
}]
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
//#endregion
|
|
1364
|
+
//#region src/collections/media.ts
|
|
1365
|
+
/**
|
|
1366
|
+
* Uploads, readable by anyone: the public site serves these files. Writing is
|
|
1367
|
+
* an editorial action, deleting is not — removing an asset a live page
|
|
1368
|
+
* references breaks that page.
|
|
1369
|
+
*/
|
|
1370
|
+
function createMedia({ staticDir = "media", mimeTypes = ["image/*"], imageSizes } = {}) {
|
|
1371
|
+
return {
|
|
1372
|
+
slug: "media",
|
|
1373
|
+
access: {
|
|
1374
|
+
read: () => true,
|
|
1375
|
+
create: canManageContent,
|
|
1376
|
+
update: canManageContent,
|
|
1377
|
+
delete: isAdmin
|
|
1378
|
+
},
|
|
1379
|
+
upload: {
|
|
1380
|
+
staticDir,
|
|
1381
|
+
mimeTypes,
|
|
1382
|
+
imageSizes
|
|
1383
|
+
},
|
|
1384
|
+
fields: [{
|
|
1385
|
+
name: "alt",
|
|
1386
|
+
type: "text",
|
|
1387
|
+
required: true
|
|
1388
|
+
}]
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
//#endregion
|
|
1392
|
+
//#region src/plugins/admin-only-api-tab.ts
|
|
1393
|
+
function gateApiTab(entity) {
|
|
1394
|
+
const components = entity.admin?.components;
|
|
1395
|
+
const edit = components?.views?.edit;
|
|
1396
|
+
const api = edit && "api" in edit ? edit.api : void 0;
|
|
1397
|
+
return {
|
|
1398
|
+
...entity,
|
|
1399
|
+
admin: {
|
|
1400
|
+
...entity.admin,
|
|
1401
|
+
components: {
|
|
1402
|
+
...components,
|
|
1403
|
+
views: {
|
|
1404
|
+
...components?.views,
|
|
1405
|
+
edit: {
|
|
1406
|
+
...edit,
|
|
1407
|
+
api: {
|
|
1408
|
+
...api,
|
|
1409
|
+
tab: {
|
|
1410
|
+
...api?.tab,
|
|
1411
|
+
condition: isDeveloperTab
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Gates the document API tab to Developer, config-wide. After plugins that
|
|
1422
|
+
* use `definePlugin` order (MCP is 10), so a collection those plugins
|
|
1423
|
+
* register is still covered.
|
|
1424
|
+
*/
|
|
1425
|
+
const adminOnlyApiTab = definePlugin({
|
|
1426
|
+
slug: "admin-only-api-tab",
|
|
1427
|
+
order: 1e3,
|
|
1428
|
+
plugin: ({ config }) => ({
|
|
1429
|
+
...config,
|
|
1430
|
+
collections: config.collections?.map(gateApiTab),
|
|
1431
|
+
globals: config.globals?.map(gateApiTab)
|
|
1432
|
+
})
|
|
1433
|
+
});
|
|
1434
|
+
//#endregion
|
|
1435
|
+
export { BRAND_ASSETS_MIME_TYPES, BRAND_ASSETS_SLUG, CAPABILITIES, DEFAULT_ROLE_MATRIX, DESCRIPTION_LENGTH, DEVELOPER_DESCRIPTION, LAST_ADMIN_DELETE_MESSAGE, LAST_ADMIN_DEMOTE_MESSAGE, LAST_USERS_TICK_MESSAGE, LOOK_FIELD, PAGE_EDITOR_SYSTEM_KEYS, ROLES, ROLES_FIELD, ROLES_MATRIX_FIELD, ROLES_SLUG, ROLE_LABELS, SHARE_IMAGE_SIZE, SYSTEM_COLOR_KEYS, THEME_APPEARANCE_FIELD, THEME_APPEARANCE_SLUG, THEME_COLORS_SLUG, THEME_COLOR_FIELD, THEME_COLOR_SCALE_FIELD, THEME_CONTRAST_REPORT, THEME_DOCUMENT_CONTROLS, THEME_FONT_FIELD, THEME_GREY_SCALE_FIELD, THEME_IDENTITY_FALLBACK, THEME_IDENTITY_SLUG, THEME_LIBRARY_FIELD, THEME_PAIRING_FIELD, THEME_PREVIEW_BREAKPOINTS, THEME_PUBLISH_FIELD, THEME_SAVE_BUTTON, THEME_SECTION_HEADING, THEME_SLUG, THEME_TYPOGRAPHY_SLUG, adminOnlyApiTab, authenticatedOrPublished, canManageBrand, canManageContent, canPublish, colorTokenField, createBrandAssets, createMedia, createPages, createRoles, createTheme, createUsers, defaultRolesFieldValue, deleteLibraryColor, documentTitle, firstImageIn, getRolesMatrix, hasCapability, hasRole, isAdmin, isAdminOrSelf, isAuthenticated, isDeveloper, isDeveloperTab, isPrivilegedRole, isRole, lookField, noIndexField, normalizeSlug, normalizeStoredRoles, pageEditorLooks, pageEditorTokens, parseRolesMatrix, persistThemeChild, publishThemeChild, resolveThemeIdentity, rewriteColorToken, roleDescription, roleSelectOptions, sanitizeSvg, seedRoles, seedTheme, seoPlugin, slugField, storedRoles, themeColorKeys, themeLibraryFromDoc, titleTemplate, truncateAtWord, validateRolesMatrix };
|
|
685
1436
|
|
|
686
1437
|
//# sourceMappingURL=index.mjs.map
|