apostrophe 4.28.0 → 4.29.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +33 -3
  2. package/README.md +142 -0
  3. package/defaults.js +1 -0
  4. package/lib/safe-json-script.js +27 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarLocale.vue +1 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +1 -0
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +3 -5
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +13 -1
  9. package/modules/@apostrophecms/asset/lib/globalIcons.js +3 -0
  10. package/modules/@apostrophecms/attachment/index.js +43 -1
  11. package/modules/@apostrophecms/color-field/index.js +7 -1
  12. package/modules/@apostrophecms/doc/index.js +11 -1
  13. package/modules/@apostrophecms/doc-type/index.js +165 -32
  14. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +1 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +104 -59
  16. package/modules/@apostrophecms/file/index.js +109 -9
  17. package/modules/@apostrophecms/i18n/i18n/de.json +0 -2
  18. package/modules/@apostrophecms/i18n/i18n/en.json +40 -1
  19. package/modules/@apostrophecms/i18n/i18n/es.json +0 -1
  20. package/modules/@apostrophecms/i18n/i18n/fr.json +0 -1
  21. package/modules/@apostrophecms/i18n/i18n/it.json +0 -1
  22. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +0 -1
  23. package/modules/@apostrophecms/i18n/i18n/sk.json +0 -1
  24. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nBatchReporting.js +18 -1
  25. package/modules/@apostrophecms/i18n/ui/apos/apps/AposI18nLocalizeActions.js +50 -0
  26. package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +56 -13
  27. package/modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue +8 -2
  28. package/modules/@apostrophecms/layout-column-widget/index.js +156 -163
  29. package/modules/@apostrophecms/layout-widget/index.js +7 -2
  30. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +6 -11
  31. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +3 -5
  32. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +4 -4
  33. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -16
  34. package/modules/@apostrophecms/layout-widget/ui/apos/lib/grid-state.mjs +7 -27
  35. package/modules/@apostrophecms/layout-widget/views/column.html +7 -9
  36. package/modules/@apostrophecms/login/index.js +39 -40
  37. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +17 -2
  38. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +3 -2
  39. package/modules/@apostrophecms/notification/ui/apos/components/AposNotification.vue +1 -0
  40. package/modules/@apostrophecms/page/index.js +2 -0
  41. package/modules/@apostrophecms/piece-type/index.js +3 -1
  42. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
  43. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +5 -0
  44. package/modules/@apostrophecms/recently-edited/index.js +831 -0
  45. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +54 -0
  46. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedCombo.vue +454 -0
  47. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilterTag.vue +75 -0
  48. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedFilters.vue +287 -0
  49. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +16 -0
  50. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedManager.vue +346 -0
  51. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedBatch.js +193 -0
  52. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedData.js +276 -0
  53. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFetch.js +199 -0
  54. package/modules/@apostrophecms/recently-edited/ui/apos/composables/useRecentlyEditedFilters.js +100 -0
  55. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +8 -4
  56. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +1 -1
  57. package/modules/@apostrophecms/styles/index.js +10 -0
  58. package/modules/@apostrophecms/styles/lib/apiRoutes.js +6 -0
  59. package/modules/@apostrophecms/styles/lib/handlers.js +5 -0
  60. package/modules/@apostrophecms/styles/lib/methods.js +9 -3
  61. package/modules/@apostrophecms/styles/lib/presets.js +119 -0
  62. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +3 -8
  63. package/modules/@apostrophecms/styles/ui/apos/composables/AposStyles.js +1 -3
  64. package/modules/@apostrophecms/styles/ui/apos/render-factory.js +29 -0
  65. package/modules/@apostrophecms/styles/ui/apos/universal/backgroundHelpers.mjs +140 -0
  66. package/modules/@apostrophecms/styles/ui/apos/universal/customRules.mjs +105 -0
  67. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +195 -15
  68. package/modules/@apostrophecms/template/index.js +22 -6
  69. package/modules/@apostrophecms/ui/ui/apos/components/AposCellContextMenu.vue +2 -0
  70. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +18 -4
  71. package/modules/@apostrophecms/ui/ui/apos/composables/useInfiniteScroll.js +91 -0
  72. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  73. package/modules/@apostrophecms/ui/ui/apos/stores/modal.js +5 -2
  74. package/modules/@apostrophecms/ui/ui/apos/utils/index.js +9 -0
  75. package/modules/@apostrophecms/url/index.js +38 -4
  76. package/modules/@apostrophecms/widget-type/index.js +22 -6
  77. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +8 -4
  78. package/package.json +19 -19
  79. package/test/files.js +129 -0
  80. package/test/layout-widget-migration.js +719 -0
  81. package/test/login-requirements.js +1 -1
  82. package/test/pieces-public-api.js +80 -0
  83. package/test/pieces.js +25 -0
  84. package/test/recently-edited.js +2311 -0
  85. package/test/schemas.js +39 -3
  86. package/test/static-build.js +642 -0
  87. package/test/styles.js +2569 -0
  88. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposLayoutColControlDialog.vue +0 -171
@@ -0,0 +1,100 @@
1
+ import { computed, ref } from 'vue';
2
+
3
+ /**
4
+ * Manages filter state for the recently-edited document manager.
5
+ *
6
+ * @param {import('vue').ComputedRef<Array>} managerFilters - computed ref of
7
+ * filter definitions
8
+ * @param {Object} [initialFilters={}] - Optional initial filter values.
9
+ * When provided (non-empty), fully replaces the default filter state
10
+ * (filter `def` values are NOT used). Values should be arrays and are
11
+ * coerced to scalar/array based on the filter's inputType.
12
+ */
13
+ export function useRecentlyEditedFilters(managerFilters, initialFilters = {}) {
14
+ const hasInitial = Object.keys(initialFilters).length > 0;
15
+ const filterState = ref(
16
+ Object.fromEntries(
17
+ (managerFilters.value || []).map(f => {
18
+ if (!hasInitial) {
19
+ return [ f.name, filterDefault(f) ];
20
+ }
21
+ return [ f.name, coerceInitialValue(f, initialFilters[f.name]) ];
22
+ })
23
+ )
24
+ );
25
+
26
+ const hasActiveFilters = computed(() => {
27
+ return Object.values(filterState.value)
28
+ .some(value => {
29
+ if (Array.isArray(value)) {
30
+ return value.length > 0;
31
+ }
32
+ return value !== null && value !== undefined && value !== '';
33
+ });
34
+ });
35
+
36
+ function updateFilter(name, value) {
37
+ filterState.value = {
38
+ ...filterState.value,
39
+ [name]: value
40
+ };
41
+ }
42
+
43
+ function updateFilters(nextState) {
44
+ filterState.value = nextState;
45
+ }
46
+
47
+ function clearFilter(name, value) {
48
+ const filter = managerFilters.value.find(f => f.name === name);
49
+ const current = filterState.value[name];
50
+ // For array-valued filters, remove just the specified value.
51
+ if (Array.isArray(current) && value !== undefined) {
52
+ filterState.value = {
53
+ ...filterState.value,
54
+ [name]: current.filter(v => v !== value)
55
+ };
56
+ return;
57
+ }
58
+ filterState.value = {
59
+ ...filterState.value,
60
+ [name]: filterDefault(filter || {})
61
+ };
62
+ }
63
+
64
+ function clearAllFilters() {
65
+ filterState.value = Object.fromEntries(
66
+ managerFilters.value.map(f => [ f.name, filterDefault(f) ])
67
+ );
68
+ }
69
+
70
+ return {
71
+ filterState,
72
+ hasActiveFilters,
73
+ updateFilter,
74
+ updateFilters,
75
+ clearFilter,
76
+ clearAllFilters
77
+ };
78
+ }
79
+
80
+ function filterDefault(filter) {
81
+ if (filter.def !== undefined) {
82
+ return filter.def;
83
+ }
84
+ return (filter.inputType || 'select') === 'checkbox' ? [] : null;
85
+ }
86
+
87
+ // Coerce an initial filter value based on the filter's inputType.
88
+ // When initialFilters is active, absent keys get empty defaults (not `def`).
89
+ function coerceInitialValue(filter, value) {
90
+ const isCheckbox = (filter.inputType || 'select') === 'checkbox';
91
+ if (value === undefined || value === null) {
92
+ return isCheckbox ? [] : null;
93
+ }
94
+ const arr = Array.isArray(value) ? value : [ value ];
95
+ if (isCheckbox) {
96
+ return arr;
97
+ }
98
+ // Scalar filter: use value only if exactly one item
99
+ return arr.length === 1 ? arr[0] : null;
100
+ }
@@ -51,10 +51,14 @@ export default {
51
51
  },
52
52
  // TODO get 'Search' server for better i18n
53
53
  placeholder() {
54
- return this.field.placeholder || {
55
- key: 'apostrophe:searchDocType',
56
- type: this.$t(this.pluralLabel)
57
- };
54
+ return this.modifiers.some(m => [ 'small', 'micro' ].includes(m))
55
+ ? {
56
+ key: 'apostrophe:search'
57
+ }
58
+ : {
59
+ key: 'apostrophe:searchDocType',
60
+ type: this.$t(this.pluralLabel)
61
+ };
58
62
  },
59
63
  // TODO get 'Browse' for better i18n
60
64
  browseLabel() {
@@ -26,7 +26,7 @@ export default {
26
26
  default: null
27
27
  },
28
28
  uid: {
29
- type: Number,
29
+ type: [ String, Number ],
30
30
  required: true
31
31
  },
32
32
  modifiers: {
@@ -87,6 +87,16 @@ module.exports = {
87
87
  self.prependNodes('body', 'stylesheet');
88
88
  self.prependNodes('body', 'ui');
89
89
 
90
+ // Detect if any top-level style field uses a background preset
91
+ // (which includes image relationships requiring attachment annotation).
92
+ // A hack until we analyze the relationship/attachment racing
93
+ // problem more thoroughly and implement a more robust solution.
94
+ // This signals the API render route and post save hook to explicitly
95
+ // re-annotate attachments.
96
+ self.hasBackgroundFields = self.schema.some(
97
+ field => field.customType === 'background'
98
+ );
99
+
90
100
  // Removes some automatically added top level groups and
91
101
  // provides a default group if none are provided.
92
102
  const nonFields = [ 'archived' ];
@@ -40,6 +40,12 @@ module.exports = self => {
40
40
  const piece = {};
41
41
  await self.convert(req, req.body.data, piece);
42
42
 
43
+ // Re-annotate attachments so that relationship _fields
44
+ // (crop/focal-point) are reflected in _urls for CSS generation.
45
+ if (self.hasBackgroundFields) {
46
+ self.apos.attachment.all([ piece ], { annotate: true });
47
+ }
48
+
43
49
  req.res.setHeader('Content-Type', 'text/css');
44
50
 
45
51
  return self.getStylesheet(piece);
@@ -23,6 +23,11 @@ node app @apostrophecms-pro/palette:migrate-to-styles
23
23
  afterSave: {
24
24
  async mirrorToGlobal(req, doc, options) {
25
25
  // mirror the stylesheet to @apostrophecms/global
26
+ // Re-annotate attachments so that relationship _fields
27
+ // (crop/focal-point) are reflected in _urls for CSS generation.
28
+ if (self.hasBackgroundFields) {
29
+ self.apos.attachment.all([ doc ], { annotate: true });
30
+ }
26
31
  const { css, classes } = self.getStylesheet(doc);
27
32
  const $set = {
28
33
  stylesStylesheet: css,
@@ -125,6 +125,8 @@ module.exports = (self, options) => {
125
125
  });
126
126
  }
127
127
  if (!hasLink) {
128
+ // Prevent an XSS attack even if a styles exploit is found
129
+ const css = (req.data.global.stylesStylesheet || '').replaceAll('</', '<\\/');
128
130
  nodes.push({
129
131
  name: 'style',
130
132
  attrs: {
@@ -132,7 +134,7 @@ module.exports = (self, options) => {
132
134
  },
133
135
  body: [
134
136
  {
135
- raw: req.data.global.stylesStylesheet || ''
137
+ raw: css
136
138
  }
137
139
  ]
138
140
  });
@@ -156,7 +158,8 @@ module.exports = (self, options) => {
156
158
  // to the <body> element (`class` attribute).
157
159
  getStylesheet(doc) {
158
160
  return self.stylesheetGlobalRender(self.schema, doc, {
159
- checkIfConditionsFn: self.styleCheckIfConditions
161
+ checkIfConditionsFn: self.styleCheckIfConditions,
162
+ imageSizes: self.apos.attachment.imageSizes
160
163
  });
161
164
  },
162
165
  // Returns object with `css` (string), `inline` (string) and `classes` (array)
@@ -172,7 +175,8 @@ module.exports = (self, options) => {
172
175
  getWidgetStylesheet(schema, doc, options = {}) {
173
176
  return self.stylesheetScopedRender(schema, doc, {
174
177
  ...options,
175
- checkIfConditionsFn: self.styleCheckIfConditions
178
+ checkIfConditionsFn: self.styleCheckIfConditions,
179
+ imageSizes: self.apos.attachment.imageSizes
176
180
  });
177
181
  },
178
182
  // Generate unique ID, Invoke the widget owned `getStylesheet` method
@@ -229,6 +233,8 @@ module.exports = (self, options) => {
229
233
  transform: assetOptions.transform || null
230
234
  });
231
235
  }
236
+ // Prevent an XSS attack even if a styles exploit is found
237
+ css = css.replaceAll('</', '<\\/');
232
238
  return `<style data-apos-widget-style-for="${widgetId}" data-apos-widget-style-id="${styleId}">\n` +
233
239
  css +
234
240
  '\n</style>';
@@ -179,6 +179,125 @@ module.exports = (moduleOptions) => {
179
179
  }
180
180
  }
181
181
  }
182
+ },
183
+ background: {
184
+ label: 'apostrophe:styleBackground',
185
+ type: 'object',
186
+ customType: 'background',
187
+ options: {
188
+ flat: true,
189
+ hideLabel: true
190
+ },
191
+ property: '--preset-bg',
192
+ fields: {
193
+ add: {
194
+ enabled: {
195
+ label: 'apostrophe:styleBackground',
196
+ type: 'boolean',
197
+ def: false
198
+ },
199
+ backgroundType: {
200
+ label: 'apostrophe:styleBackgroundType',
201
+ type: 'select',
202
+ def: 'color',
203
+ if: { enabled: true },
204
+ choices: [
205
+ {
206
+ label: 'apostrophe:styleBackgroundColor',
207
+ value: 'color'
208
+ },
209
+ {
210
+ label: 'apostrophe:styleBackgroundGradient',
211
+ value: 'gradient'
212
+ },
213
+ {
214
+ label: 'apostrophe:styleBackgroundImage',
215
+ value: 'image'
216
+ }
217
+ ]
218
+ },
219
+ color: {
220
+ label: 'apostrophe:styleBackgroundColor',
221
+ type: 'color',
222
+ if: {
223
+ enabled: true,
224
+ backgroundType: 'color'
225
+ }
226
+ },
227
+ gradientStart: {
228
+ label: 'apostrophe:styleGradientStart',
229
+ type: 'color',
230
+ def: '#000000',
231
+ if: {
232
+ enabled: true,
233
+ backgroundType: 'gradient'
234
+ }
235
+ },
236
+ gradientEnd: {
237
+ label: 'apostrophe:styleGradientEnd',
238
+ type: 'color',
239
+ def: '#ffffff',
240
+ if: {
241
+ enabled: true,
242
+ backgroundType: 'gradient'
243
+ }
244
+ },
245
+ gradientAngle: {
246
+ label: 'apostrophe:styleGradientAngle',
247
+ type: 'range',
248
+ min: 0,
249
+ max: 360,
250
+ step: 5,
251
+ def: 180,
252
+ unit: 'deg',
253
+ if: {
254
+ enabled: true,
255
+ backgroundType: 'gradient'
256
+ }
257
+ },
258
+ _image: {
259
+ label: 'apostrophe:styleBackgroundImage',
260
+ type: 'relationship',
261
+ withType: '@apostrophecms/image',
262
+ max: 1,
263
+ if: {
264
+ enabled: true,
265
+ backgroundType: 'image'
266
+ }
267
+ },
268
+ overlay: {
269
+ label: 'apostrophe:styleBackgroundOverlay',
270
+ type: 'boolean',
271
+ def: false,
272
+ if: {
273
+ enabled: true,
274
+ backgroundType: 'image'
275
+ }
276
+ },
277
+ overlayColor: {
278
+ label: 'apostrophe:styleOverlayColor',
279
+ type: 'color',
280
+ def: '#000000',
281
+ if: {
282
+ enabled: true,
283
+ backgroundType: 'image',
284
+ overlay: true
285
+ }
286
+ },
287
+ overlayOpacity: {
288
+ label: 'apostrophe:styleOverlayOpacity',
289
+ type: 'range',
290
+ min: 0,
291
+ max: 100,
292
+ def: 50,
293
+ if: {
294
+ enabled: true,
295
+ backgroundType: 'image',
296
+ overlay: true
297
+ }
298
+ }
299
+ }
300
+ }
182
301
  }
183
302
  };
184
303
  };
@@ -85,9 +85,8 @@
85
85
  // via alias `Modules/@apostrophecms/ui/schema/universal/check-if-conditions.mjs`.
86
86
  // Replace the backend imports to import
87
87
  // from `../path-to/@apostrophecms/ui/apos/schema/universal/check-if-conditions.mjs`.
88
- import checkIfConditions from 'apostrophe/lib/universal/check-if-conditions.mjs';
89
88
  import AposThemeMixin from 'Modules/@apostrophecms/ui/mixins/AposThemeMixin';
90
- import renderCss from 'Modules/@apostrophecms/styles/universal/render.mjs';
89
+ import { renderGlobalStyles as renderCss } from 'Modules/@apostrophecms/styles/render-factory.js';
91
90
  import { klona } from 'klona';
92
91
  import breakpointPreviewTransformer from 'postcss-viewport-to-container-toggle/standalone.js';
93
92
  import { useDraggableWindow } from 'Modules/@apostrophecms/ui/composables/useDraggableWindow.js';
@@ -342,9 +341,7 @@ export default {
342
341
  if (Object.keys(this.docFields.data).length === 0) {
343
342
  return;
344
343
  }
345
- const { classes } = renderCss(this.schema, this.docFields.data, {
346
- checkIfConditionsFn: checkIfConditions
347
- });
344
+ const { classes } = renderCss(this.schema, this.docFields.data);
348
345
  document.body.setAttribute(this.bodyAttr, classes.join(' '));
349
346
  },
350
347
  fillGroupData(group) {
@@ -425,9 +422,7 @@ export default {
425
422
  },
426
423
  async renderBrowserSide() {
427
424
  await this.setStyleMarkup(
428
- renderCss(this.schema, this.docFields.data, {
429
- checkIfConditionsFn: checkIfConditions
430
- })
425
+ renderCss(this.schema, this.docFields.data)
431
426
  );
432
427
  },
433
428
  async setStyleMarkup({ css, classes }) {
@@ -3,8 +3,7 @@ import {
3
3
  } from 'vue';
4
4
  import { createId } from '@paralleldrive/cuid2';
5
5
  import { isEqual } from 'lodash';
6
- import { renderScopedStyles } from 'Modules/@apostrophecms/styles/universal/render.mjs';
7
- import checkIfConditions from 'apostrophe/lib/universal/check-if-conditions.mjs';
6
+ import { renderScopedStyles } from 'Modules/@apostrophecms/styles/render-factory.js';
8
7
  import breakpointPreviewTransformer from 'postcss-viewport-to-container-toggle/standalone.js';
9
8
 
10
9
  export function useAposStyles() {
@@ -28,7 +27,6 @@ export function useAposStyles() {
28
27
 
29
28
  const styles = renderScopedStyles(schema, doc, {
30
29
  rootSelector: `#${widgetId.value}`,
31
- checkIfConditionsFn: checkIfConditions,
32
30
  subset: stylesFields
33
31
  });
34
32
 
@@ -0,0 +1,29 @@
1
+ import { createRenderer } from './universal/render.mjs';
2
+ import checkIfConditions from 'apostrophe/lib/universal/check-if-conditions.mjs';
3
+
4
+ let instance = null;
5
+
6
+ export function getRenderer(overrides = {}) {
7
+ if (Object.keys(overrides).length) {
8
+ return createRenderer({
9
+ checkIfConditionsFn: checkIfConditions,
10
+ imageSizes: window.apos.attachment.imageSizes,
11
+ ...overrides
12
+ });
13
+ }
14
+ if (!instance) {
15
+ instance = createRenderer({
16
+ checkIfConditionsFn: checkIfConditions,
17
+ imageSizes: window.apos.attachment.imageSizes
18
+ });
19
+ }
20
+ return instance;
21
+ }
22
+
23
+ export function renderGlobalStyles(schema, doc, options = {}) {
24
+ return getRenderer().renderGlobalStyles(schema, doc, options);
25
+ }
26
+
27
+ export function renderScopedStyles(schema, doc, options = {}) {
28
+ return getRenderer().renderScopedStyles(schema, doc, options);
29
+ }
@@ -0,0 +1,140 @@
1
+ // Hardcoded fallback widths — used only when imageSizes is not provided
2
+ const KNOWN_SIZE_WIDTHS = {
3
+ 'one-sixth': 190,
4
+ 'one-third': 380,
5
+ 'one-half': 570,
6
+ 'two-thirds': 760,
7
+ full: 1140,
8
+ max: 1600
9
+ };
10
+
11
+ export function extractImageData(value) {
12
+ if (!value || value.group !== 'images') {
13
+ return null;
14
+ }
15
+
16
+ const urls = value._urls;
17
+ if (!urls || !Object.keys(urls).length) {
18
+ return null;
19
+ }
20
+
21
+ return urls;
22
+ }
23
+
24
+ function getSizeWidths(imageSizes) {
25
+ if (!Array.isArray(imageSizes) || !imageSizes.length) {
26
+ return KNOWN_SIZE_WIDTHS;
27
+ }
28
+ const map = {};
29
+ for (const size of imageSizes) {
30
+ map[size.name] = size.width;
31
+ }
32
+ return map;
33
+ }
34
+
35
+ // Assume 2× device-pixel-ratio when selecting images for a
36
+ // breakpoint. Covers the vast majority of modern displays
37
+ // (retina / HiDPI) without needing resolution media queries.
38
+ const DPR_FACTOR = 2;
39
+
40
+ // Internal breakpoints for responsive background images.
41
+ // Sorted ascending — each breakpoint generates a non-overlapping
42
+ // range query so there is no cascade/specificity dependency.
43
+ const BREAKPOINTS = [
44
+ {
45
+ maxWidth: 480,
46
+ label: 'mobile'
47
+ },
48
+ {
49
+ maxWidth: 768,
50
+ label: 'tablet'
51
+ }
52
+ ];
53
+
54
+ // For a given breakpoint width, find the best image URL.
55
+ // Multiplies the target by DPR_FACTOR so that retina/HiDPI
56
+ // displays get enough pixel data for crisp rendering.
57
+ // Falls back to the largest available image when nothing qualifies.
58
+ function bestUrlForWidth(entries, targetWidth) {
59
+ const adjusted = targetWidth * DPR_FACTOR;
60
+ for (const entry of entries) {
61
+ if (entry.width >= adjusted) {
62
+ return entry.url;
63
+ }
64
+ }
65
+
66
+ return entries[entries.length - 1]?.url || null;
67
+ }
68
+
69
+ export function buildResponsiveImageRules(property, urls, imageSizes) {
70
+ const sizeWidths = getSizeWidths(imageSizes);
71
+
72
+ // Build sorted entries ascending by width
73
+ const entries = [];
74
+ for (const [ name, sizedUrl ] of Object.entries(urls)) {
75
+ if (
76
+ name === 'original' ||
77
+ name === 'uncropped' ||
78
+ !sizedUrl ||
79
+ typeof sizedUrl !== 'string'
80
+ ) {
81
+ continue;
82
+ }
83
+ const width = sizeWidths[name];
84
+ if (width) {
85
+ entries.push({
86
+ url: sizedUrl,
87
+ width
88
+ });
89
+ }
90
+ }
91
+ entries.sort((a, b) => a.width - b.width);
92
+
93
+ // Use the largest sized image as the base (covers the widest viewport).
94
+ // Fall back to `original` when no sized entries exist (e.g. SVG).
95
+ const baseUrl = entries.length
96
+ ? entries[entries.length - 1].url
97
+ : urls.original;
98
+
99
+ if (!baseUrl) {
100
+ return {
101
+ rules: [],
102
+ mediaRules: []
103
+ };
104
+ }
105
+
106
+ const rules = [ `${property}: url(${baseUrl})` ];
107
+ const mediaRules = [];
108
+
109
+ if (entries.length > 1) {
110
+ for (let i = 0; i < BREAKPOINTS.length; i++) {
111
+ const bp = BREAKPOINTS[i];
112
+ const bpUrl = bestUrlForWidth(entries, bp.maxWidth);
113
+ // Skip if the breakpoint would use the same URL as the base —
114
+ // the base rule already covers it at every viewport.
115
+ if (bpUrl && bpUrl !== baseUrl) {
116
+ // First breakpoint: (width <= Npx)
117
+ // Subsequent: (Ppx < width <= Npx)
118
+ const query = i === 0
119
+ ? `(width <= ${bp.maxWidth}px)`
120
+ : `(${BREAKPOINTS[i - 1].maxWidth}px < width <= ${bp.maxWidth}px)`;
121
+ mediaRules.push({
122
+ query,
123
+ rules: [ `${property}: url(${bpUrl})` ]
124
+ });
125
+ }
126
+ }
127
+ }
128
+
129
+ return {
130
+ rules,
131
+ mediaRules
132
+ };
133
+ }
134
+
135
+ export function hexToRgba(hex, opacity) {
136
+ const r = parseInt(hex.slice(1, 3), 16);
137
+ const g = parseInt(hex.slice(3, 5), 16);
138
+ const b = parseInt(hex.slice(5, 7), 16);
139
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
140
+ }
@@ -1,3 +1,9 @@
1
+ import {
2
+ extractImageData,
3
+ buildResponsiveImageRules,
4
+ hexToRgba
5
+ } from './backgroundHelpers.mjs';
6
+
1
7
  export default {
2
8
  /**
3
9
  * Custom render functions for fields with nuanced data structures
@@ -60,5 +66,104 @@ export default {
60
66
  field,
61
67
  rule
62
68
  };
69
+ },
70
+
71
+ background: function ({
72
+ field, subfields, options
73
+ }) {
74
+ const varBase = field.raw.property || '--preset-bg';
75
+ const bgType = subfields.backgroundType?.value || 'color';
76
+
77
+ const PROCESSED = [
78
+ 'enabled', 'backgroundType',
79
+ 'color',
80
+ 'gradientStart', 'gradientEnd', 'gradientAngle',
81
+ '_image',
82
+ 'overlay', 'overlayColor', 'overlayOpacity'
83
+ ];
84
+
85
+ // Color Mode
86
+ if (bgType === 'color') {
87
+ const color = subfields.color?.value;
88
+ if (!color) {
89
+ return {
90
+ field,
91
+ rules: [],
92
+ processedFields: PROCESSED
93
+ };
94
+ }
95
+ return {
96
+ field,
97
+ rules: [ `background-color: ${color}` ],
98
+ processedFields: PROCESSED
99
+ };
100
+ }
101
+
102
+ // Gradient Mode
103
+ if (bgType === 'gradient') {
104
+ const start = subfields.gradientStart?.value || '#000000';
105
+ const end = subfields.gradientEnd?.value || '#ffffff';
106
+ const angle = subfields.gradientAngle?.value ?? 180;
107
+ const unit = subfields.gradientAngle?.unit || 'deg';
108
+ return {
109
+ field,
110
+ rules: [ `background: linear-gradient(${angle}${unit}, ${start}, ${end})` ],
111
+ processedFields: PROCESSED
112
+ };
113
+ }
114
+
115
+ // Image Mode
116
+ const rel = subfields._image?.value;
117
+ const urls = extractImageData(rel?.[0]?.attachment);
118
+ if (!urls) {
119
+ return {
120
+ field,
121
+ rules: [],
122
+ processedFields: PROCESSED
123
+ };
124
+ }
125
+
126
+ const rules = [];
127
+
128
+ // --- CSS Variable Export ---
129
+ const responsive = buildResponsiveImageRules(
130
+ `${varBase}-image`, urls, options.imageSizes
131
+ );
132
+ rules.push(...responsive.rules);
133
+
134
+ // --- Overlay ---
135
+ const layers = [];
136
+ const overlay = subfields.overlay?.value;
137
+ const overlayColor = subfields.overlayColor?.value || '#000000';
138
+ const overlayOpacity = (subfields.overlayOpacity?.value ?? 50) / 100;
139
+
140
+ if (overlay && overlayColor) {
141
+ const rgba = hexToRgba(overlayColor, overlayOpacity);
142
+ rules.push(`${varBase}-overlay: linear-gradient(${rgba}, ${rgba})`);
143
+ layers.push(`var(${varBase}-overlay-layer, var(${varBase}-overlay))`);
144
+ }
145
+
146
+ // --- Override Hook Reset ---
147
+ // Reset inherited hook values so nested widgets aren't affected
148
+ // by a parent's blur recipe setting these to `none`.
149
+ rules.push(`${varBase}-image-layer: initial`);
150
+ rules.push(`${varBase}-overlay-layer: initial`);
151
+
152
+ // --- Image Layer ---
153
+ const imageFallback =
154
+ `var(${varBase}-image) center / cover no-repeat`;
155
+ layers.push(`var(${varBase}-image-layer, ${imageFallback})`);
156
+
157
+ // --- Composite background shorthand ---
158
+ rules.push(`background: ${layers.join(', ')}`);
159
+
160
+ return {
161
+ field,
162
+ rules,
163
+ processedFields: PROCESSED,
164
+ ...responsive.mediaRules.length > 0 && {
165
+ mediaRules: responsive.mediaRules
166
+ }
167
+ };
63
168
  }
64
169
  };