@eeacms/volto-cca-policy 0.3.120 → 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,39 @@ 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
+
21
+ ### [0.3.121](https://github.com/eea/volto-cca-policy/compare/0.3.120...0.3.121) - 7 May 2026
22
+
23
+ #### :rocket: New Features
24
+
25
+ - feat: improve RSSFeedView [kreafox - [`a425682`](https://github.com/eea/volto-cca-policy/commit/a4256825ede073eda95707f9e09916177b426716)]
26
+ - feat: add original file of RSSFeedView.jsx [kreafox - [`581a27f`](https://github.com/eea/volto-cca-policy/commit/581a27fd79191d6df70f94163cb511397b4445d9)]
27
+ - feat: add volto-rss-provider dependency to package.json [kreafox - [`218c1da`](https://github.com/eea/volto-cca-policy/commit/218c1da54e019bee346535c893131e07b0bcba64)]
28
+ - feat: sort_order handling in volto-rss-provider middleware [kreafox - [`bc6cf86`](https://github.com/eea/volto-cca-policy/commit/bc6cf86696b4798b9939e0615f3025dfe1ac742a)]
29
+ - feat: add original file of volto-rss-provider middleware [kreafox - [`ddf4e22`](https://github.com/eea/volto-cca-policy/commit/ddf4e2256f8e75a8944fa416e07722ecd963907b)]
30
+
31
+ #### :bug: Bug Fixes
32
+
33
+ - fix(eslint): remove unused import and clean up useClipboard [kreafox - [`9abda41`](https://github.com/eea/volto-cca-policy/commit/9abda419ff91c3344b113dd2a8289d23f3512520)]
34
+ - fix: remove unused import [kreafox - [`597075d`](https://github.com/eea/volto-cca-policy/commit/597075d0dde7dfe2b8ec9fe9edb0de08503e1ad6)]
35
+
36
+ #### :nail_care: Enhancements
37
+
38
+ - refactor: RSSFeedView [kreafox - [`a32e7ff`](https://github.com/eea/volto-cca-policy/commit/a32e7ff7c8dba96a8ab7b87f219b6f3988072b5e)]
39
+
7
40
  ### [0.3.120](https://github.com/eea/volto-cca-policy/compare/0.3.119...0.3.120) - 30 April 2026
8
41
 
9
42
  #### :bug: Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-cca-policy",
3
- "version": "0.3.120",
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",
@@ -41,6 +41,7 @@
41
41
  "@eeacms/volto-tabs-block": "^9.0.3",
42
42
  "@elastic/search-ui": "1.21.2",
43
43
  "@plone-collective/volto-authomatic": "2.0.1",
44
+ "@plone-collective/volto-rss-provider": "1.0.1",
44
45
  "@tanstack/react-table": "8.19.3",
45
46
  "d3-array": "^2.12.1",
46
47
  "jotai": "^1.6.0",
@@ -0,0 +1,5 @@
1
+ ## RSS middleware customization
2
+
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.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Document view component.
3
+ * @module components/theme/View/DefaultView
4
+ */
5
+
6
+ import React from 'react';
7
+ import PropTypes from 'prop-types';
8
+ import { isEqual } from 'lodash';
9
+ import { compose } from 'redux';
10
+ import { useDispatch, useSelector } from 'react-redux';
11
+ import { defineMessages, injectIntl } from 'react-intl';
12
+ import {
13
+ Container as SemanticContainer,
14
+ Segment,
15
+ Button,
16
+ Grid,
17
+ Label,
18
+ Icon,
19
+ } from 'semantic-ui-react';
20
+ import config from '@plone/volto/registry';
21
+ import { getSchema } from '@plone/volto/actions';
22
+ import { getWidget } from '@plone/volto/helpers/Widget/utils';
23
+ import RenderBlocks from '@plone/volto/components/theme/View/RenderBlocks';
24
+ import useClipboard from '@plone/volto/hooks/clipboard/useClipboard';
25
+ import {
26
+ hasBlocksData,
27
+ getBaseUrl,
28
+ addAppURL,
29
+ Helmet,
30
+ } from '@plone/volto/helpers';
31
+
32
+ const messages = defineMessages({
33
+ rssFeed: {
34
+ id: 'rssFeed',
35
+ defaultMessage: 'RSS Feed',
36
+ },
37
+ });
38
+
39
+ /**
40
+ * Component to display the default view.
41
+ * @function DefaultView
42
+ * @param {Object} content Content object.
43
+ * @returns {string} Markup of the component.
44
+ */
45
+ const DefaultView = (props) => {
46
+ const dispatch = useDispatch();
47
+ const { content, location } = props;
48
+ const { views } = config.widgets;
49
+ const path = getBaseUrl(location?.pathname || '');
50
+ const contentSchema = useSelector((state) => state.schema?.schema);
51
+ const [visible, setVisible] = React.useState(false);
52
+ const [, copy] = useClipboard(`${addAppURL(path)}/rss.xml`);
53
+ const fieldsetsToExclude = [
54
+ 'categorization',
55
+ 'dates',
56
+ 'ownership',
57
+ 'settings',
58
+ ];
59
+ const fieldsets = contentSchema?.fieldsets.filter(
60
+ (fs) => !fieldsetsToExclude.includes(fs.id),
61
+ );
62
+
63
+ // TL;DR: There is a flash of the non block-based view because of the reset
64
+ // of the content on route change. Subscribing to the content change at this
65
+ // level has nasty implications, so we can't watch the Redux state for loaded
66
+ // content flag here (because it forces an additional component update)
67
+ // Instead, we can watch if the content is "empty", but this has a drawback
68
+ // since the locking mechanism inserts a `lock` key before the content is there.
69
+ // So "empty" means `content` is present, but only with a `lock` key, thus the next
70
+ // ugly condition comes to life
71
+ const contentLoaded = content && !isEqual(Object.keys(content), ['lock']);
72
+
73
+ React.useEffect(() => {
74
+ content?.['@type'] &&
75
+ !hasBlocksData(content) &&
76
+ dispatch(getSchema(content['@type'], location.pathname));
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, []);
79
+
80
+ const Container =
81
+ config.getComponent({ name: 'Container' }).component || SemanticContainer;
82
+
83
+ // If the content is not yet loaded, then do not show anything
84
+ return contentLoaded ? (
85
+ hasBlocksData(content) ? (
86
+ <Container id="page-document">
87
+ <Helmet
88
+ link={[
89
+ {
90
+ rel: 'alternate',
91
+ title: props.intl.formatMessage(messages.rssFeed),
92
+ href: `${addAppURL(path)}/rss.xml`,
93
+ type: 'application/rss+xml',
94
+ },
95
+ ]}
96
+ />
97
+ <Segment>
98
+ <Button
99
+ primary
100
+ onClick={() => {
101
+ copy();
102
+ setVisible(true);
103
+ }}
104
+ >
105
+ <Icon className="ri-rss-fill" />
106
+ RSS Feed
107
+ </Button>
108
+ {visible && (
109
+ <p>
110
+ Copied! The RSS link is{' '}
111
+ <a
112
+ href={`${addAppURL(path)}/rss.xml`}
113
+ target="_blank"
114
+ rel="noopener"
115
+ >
116
+ {' '}
117
+ {`${addAppURL(path)}/rss.xml`}
118
+ </a>
119
+ </p>
120
+ )}
121
+ <div style={{ marginTop: '2em' }}>
122
+ <RenderBlocks {...props} path={path} />
123
+ </div>
124
+ </Segment>
125
+ </Container>
126
+ ) : (
127
+ <Container id="page-document">
128
+ {fieldsets?.map((fs) => {
129
+ return (
130
+ <div className="fieldset" key={fs.id}>
131
+ {fs.id !== 'default' && <h2>{fs.title}</h2>}
132
+ {fs.fields?.map((f, key) => {
133
+ let field = {
134
+ ...contentSchema?.properties[f],
135
+ id: f,
136
+ widget: getWidget(f, contentSchema?.properties[f]),
137
+ };
138
+ let Widget = views?.getWidget(field);
139
+ return f !== 'title' ? (
140
+ <Grid celled="internally" key={key}>
141
+ <Grid.Row>
142
+ <Label title={field.id}>{field.title}:</Label>
143
+ </Grid.Row>
144
+ <Grid.Row>
145
+ <Segment basic>
146
+ <Widget value={content[f]} />
147
+ </Segment>
148
+ </Grid.Row>
149
+ </Grid>
150
+ ) : (
151
+ <Widget key={key} value={content[f]} />
152
+ );
153
+ })}
154
+ </div>
155
+ );
156
+ })}
157
+ </Container>
158
+ )
159
+ ) : null;
160
+ };
161
+
162
+ /**
163
+ * Property types.
164
+ * @property {Object} propTypes Property types.
165
+ * @static
166
+ */
167
+ DefaultView.propTypes = {
168
+ /**
169
+ * Content of the object
170
+ */
171
+ content: PropTypes.shape({
172
+ /**
173
+ * Title of the object
174
+ */
175
+ title: PropTypes.string,
176
+ /**
177
+ * Description of the object
178
+ */
179
+ description: PropTypes.string,
180
+ /**
181
+ * Text of the object
182
+ */
183
+ text: PropTypes.shape({
184
+ /**
185
+ * Data of the text of the object
186
+ */
187
+ data: PropTypes.string,
188
+ }),
189
+ }).isRequired,
190
+ };
191
+
192
+ export default compose(injectIntl)(DefaultView);
@@ -0,0 +1,311 @@
1
+ import express from 'express';
2
+ import superagent from 'superagent';
3
+ import RSS from 'rss';
4
+ import { findBlocks } from '@plone/volto/helpers';
5
+ import config from '@plone/volto/registry';
6
+
7
+ /**
8
+ * Retrieves the query data (search criteria) used by the listing block of the rss_feed content type
9
+ * as well as the language, description, title, subjects (tags), date, max_description_length, and
10
+ * max_title_length of the rss_feed.
11
+ *
12
+ * The returned query object will have the following format:
13
+ * {
14
+ * query: [
15
+ * {
16
+ * i: <index>,
17
+ * o: <operator>,
18
+ * v: <value of the search criteria>
19
+ * }
20
+ * ],
21
+ * sort_order: 'ascending',
22
+ * b_size: 25,
23
+ * metadata_fields: '_all',
24
+ * b_start: 0
25
+ * }
26
+ *
27
+ * @function getRssFeedData
28
+ * @param {string} apiPath - The base path for the API requests.
29
+ * @param {string} APISUFFIX - The suffix added to the API path depending on the environment.
30
+ * @param {Object} req - The incoming Express request object.
31
+ * @param {Object} settings - Configuration settings for the application.
32
+ * @return {Object} An object containing the query data, language, description, and title of the rss_feed.
33
+ * @throws Will throw an error if no query data is found in the listing block or if the request fails.
34
+ */
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');
44
+
45
+ const authToken = req.universalCookies.get('auth_token');
46
+ if (authToken) {
47
+ request.set('Authorization', `Bearer ${authToken}`);
48
+ }
49
+
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;
56
+
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
+
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';
78
+ }
79
+
80
+ if (normalizedQueryData.sort_order === 'descending') {
81
+ normalizedQueryData.sort_order = 'reverse';
82
+ }
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
+ };
105
+ }
106
+
107
+ /**
108
+ * Fetches the listing block items based on the provided query data.
109
+ *
110
+ * The function sends a POST request to the @querystring-search endpoint with the query data
111
+ * and returns the items that match the search criteria.
112
+ * ref: https://6.docs.plone.org/plone.restapi/docs/source/endpoints/querystringsearch.html
113
+ *
114
+ * @function fetchListingItems
115
+ * @param {Object} query - The query data used for fetching items.
116
+ * @param {string} apiPath - The base path for the API requests.
117
+ * @param {string} APISUFFIX - The suffix added to the API path depending on the environment.
118
+ * @param {string} authToken - The authentication token for authorized requests.
119
+ * @return {Array} An array of items that match the query criteria.
120
+ * @throws Will throw an error if the request fails.
121
+ */
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
+
139
+ try {
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;
145
+
146
+ if (parsedUrl.origin === apiOrigin) {
147
+ parsedUrl.protocol = new URL(publicOrigin).protocol;
148
+ parsedUrl.host = new URL(publicOrigin).host;
149
+ }
150
+ }
151
+
152
+ return parsedUrl.toString();
153
+ } catch {
154
+ return url;
155
+ }
156
+ }
157
+
158
+ /** Truncates the text to the specified length.
159
+ * If the text is longer than the specified length, it will be truncated and '...' will be appended.
160
+ * @function truncateText
161
+ * @param {string} text - The text to be truncated.
162
+ * @param {number} maxLength - The maximum length of the text.
163
+ * @return {string} The truncated text.
164
+ */
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;
175
+ }
176
+
177
+ /**
178
+ * Creates an Express middleware for generating an RSS feed using the listing block of the
179
+ * rss_feed content type.
180
+ *
181
+ * The middleware fetches the query data of the listing block, retrieves the matching items,
182
+ * and generates an RSS feed in XML format, which is sent as the response.
183
+ *
184
+ * @function make_rssMiddleware
185
+ * @return {Function} An Express middleware function for generating the RSS feed.
186
+ */
187
+ function make_rssMiddleware() {
188
+ const { settings } = config;
189
+ const APISUFFIX = settings.legacyTraverse ? '' : '/++api++';
190
+ let apiPath = '';
191
+ const host = process.env.HOST || 'localhost';
192
+ const port = process.env.PORT || '3000';
193
+ const ProdapiPath = `http://${host}:${port}`;
194
+ if (settings.internalApiPath && __SERVER__) {
195
+ apiPath = settings.internalApiPath;
196
+ } else if (__DEVELOPMENT__ && settings.devProxyToApiPath) {
197
+ apiPath = settings.devProxyToApiPath;
198
+ } else {
199
+ apiPath = settings.apiPath;
200
+ }
201
+ if (apiPath === '') {
202
+ apiPath = ProdapiPath;
203
+ }
204
+
205
+ async function rssMiddleware(req, res, next) {
206
+ try {
207
+ const {
208
+ query,
209
+ language,
210
+ description,
211
+ title,
212
+ subjects,
213
+ date,
214
+ max_description_length,
215
+ max_title_length,
216
+ } = await getRssFeedData(apiPath, APISUFFIX, req, settings);
217
+ const items = await fetchListingItems(
218
+ query,
219
+ apiPath,
220
+ APISUFFIX,
221
+ req.universalCookies.get('auth_token'),
222
+ );
223
+ const feedOptions = {
224
+ title: truncateText(title, max_title_length),
225
+ description: truncateText(description, max_description_length),
226
+ feed_url: normalizeFeedURL(req.path, settings.publicURL, apiPath),
227
+ site_url: settings.publicURL,
228
+ generator: 'RSS Feed Generator',
229
+ language: language,
230
+ pubDate: safeDate(date),
231
+ categories: subjects || [],
232
+ };
233
+
234
+ const feed = new RSS(feedOptions);
235
+
236
+ items.forEach((item) => {
237
+ const link = normalizeFeedURL(item.getURL, settings.publicURL, apiPath);
238
+ let enclosure = undefined;
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']) {
244
+ enclosure = {
245
+ url: normalizeFeedURL(
246
+ new URL(previewDownload, link).toString(),
247
+ settings.publicURL,
248
+ apiPath,
249
+ ),
250
+ type: imageData['content-type'],
251
+ size: imageData.size,
252
+ };
253
+ }
254
+
255
+ feed.item({
256
+ title: truncateText(item.title, max_title_length),
257
+ description: truncateText(
258
+ item.description || item.Description || '',
259
+ max_description_length,
260
+ ),
261
+ url: link,
262
+ guid: item.UID,
263
+ date: safeDate(item.effective || item.created || item.modified),
264
+ author: item.listCreators?.join(', '),
265
+ categories: item.Subject ? item.Subject : [],
266
+ enclosure: enclosure || undefined,
267
+ });
268
+ });
269
+
270
+ const xml = feed.xml({ indent: true });
271
+ res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8');
272
+ res.send(xml);
273
+ } catch (err) {
274
+ if (err.response && err.response.status === 401) {
275
+ // Handle unauthorized errors
276
+ res.status(401).json({
277
+ error: 'Unauthorized',
278
+ message:
279
+ 'You are not authorized to access the requested RSS feed. Please log in and try again.',
280
+ });
281
+ } else if (err.response && err.response.status === 404) {
282
+ // Handle not found errors
283
+ res.status(404).json({
284
+ error: 'Not Found',
285
+ message:
286
+ 'The requested RSS feed could not be found. Please verify the URL and try again.',
287
+ });
288
+ } else {
289
+ // Handle other errors
290
+ res.status(500).json({
291
+ error: 'Internal Server Error',
292
+ message:
293
+ 'An unexpected error occurred while generating the RSS feed. Please try again later.',
294
+ });
295
+ }
296
+ return;
297
+ }
298
+ }
299
+
300
+ return rssMiddleware;
301
+ }
302
+
303
+ export default function makeMiddlewares() {
304
+ const middleware = express.Router();
305
+ middleware.use(express.urlencoded({ extended: true }));
306
+ middleware.get('**/rss.xml', make_rssMiddleware());
307
+
308
+ middleware.id = 'rss-middleware';
309
+
310
+ return middleware;
311
+ }