Skip to content

Commit 381591f

Browse files
committed
Merge branch 'main' into pro-8255-manager-locale-switcher
* main: enforce empty state font (#5113) sticky controls (#5106) Layout widget icon (#5112) Feature layout widget (#5031) PRO-8435: log what widget type was missing (#5110) fix changelog (#5108) take aspect ratio into account to calculate min size constrain (#5087) PRO-8343: render-areas=inline support when using an external frontend (#5092)
2 parents 80a96aa + 544f93f commit 381591f

50 files changed

Lines changed: 19364 additions & 1209 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.npmrc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
public-hoist-pattern[]=*

‎CHANGELOG.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,21 @@
55
### Adds
66

77
* Add locale picker in the page and piece manager modals.
8-
9-
### Fixes
8+
* Support for the `render-areas` query parameter in the REST API when using Astro as an external frontend, provided the Astro project has the corresponding route. This allows section template library previews to work in Astro projects. For ease of migration, if Astro cannot satisfy the request, ApostropheCMS will also attempt to render the widget natively.
9+
* Made `self.apos.externalFrontKey` available, simplifying API calls back to Astro.
10+
* Layout widget for dynamic grid layouts.
11+
* `widgetOperations` support for `placement: 'breadcrumb'` to add operations to the breadcrumb menu of widgets. Extend the widget operations configuration to support various features when in the breadcrumb menu.
12+
* Area template (Nunjucks) support for `aposStyle`, `aposClassName`, `aposParentOptions` and `aposAttrs` contextual named variables (`with {}` syntax).
1013

1114
### Changes
1215

13-
* Refactors complex logic from `AposSchema` that handle data updates to simplifies it.
16+
* Refactored complex logic regarding data updates in `AposSchema`.
17+
* Cleaned up `annotateAreaForExternalFront` logic and added context so developers understand the reason if it fails due to a widget type with no matching module in the project.
18+
19+
### Fixes
20+
21+
* The `render-areas` query parameter now works correctly with areas nested in array items.
22+
* Fix min size calculation for image widgets configured with an aspect ratio.
1423

1524
## 4.22.0 (2025-10-01)
1625

‎defaults.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ module.exports = {
4141
'@apostrophecms/search': {},
4242
'@apostrophecms/any-page-type': {},
4343
'@apostrophecms/area': {},
44+
'@apostrophecms/layout-widget': {},
45+
'@apostrophecms/layout-column-widget': {},
4446
'@apostrophecms/rich-text-widget': {},
4547
'@apostrophecms/html-widget': {},
4648
'@apostrophecms/color-field': {},

‎lib/image.js‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
function computeMinSizes([ minWidth, minHeight ], aspectRatio) {
2+
const aspectRatioFloat = Array.isArray(aspectRatio)
3+
? aspectRatio[0] / aspectRatio[1]
4+
: aspectRatio;
5+
6+
if (!aspectRatioFloat) {
7+
return {
8+
minWidth,
9+
minHeight
10+
};
11+
}
12+
13+
// If ratio wants a square,
14+
// we simply take the higher min size
15+
if (aspectRatioFloat === 1) {
16+
const higherValue = Math.max(minWidth, minHeight);
17+
18+
return {
19+
minWidth: higherValue,
20+
minHeight: higherValue
21+
};
22+
}
23+
24+
const diff = minWidth / minHeight - aspectRatioFloat;
25+
26+
if (diff > 0) {
27+
return {
28+
minWidth,
29+
minHeight: minWidth / aspectRatioFloat
30+
};
31+
}
32+
33+
if (diff < 0) {
34+
return {
35+
minWidth: minHeight * aspectRatioFloat,
36+
minHeight
37+
};
38+
}
39+
40+
return {
41+
minWidth,
42+
minHeight
43+
};
44+
};
45+
46+
module.exports = {
47+
computeMinSizes
48+
};

‎modules/@apostrophecms/area/index.js‎

Lines changed: 98 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const _ = require('lodash');
22
const { stripIndent } = require('common-tags');
3+
const cheerio = require('cheerio');
34

45
// An area is a series of zero or more widgets, in which users can add
56
// and remove widgets and drag them to reorder them. This module implements
@@ -270,7 +271,9 @@ module.exports = {
270271
self.missingWidgetTypes = {};
271272
}
272273
if (!self.missingWidgetTypes[name]) {
273-
self.apos.util.error('WARNING: widget type ' + name + ' exists in content but is not configured');
274+
self.apos.util.error(`WARNING: widget type ${name} exists in your database but is not configured.\n` +
275+
`You probably do not have a ${name}-widget module in your project.`
276+
);
274277
self.missingWidgetTypes[name] = true;
275278
}
276279
},
@@ -292,6 +295,10 @@ module.exports = {
292295
// If `inline` is true then the rendering of each widget is attached
293296
// to the widget as a `_rendered` property, bypassing normal full-area
294297
// HTML responses, and the return value of this method is `null`.
298+
//
299+
// If an external front key is configured, ApostropheCMS will attempt
300+
// to render the widget via Astro before attempting to render it
301+
// natively.
295302
async renderArea(req, area, _with, { inline = false } = {}) {
296303
if (!area._id) {
297304
throw new Error('All areas must have an _id property in A3.x. Area details:\n\n' + JSON.stringify(area));
@@ -337,27 +344,86 @@ module.exports = {
337344
// just use the helpers
338345
self.apos.attachment.all(area, { annotate: true });
339346
}
340-
if (inline) {
341-
for (const item of area.items) {
342-
item._rendered = await self.renderWidget(
343-
req,
344-
item.type,
345-
item,
346-
widgets[item.type]
347-
);
347+
348+
let externalError = null;
349+
if (!self.apos.externalFrontKey) {
350+
return renderNatively();
351+
}
352+
353+
try {
354+
return await renderViaExternalFront();
355+
} catch (e) {
356+
externalError = e;
357+
}
358+
try {
359+
return await renderNatively();
360+
} catch (e) {
361+
throw new Error('Could not render area for API, neither via the external frontend nor natively.\n\n' +
362+
'Check your Astro server logs as well.\n\n' +
363+
niceError(externalError) + '\n\n' +
364+
niceError(e)
365+
);
366+
}
367+
368+
async function renderViaExternalFront() {
369+
if (!self.apos.baseUrl && self.apos.externalFrontKey) {
370+
throw new Error('APOS_BASE_URL and APOS_EXTERNAL_FRONT_KEY must both be set in order to render\nvia the external frontend');
371+
}
372+
// Astro can render components or return JSON but not both, at least not without
373+
// using its experimental container API which would potentially not have
374+
// the same configuration as the main Astro project. So we let Astro be Astro,
375+
// then we pull out the individual renderings with Cheerio. -Tom
376+
const response = await fetch(`${self.apos.baseUrl}/api/apos-external-front/render-area`, {
377+
method: 'POST',
378+
headers: {
379+
'apos-external-front-key': self.apos.externalFrontKey
380+
},
381+
body: JSON.stringify({
382+
area
383+
})
384+
});
385+
if (response.status >= 400) {
386+
throw response;
387+
}
388+
const html = await response.text();
389+
const $ = cheerio.load(`<div id="root">${html}</div>`);
390+
if (inline) {
391+
for (let i = 0; (i < area.items.length); i++) {
392+
area.items[i]._rendered = $(`#root [data-widget-id="${area.items[i]._id}"]`).html() || '';
393+
}
394+
return null;
395+
} else {
396+
const $children = $('#root [data-widget-id]');
397+
return $children.map(function() {
398+
return $(this).html();
399+
}).join('\n');
400+
}
401+
}
402+
403+
async function renderNatively() {
404+
if (inline) {
405+
for (const item of area.items) {
406+
item._rendered = await self.renderWidget(
407+
req,
408+
item.type,
409+
item,
410+
widgets[item.type]
411+
);
412+
}
413+
return null;
414+
} else {
415+
return self.render(req, 'area', {
416+
// TODO filter area to exclude big relationship objects, but
417+
// not so sloppy this time please
418+
area,
419+
field,
420+
options,
421+
choices,
422+
_with,
423+
canEdit
424+
});
348425
}
349-
return null;
350426
}
351-
return self.render(req, 'area', {
352-
// TODO filter area to exclude big relationship objects, but
353-
// not so sloppy this time please
354-
area,
355-
field,
356-
options,
357-
choices,
358-
_with,
359-
canEdit
360-
});
361427
},
362428
// Replace documents' area objects with rendered HTML for each area.
363429
// This is used by GET requests including the `render-areas` query
@@ -373,6 +439,10 @@ module.exports = {
373439
let index = 0;
374440
// Loop over the docs in the array passed in.
375441
for (const doc of within) {
442+
if (self.apos.externalFrontKey) {
443+
self.apos.template.annotateDocForExternalFront(doc);
444+
}
445+
376446
const rendered = [];
377447

378448
const areasToRender = {};
@@ -409,8 +479,8 @@ module.exports = {
409479
index++;
410480
}
411481

412-
async function render(area, path, context, opts) {
413-
const preppedArea = self.prepForRender(area, context, path);
482+
async function render(area, path, context) {
483+
const preppedArea = self.prepForRender(area, context, path.split('.').at(-1));
414484

415485
const areaRendered = await self.apos.area.renderArea(
416486
req,
@@ -903,3 +973,9 @@ module.exports = {
903973
};
904974
}
905975
};
976+
977+
function niceError(e) {
978+
// Node.js includes the error message in the stack property, it's
979+
// actually a complete rendering plus the stack 🤷
980+
return e.stack;
981+
}

‎modules/@apostrophecms/area/ui/apos/apps/AposAreas.js‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import createApp from 'Modules/@apostrophecms/ui/lib/vue';
33

44
export default function() {
55

6-
const component = apos.vueComponents.AposAreaEditor;
7-
86
let widgetsRendering = 0;
97

108
apos.area.widgetOptions = [];
@@ -66,13 +64,25 @@ export default function() {
6664
}
6765

6866
function createAreaApp(el) {
69-
const options = JSON.parse(el.getAttribute('data-options'));
70-
const data = JSON.parse(el.getAttribute('data'));
67+
const options = JSON.parse(el.getAttribute('data-options')) || {};
68+
const data = JSON.parse(el.getAttribute('data')) || {};
7169
const fieldId = el.getAttribute('data-field-id');
70+
const moduleName = el.getAttribute('data-module');
7271
const choices = JSON.parse(el.getAttribute('data-choices'));
7372
const renderings = {};
7473
const _docId = data._docId;
7574

75+
const parentOptionsStr = el.getAttribute('data-parent-options');
76+
const parentOptions = parentOptionsStr ? JSON.parse(parentOptionsStr) : null;
77+
78+
let componentName = options.editorComponent || 'AposAreaEditor';
79+
if (!apos.vueComponents[componentName]) {
80+
// eslint-disable-next-line no-console
81+
console.error(`Area Editor component "${componentName}" not found. Switching to default.`);
82+
componentName = 'AposAreaEditor';
83+
}
84+
const component = apos.vueComponents[componentName];
85+
7686
for (const widgetEl of el.querySelectorAll('[data-apos-widget]')) {
7787
const _id = widgetEl.getAttribute('data-apos-widget');
7888
const item = data.items.find(item => _id === item._id);
@@ -109,6 +119,8 @@ export default function() {
109119
choices,
110120
docId: _docId,
111121
fieldId,
122+
moduleName,
123+
parentOptions,
112124
renderings
113125
});
114126

0 commit comments

Comments
 (0)