@eeacms/volto-cca-policy 0.3.121 → 0.3.122

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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ ### [0.3.122](https://github.com/eea/volto-cca-policy/compare/0.3.121...0.3.122) - 8 May 2026
8
+
9
+ #### :bug: Bug Fixes
10
+
11
+ - fix: add image fallback & improvements in RSS middleware [kreafox - [`6e6091a`](https://github.com/eea/volto-cca-policy/commit/6e6091a2e72c53fd37489fc959f5c1083e99c84e)]
12
+
13
+ #### :house: Internal changes
14
+
15
+ - style: Automated code fix [eea-jenkins - [`278f8b4`](https://github.com/eea/volto-cca-policy/commit/278f8b49ae0c56ce7a705a2eea8a6ff3373e6dff)]
16
+
17
+ #### :house: Documentation changes
18
+
19
+ - docs: update README.md [kreafox - [`5d810e1`](https://github.com/eea/volto-cca-policy/commit/5d810e1385c22d481fd14977c8c69dcecd16f70e)]
20
+
7
21
  ### [0.3.121](https://github.com/eea/volto-cca-policy/compare/0.3.120...0.3.121) - 7 May 2026
8
22
 
9
23
  #### :rocket: New Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-cca-policy",
3
- "version": "0.3.121",
3
+ "version": "0.3.122",
4
4
  "description": "@eeacms/volto-cca-policy: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -1,3 +1,5 @@
1
- ## RSS sort_order customization
1
+ ## RSS middleware customization
2
2
 
3
3
  This customization patches the `volto-rss-provider` middleware to correctly handle reversed sorting in RSS Listing block queries.
4
+
5
+ It also fixes image handling so items with small lead images or missing `preview` scales do not break the entire feed; those items remain in the RSS output, but their image enclosure is skipped.
@@ -1,7 +1,7 @@
1
1
  import express from 'express';
2
2
  import superagent from 'superagent';
3
3
  import RSS from 'rss';
4
- import { findBlocks, toPublicURL } from '@plone/volto/helpers';
4
+ import { findBlocks } from '@plone/volto/helpers';
5
5
  import config from '@plone/volto/registry';
6
6
 
7
7
  /**
@@ -26,75 +26,82 @@ import config from '@plone/volto/registry';
26
26
  *
27
27
  * @function getRssFeedData
28
28
  * @param {string} apiPath - The base path for the API requests.
29
- * @param {string} APISUFIX - The suffix added to the API path depending on the environment.
29
+ * @param {string} APISUFFIX - The suffix added to the API path depending on the environment.
30
30
  * @param {Object} req - The incoming Express request object.
31
31
  * @param {Object} settings - Configuration settings for the application.
32
32
  * @return {Object} An object containing the query data, language, description, and title of the rss_feed.
33
33
  * @throws Will throw an error if no query data is found in the listing block or if the request fails.
34
34
  */
35
- async function getRssFeedData(apiPath, APISUFIX, req, settings) {
36
- try {
37
- const request = superagent
38
- .get(
39
- `${apiPath}${__DEVELOPMENT__ ? '' : APISUFIX}${req.path.replace(
40
- '/rss.xml',
41
- '',
42
- )}`,
43
- )
44
- .accept('json');
35
+ async function getRssFeedData(apiPath, APISUFFIX, req, settings) {
36
+ const request = superagent
37
+ .get(
38
+ `${apiPath}${__DEVELOPMENT__ ? '' : APISUFFIX}${req.path.replace(
39
+ '/rss.xml',
40
+ '',
41
+ )}`,
42
+ )
43
+ .accept('json');
45
44
 
46
- const authToken = req.universalCookies.get('auth_token');
47
- if (authToken) {
48
- request.set('Authorization', `Bearer ${authToken}`);
49
- }
45
+ const authToken = req.universalCookies.get('auth_token');
46
+ if (authToken) {
47
+ request.set('Authorization', `Bearer ${authToken}`);
48
+ }
50
49
 
51
- const response = await request;
52
- const json = JSON.parse(JSON.stringify(response.body));
53
- const listingBlock = findBlocks(json.blocks, 'listing');
54
- let queryData = json.blocks[listingBlock]?.querystring;
55
- let language = json.language.token ?? 'en';
56
- let description = json.description ?? 'A Volto RSS Feed';
57
- let title = json.title;
58
- let subjects = json.subjects;
59
- let date = json.effective;
60
- let max_description_length = json.max_description_length;
61
- let max_title_length = json.max_title_length;
62
- if (!queryData) {
63
- throw new Error('No query data found in listing block');
64
- }
50
+ const response = await request;
51
+ const json = response.body;
52
+ const listingBlocks = findBlocks(json.blocks, 'listing');
53
+ const listingBlockId = Array.isArray(listingBlocks)
54
+ ? listingBlocks[0]
55
+ : listingBlocks;
65
56
 
66
- if (queryData?.sort_order !== null) {
67
- if (typeof queryData.sort_order === 'boolean') {
68
- queryData.sort_order = queryData.sort_order ? 'reverse' : 'ascending';
69
- }
57
+ const queryData = listingBlockId
58
+ ? json.blocks?.[listingBlockId]?.querystring
59
+ : null;
60
+ const language = json.language?.token ?? 'en';
61
+ const description = json.description ?? 'A Volto RSS Feed';
62
+ const title = json.title;
63
+ const subjects = json.subjects;
64
+ const date = json.effective;
65
+ const max_description_length = json.max_description_length;
66
+ const max_title_length = json.max_title_length;
67
+ if (!queryData) {
68
+ throw new Error('No query data found in listing block');
69
+ }
70
70
 
71
- if (queryData.sort_order === 'descending') {
72
- queryData.sort_order = 'reverse';
73
- }
71
+ const normalizedQueryData = { ...queryData };
72
+
73
+ if (normalizedQueryData.sort_order != null) {
74
+ if (typeof normalizedQueryData.sort_order === 'boolean') {
75
+ normalizedQueryData.sort_order = normalizedQueryData.sort_order
76
+ ? 'reverse'
77
+ : 'ascending';
74
78
  }
75
79
 
76
- let query = {
77
- ...queryData,
78
- ...(!queryData.b_size && {
79
- b_size: settings.defaultPageSize,
80
- }),
81
- query: queryData?.query,
82
- metadata_fields: '_all',
83
- b_start: 0,
84
- };
85
- return {
86
- query,
87
- language,
88
- description,
89
- title,
90
- subjects,
91
- date,
92
- max_description_length,
93
- max_title_length,
94
- };
95
- } catch (err) {
96
- throw err;
80
+ if (normalizedQueryData.sort_order === 'descending') {
81
+ normalizedQueryData.sort_order = 'reverse';
82
+ }
97
83
  }
84
+
85
+ const query = {
86
+ ...normalizedQueryData,
87
+ ...(!normalizedQueryData.b_size && {
88
+ b_size: settings.defaultPageSize,
89
+ }),
90
+ query: normalizedQueryData?.query,
91
+ metadata_fields: '_all',
92
+ b_start: 0,
93
+ };
94
+
95
+ return {
96
+ query,
97
+ language,
98
+ description,
99
+ title,
100
+ subjects,
101
+ date,
102
+ max_description_length,
103
+ max_title_length,
104
+ };
98
105
  }
99
106
 
100
107
  /**
@@ -107,26 +114,44 @@ async function getRssFeedData(apiPath, APISUFIX, req, settings) {
107
114
  * @function fetchListingItems
108
115
  * @param {Object} query - The query data used for fetching items.
109
116
  * @param {string} apiPath - The base path for the API requests.
110
- * @param {string} APISUFIX - The suffix added to the API path depending on the environment.
117
+ * @param {string} APISUFFIX - The suffix added to the API path depending on the environment.
111
118
  * @param {string} authToken - The authentication token for authorized requests.
112
119
  * @return {Array} An array of items that match the query criteria.
113
120
  * @throws Will throw an error if the request fails.
114
121
  */
115
- async function fetchListingItems(query, apiPath, APISUFIX, authToken) {
122
+ async function fetchListingItems(query, apiPath, APISUFFIX, authToken) {
123
+ const request = superagent
124
+ .post(`${apiPath}${__DEVELOPMENT__ ? '' : APISUFFIX}/@querystring-search`)
125
+ .send(query)
126
+ .accept('json');
127
+
128
+ if (authToken) {
129
+ request.set('Authorization', `Bearer ${authToken}`);
130
+ }
131
+
132
+ const response = await request;
133
+ return response.body.items || [];
134
+ }
135
+
136
+ function normalizeFeedURL(url, publicURL, apiPath) {
137
+ if (!url) return publicURL;
138
+
116
139
  try {
117
- const request = superagent
118
- .post(`${apiPath}${__DEVELOPMENT__ ? '' : APISUFIX}/@querystring-search`)
119
- .send(query)
120
- .accept('json');
140
+ const parsedUrl = new URL(url, publicURL);
141
+
142
+ if (apiPath) {
143
+ const apiOrigin = new URL(apiPath).origin;
144
+ const publicOrigin = new URL(publicURL).origin;
121
145
 
122
- if (authToken) {
123
- request.set('Authorization', `Bearer ${authToken}`);
146
+ if (parsedUrl.origin === apiOrigin) {
147
+ parsedUrl.protocol = new URL(publicOrigin).protocol;
148
+ parsedUrl.host = new URL(publicOrigin).host;
149
+ }
124
150
  }
125
151
 
126
- const response = await request;
127
- return response.body.items;
128
- } catch (err) {
129
- throw err;
152
+ return parsedUrl.toString();
153
+ } catch {
154
+ return url;
130
155
  }
131
156
  }
132
157
 
@@ -137,8 +162,16 @@ async function fetchListingItems(query, apiPath, APISUFIX, authToken) {
137
162
  * @param {number} maxLength - The maximum length of the text.
138
163
  * @return {string} The truncated text.
139
164
  */
140
- function truncateText(text, maxLength) {
141
- return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
165
+ function truncateText(text = '', maxLength) {
166
+ if (!maxLength || typeof text !== 'string') return text || '';
167
+ return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
168
+ }
169
+
170
+ function safeDate(value) {
171
+ if (!value) return undefined;
172
+ const d = new Date(value);
173
+ if (Number.isNaN(d.getTime()) || d.getTime() <= 0) return undefined;
174
+ return d;
142
175
  }
143
176
 
144
177
  /**
@@ -153,7 +186,7 @@ function truncateText(text, maxLength) {
153
186
  */
154
187
  function make_rssMiddleware() {
155
188
  const { settings } = config;
156
- const APISUFIX = settings.legacyTraverse ? '' : '/++api++';
189
+ const APISUFFIX = settings.legacyTraverse ? '' : '/++api++';
157
190
  let apiPath = '';
158
191
  const host = process.env.HOST || 'localhost';
159
192
  const port = process.env.PORT || '3000';
@@ -180,67 +213,62 @@ function make_rssMiddleware() {
180
213
  date,
181
214
  max_description_length,
182
215
  max_title_length,
183
- } = await getRssFeedData(apiPath, APISUFIX, req, settings);
216
+ } = await getRssFeedData(apiPath, APISUFFIX, req, settings);
184
217
  const items = await fetchListingItems(
185
218
  query,
186
219
  apiPath,
187
- APISUFIX,
220
+ APISUFFIX,
188
221
  req.universalCookies.get('auth_token'),
189
222
  );
190
223
  const feedOptions = {
191
224
  title: truncateText(title, max_title_length),
192
225
  description: truncateText(description, max_description_length),
193
- feed_url: `${settings.publicURL}${req.path}`,
226
+ feed_url: normalizeFeedURL(req.path, settings.publicURL, apiPath),
194
227
  site_url: settings.publicURL,
195
228
  generator: 'RSS Feed Generator',
196
229
  language: language,
197
- pubDate: new Date(date),
230
+ pubDate: safeDate(date),
231
+ categories: subjects || [],
198
232
  };
199
- if (subjects) {
200
- for (let i = 0; i < subjects.length; i++) {
201
- feedOptions.categories = subjects;
202
- }
203
- }
233
+
204
234
  const feed = new RSS(feedOptions);
205
235
 
206
236
  items.forEach((item) => {
207
- let link = toPublicURL(item['getURL']);
237
+ const link = normalizeFeedURL(item.getURL, settings.publicURL, apiPath);
208
238
  let enclosure = undefined;
209
- if (
210
- item.image_field &&
211
- item.image_scales &&
212
- item.image_scales[item.image_field]
213
- ) {
214
- const imageData = item.image_scales[item.image_field][0];
215
- const imageUrl = `${link
216
- .concat('/')
217
- .concat(
218
- item.image_scales[item.image_field][0].scales.preview.download,
219
- )}`;
220
- const mimeType = item['content-type'];
221
- const originalSize = imageData.size;
239
+
240
+ const imageData = item.image_scales?.[item.image_field]?.[0];
241
+ const previewDownload = imageData?.scales?.preview?.download;
242
+
243
+ if (previewDownload && imageData.size && imageData['content-type']) {
222
244
  enclosure = {
223
- url: imageUrl,
224
- type: mimeType,
225
- size: originalSize,
245
+ url: normalizeFeedURL(
246
+ new URL(previewDownload, link).toString(),
247
+ settings.publicURL,
248
+ apiPath,
249
+ ),
250
+ type: imageData['content-type'],
251
+ size: imageData.size,
226
252
  };
227
253
  }
254
+
228
255
  feed.item({
229
256
  title: truncateText(item.title, max_title_length),
230
- description: truncateText(item.description, max_description_length),
257
+ description: truncateText(
258
+ item.description || item.Description || '',
259
+ max_description_length,
260
+ ),
231
261
  url: link,
232
262
  guid: item.UID,
233
- date: new Date(item.effective),
234
- author: item['listCreators']
235
- ? item['listCreators'].map((creator) => creator).join(', ')
236
- : undefined,
263
+ date: safeDate(item.effective || item.created || item.modified),
264
+ author: item.listCreators?.join(', '),
237
265
  categories: item.Subject ? item.Subject : [],
238
266
  enclosure: enclosure || undefined,
239
267
  });
240
268
  });
241
269
 
242
270
  const xml = feed.xml({ indent: true });
243
- res.setHeader('content-type', 'application/atom+xml');
271
+ res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8');
244
272
  res.send(xml);
245
273
  } catch (err) {
246
274
  if (err.response && err.response.status === 401) {
@@ -275,7 +303,7 @@ function make_rssMiddleware() {
275
303
  export default function makeMiddlewares() {
276
304
  const middleware = express.Router();
277
305
  middleware.use(express.urlencoded({ extended: true }));
278
- middleware.all('**/rss.xml', make_rssMiddleware());
306
+ middleware.get('**/rss.xml', make_rssMiddleware());
279
307
 
280
308
  middleware.id = 'rss-middleware';
281
309