ep_rss 11.0.26 → 11.0.28

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/AGENTS.md CHANGED
@@ -17,13 +17,14 @@ ep_rss/
17
17
  ├── client.js
18
18
  ├── ep.json
19
19
  ├── index.js
20
+ ├── locales/
21
+ │ └── en.json
20
22
  ├── package.json
21
23
  ├── static/
22
- │ ├── img/
23
- ├── tests/
24
+ │ ├── css/exportColumn.css
25
+ └── tests/
24
26
  ├── templates/
25
- ├── embedFrame.ejs
26
- │ ├── embedFrame.html
27
+ └── exportColumn.ejs
27
28
  ```
28
29
 
29
30
  ## Helpers used
@@ -59,6 +60,6 @@ pnpm --filter ep_etherpad-lite run test-ui
59
60
 
60
61
  ## Quick reference: hooks declared in `ep.json`
61
62
 
62
- * Server: `eejsBlock_htmlHead`, `expressCreateServer`, `eejsBlock_embedPopup`
63
+ * Server: `eejsBlock_htmlHead`, `expressCreateServer`, `eejsBlock_exportColumn`, `eejsBlock_styles`
63
64
 
64
65
  When adding a hook, register it in both `ep.json` *and* the matching `exports.<hook> = ...` in the JS file.
package/client.js CHANGED
@@ -2,8 +2,14 @@
2
2
 
3
3
  const eejs = require('ep_etherpad-lite/node/eejs');
4
4
 
5
- exports.eejsBlock_embedPopup = (hookName, args, cb) => {
5
+ exports.eejsBlock_exportColumn = (hookName, args, cb) => {
6
6
  const feedURL = `..${args.renderContext.req.url}/feed`;
7
- args.content += eejs.require('ep_rss/templates/embedFrame.ejs', {feed: feedURL});
7
+ args.content += eejs.require('ep_rss/templates/exportColumn.ejs', {feed: feedURL});
8
+ return cb();
9
+ };
10
+
11
+ exports.eejsBlock_styles = (hookName, args, cb) => {
12
+ args.content +=
13
+ '<link rel="stylesheet" href="/static/plugins/ep_rss/static/css/exportColumn.css">';
8
14
  return cb();
9
15
  };
package/ep.json CHANGED
@@ -5,7 +5,8 @@
5
5
  "hooks": {
6
6
  "eejsBlock_htmlHead": "ep_rss/index",
7
7
  "expressCreateServer" : "ep_rss/index:registerRoute",
8
- "eejsBlock_embedPopup": "ep_rss/client:eejsBlock_embedPopup"
8
+ "eejsBlock_exportColumn": "ep_rss/client:eejsBlock_exportColumn",
9
+ "eejsBlock_styles": "ep_rss/client:eejsBlock_styles"
9
10
  }
10
11
  }
11
12
  ]
package/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const {Feed} = require('feed');
3
4
  const API = require('ep_etherpad-lite/node/db/API');
4
5
  const padManager = require('ep_etherpad-lite/node/db/PadManager');
5
6
  const settings = require('ep_etherpad-lite/node/utils/Settings');
@@ -9,9 +10,13 @@ if (!settings.rss) {
9
10
  console.log('ep_rss settings have not been configured');
10
11
  }
11
12
 
12
- const staleTime = settings.rss.staleTime || 300000; // 5 minutes, value should be set in settings
13
- const feeds = {}; // A nasty global
13
+ // How long to serve a cached feed before regenerating. Bumped on every
14
+ // pad edit anyway via the lastEdited check below, so this only affects
15
+ // pads that haven't been edited recently.
16
+ const staleTime = settings.rss.staleTime || 300000;
14
17
 
18
+ // Per-pad cache: { lastEdited: number, body: string }.
19
+ const feeds = {};
15
20
 
16
21
  exports.eejsBlock_htmlHead = (hookName, args, cb) => {
17
22
  args.content +=
@@ -28,79 +33,45 @@ exports.registerRoute = (hookName, args, cb) => {
28
33
  args.app.get('/p/:padId/atom.xml', redirectToFeed);
29
34
 
30
35
  args.app.get('/p/:padId/feed', async (req, res) => {
31
- const fullURL = `${req.protocol}://${req.get('host')}${req.url}`;
32
36
  const padId = req.params.padId;
33
- const padURL = `${req.protocol}://${req.get('host')}/p/${padId}`;
34
- const dateString = new Date();
35
- let isPublished = false; // is this item already published?
36
- let text;
37
+ const origin = `${req.protocol}://${req.get('host')}`;
38
+ const padURL = `${origin}/p/${padId}`;
39
+ // Strip query string so the atom:self link matches the URL feed
40
+ // readers actually fetched (e.g. when they tack on cache-busters).
41
+ const feedURL = `${origin}${req.url.split('?')[0]}`;
37
42
 
38
- /* When was this pad last edited and should we publish an RSS update? */
39
- const message = await API.getLastEdited(padId);
40
- const currTS = new Date().getTime();
41
- if (!feeds[padId]) {
42
- feeds[padId] = {};
43
+ const {lastEdited} = await API.getLastEdited(padId);
44
+ const now = Date.now();
45
+ const cached = feeds[padId];
46
+ if (cached && cached.lastEdited === lastEdited && now - cached.builtAt < staleTime) {
47
+ res.type('application/rss+xml');
48
+ res.send(cached.body);
49
+ return;
43
50
  }
44
51
 
45
- // was the pad edited within the last 5 minutes?
46
- if (currTS - message.lastEdited < staleTime) {
47
- isPublished = isAlreadyPublished(padId, message.lastEdited);
52
+ const pad = await padManager.getPad(padId);
53
+ const feed = new Feed({
54
+ title: padId,
55
+ description: `Etherpad feed for ${padId}`,
56
+ id: padURL,
57
+ link: padURL,
58
+ language: 'en-us',
59
+ feedLinks: {rss: feedURL},
60
+ updated: new Date(lastEdited),
61
+ });
62
+ feed.addItem({
63
+ title: padId,
64
+ id: `${padURL}#${lastEdited}`,
65
+ link: padURL,
66
+ description: pad.text(),
67
+ date: new Date(lastEdited),
68
+ });
48
69
 
49
- if (!isPublished) { // If it's not already published and it's gone stale
50
- feeds[padId].lastEdited = message.lastEdited; // Add it to the timer object
51
- }
52
- } else {
53
- if (!feeds[padId].feed) { // If it's not already stored in memory
54
- console.debug('RSS Feed not already in memory so writing memory', feeds);
55
- isPublished = false;
56
- feeds[padId].lastEdited = message.lastEdited; // Add it to the timer object
57
- } else {
58
- isPublished = true;
59
- }
60
- }
61
- if (!isPublished) {
62
- const pad = await padManager.getPad(padId);
63
- text = safe_tags(pad.text()).replace(/\n/g, '<br/>');
64
- }
65
-
66
- if (isPublished) {
67
- console.debug(`Sending RSS from memory for ${padId}`);
68
- res.contentType('rss');
69
- res.send(feeds[padId].feed);
70
- } else {
71
- console.debug(`Building RSS for ${padId}`);
72
- res.contentType('rss');
73
- args.content = '<rss version="2.0" \n';
74
- args.content += ' xmlns:content="http://purl.org/rss/1.0/modules/content/"\n';
75
- args.content += ' xmlns:atom="http://www.w3.org/2005/Atom"\n';
76
- args.content += '>\n';
77
- args.content += '<channel>\n';
78
- args.content += `<title>${padId}</title>\n`;
79
- args.content += `<atom:link href="${fullURL}" rel="self" type="application/rss+xml" />`;
80
- args.content += `<link>${padURL}</link>\n`;
81
- args.content += '<description/>\n';
82
- args.content += '<language>en-us</language>\n';
83
- args.content += `<pubDate>${dateString}</pubDate>\n`;
84
- args.content += `<lastBuildDate>${dateString}</lastBuildDate>\n`;
85
- args.content += '<item>\n';
86
- args.content += '<title>\n';
87
- args.content += `${padId}\n`;
88
- args.content += '</title>\n';
89
- args.content += '<description>\n';
90
- args.content += `<![CDATA[${text}]]>\n`;
91
- args.content += '</description>\n';
92
- args.content += `<link>${padURL}</link>\n`;
93
- args.content += '</item>\n';
94
- args.content += '</channel>\n';
95
- args.content += '</rss>';
96
- feeds[padId].feed = args.content;
97
- res.send(args.content); // Send it to the requester
98
- }
70
+ const body = feed.rss2();
71
+ feeds[padId] = {lastEdited, builtAt: now, body};
72
+ res.type('application/rss+xml');
73
+ res.send(body);
99
74
  });
75
+
100
76
  cb();
101
77
  };
102
-
103
- const safe_tags =
104
- (str) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
105
-
106
- const isAlreadyPublished = (padId, editTime) => (feeds[padId] === editTime);
@@ -0,0 +1,4 @@
1
+ {
2
+ "ep_rss.exportrssa.title": "RSS feed of this pad",
3
+ "ep_rss.exportrss": "RSS"
4
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ep_rss",
3
3
  "description": "Get an RSS feed of pad updates",
4
- "version": "11.0.26",
4
+ "version": "11.0.28",
5
5
  "author": {
6
6
  "name": "John McLear",
7
7
  "email": "john@mclear.co.uk",
@@ -19,6 +19,9 @@
19
19
  "type": "individual",
20
20
  "url": "https://etherpad.org/"
21
21
  },
22
+ "dependencies": {
23
+ "feed": "^5.2.1"
24
+ },
22
25
  "devDependencies": {
23
26
  "eslint": "^8.57.1",
24
27
  "eslint-config-etherpad": "^4.0.5",
@@ -0,0 +1,24 @@
1
+ /*
2
+ * SVG export-icon styling. The other export buttons render a Fontello
3
+ * glyph via the .exporttype:before / .buttonicon-* pseudo-element; we
4
+ * use an inline SVG instead so the icon survives without registering
5
+ * an extra font character. Sized at 22px to match the heuristic
6
+ * height of the buttonicon font row, kept orange so RSS readers still
7
+ * recognise it at a glance.
8
+ */
9
+ #exportrssa .ep_rss_export_icon {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ width: 22px;
14
+ height: 22px;
15
+ margin-right: 10px;
16
+ color: #f26522;
17
+ vertical-align: middle;
18
+ }
19
+
20
+ #exportrssa .ep_rss_export_icon svg {
21
+ width: 100%;
22
+ height: 100%;
23
+ fill: currentColor;
24
+ }
@@ -0,0 +1,43 @@
1
+ import {expect, test} from '@playwright/test';
2
+ import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
3
+
4
+ test.describe('ep_rss export-column link', () => {
5
+ test('renders the RSS link inside the Import/Export column', async ({page}) => {
6
+ await goToNewPad(page);
7
+ const link = page.locator('#exportColumn a#exportrssa');
8
+ await expect(link).toHaveCount(1);
9
+ await expect(link).toHaveAttribute('data-l10n-id', 'ep_rss.exportrssa.title');
10
+ await expect(link).toHaveAttribute('target', '_blank');
11
+ await expect(link).toHaveAttribute('rel', 'noopener');
12
+ const href = await link.getAttribute('href');
13
+ expect(href).toMatch(/\/feed$/);
14
+ await expect(link.locator('span#exportrss svg')).toHaveCount(1);
15
+ });
16
+
17
+ test('localized title attribute resolves via html10n', async ({page}) => {
18
+ await goToNewPad(page);
19
+ const link = page.locator('#exportrssa');
20
+ await expect.poll(
21
+ async () => link.getAttribute('title'),
22
+ {timeout: 5000},
23
+ ).toBe('RSS feed of this pad');
24
+ });
25
+
26
+ test('clicking the link reaches /p/<pad>/feed', async ({page, request}) => {
27
+ await goToNewPad(page);
28
+ const href = await page.locator('#exportrssa').getAttribute('href');
29
+ expect(href).toBeTruthy();
30
+ const resolved = new URL(href!, page.url()).toString();
31
+ const res = await request.get(resolved);
32
+ expect(res.status()).toBe(200);
33
+ expect(res.headers()['content-type']).toContain('application/rss+xml');
34
+ });
35
+
36
+ test('share/embed popup no longer carries an RSS block', async ({page}) => {
37
+ await goToNewPad(page);
38
+ // The legacy embedFrame.ejs put `<a class="rssfeed">` inside #embed.
39
+ // Verify nothing in #embed references the feed any more.
40
+ const stale = page.locator('#embed a.rssfeed, #embed a[href*="feed"]');
41
+ await expect(stale).toHaveCount(0);
42
+ });
43
+ });
@@ -25,21 +25,79 @@ test.describe('ep_rss feed', () => {
25
25
  expect(res.status()).toBe(200);
26
26
  expect(res.headers()['content-type']).toContain('application/rss+xml');
27
27
  const body = await res.text();
28
- expect(body).toMatch(/^<rss /);
28
+ expect(body).toMatch(/^<\?xml version="1\.0" encoding="utf-8"\?>\n<rss /);
29
+ // Channel title is a plain element; item title is CDATA-wrapped by
30
+ // the feed library. Both must carry the pad id.
29
31
  expect(body).toContain(`<title>${padId}</title>`);
32
+ expect(body).toContain(`<title><![CDATA[${padId}]]></title>`);
30
33
  expect(body).toContain(marker);
31
34
  expect(body.trim().endsWith('</rss>')).toBe(true);
32
35
  });
33
36
 
34
- test('escapes HTML special characters in the description', async ({page, request}) => {
37
+ test('uses RFC-822 dates for pubDate and lastBuildDate (feedvalidator.org)', async ({page, request}) => {
38
+ await goToNewPad(page);
39
+ const padId = padIdFromUrl(page.url());
40
+ await writeToPad(page, 'date check');
41
+ await page.waitForTimeout(1200);
42
+ const body = await (await request.get(`${BASE_URL}/p/${padId}/feed`)).text();
43
+ // "Mon, 18 May 2026 09:53:13 GMT" — what Date.toUTCString() emits and
44
+ // what feedvalidator.org requires per RSS 2.0's RFC-822 rule.
45
+ const RFC822 = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
46
+ const pub = body.match(/<pubDate>([^<]+)<\/pubDate>/g) || [];
47
+ const build = body.match(/<lastBuildDate>([^<]+)<\/lastBuildDate>/);
48
+ expect(pub.length).toBeGreaterThanOrEqual(1);
49
+ for (const tag of pub) {
50
+ const value = tag.replace(/<\/?pubDate>/g, '');
51
+ expect(value, `pubDate "${value}"`).toMatch(RFC822);
52
+ }
53
+ expect(build).not.toBeNull();
54
+ expect(build![1]).toMatch(RFC822);
55
+ });
56
+
57
+ test('item carries a stable non-permalink guid', async ({page, request}) => {
58
+ await goToNewPad(page);
59
+ const padId = padIdFromUrl(page.url());
60
+ await writeToPad(page, 'guid check');
61
+ await page.waitForTimeout(1200);
62
+
63
+ const first = await (await request.get(`${BASE_URL}/p/${padId}/feed`)).text();
64
+ const m = first.match(/<guid isPermaLink="false">([^<]+)<\/guid>/);
65
+ expect(m, 'guid element missing').not.toBeNull();
66
+ expect(m![1]).toContain(`/p/${padId}#`);
67
+
68
+ // Polling the same pad without editing must return the same guid so
69
+ // feed readers don't show a duplicate item every fetch.
70
+ const second = await (await request.get(`${BASE_URL}/p/${padId}/feed`)).text();
71
+ const m2 = second.match(/<guid isPermaLink="false">([^<]+)<\/guid>/);
72
+ expect(m2![1]).toBe(m![1]);
73
+ });
74
+
75
+ test('atom:self link drops the query string so it matches the document URL', async ({page, request}) => {
76
+ await goToNewPad(page);
77
+ const padId = padIdFromUrl(page.url());
78
+ await writeToPad(page, 'self-ref check');
79
+ await page.waitForTimeout(1200);
80
+ const body = await (await request.get(`${BASE_URL}/p/${padId}/feed?_=cachebust`)).text();
81
+ const self = body.match(/<atom:link href="([^"]+)" rel="self"/);
82
+ expect(self).not.toBeNull();
83
+ expect(self![1]).toBe(`${BASE_URL}/p/${padId}/feed`);
84
+ });
85
+
86
+ test('pad text with XML-significant characters is CDATA-wrapped', async ({page, request}) => {
35
87
  await goToNewPad(page);
36
88
  const padId = padIdFromUrl(page.url());
37
89
  await writeToPad(page, 'tags <b>&</b>');
38
90
  await page.waitForTimeout(1200);
39
91
 
40
92
  const body = await (await request.get(`${BASE_URL}/p/${padId}/feed`)).text();
41
- expect(body).toContain('&lt;b&gt;&amp;&lt;/b&gt;');
42
- expect(body).not.toContain('<b>&</b>');
93
+ // CDATA section protects raw `<` and `&` from XML parsing, so the
94
+ // feed library passes pad text through verbatim instead of double-
95
+ // encoding. The body must (a) be well-formed XML — that's covered
96
+ // implicitly by every test that parses the response — and (b)
97
+ // include the text inside the CDATA section.
98
+ const descMatch = body.match(/<description><!\[CDATA\[([\s\S]*?)\]\]><\/description>/);
99
+ expect(descMatch, 'item description must be CDATA-wrapped').not.toBeNull();
100
+ expect(descMatch![1]).toContain('tags <b>&</b>');
43
101
  });
44
102
 
45
103
  for (const alias of ['rss', 'feed.rss', 'atom.xml']) {
@@ -0,0 +1,7 @@
1
+ <a id="exportrssa" data-l10n-id="ep_rss.exportrssa.title" target="_blank" rel="noopener" class="exportlink" href="<%= feed %>">
2
+ <span class="exporttype ep_rss_export_icon" id="exportrss" data-l10n-id="ep_rss.exportrss" aria-hidden="true">
3
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true">
4
+ <path d="M5 4.5a1.5 1.5 0 0 1 1.5-1.5C14.4 3 21 9.6 21 17.5a1.5 1.5 0 0 1-3 0C18 11.3 12.7 6 6.5 6A1.5 1.5 0 0 1 5 4.5Zm0 6a1.5 1.5 0 0 1 1.5-1.5C11.7 9 16 13.3 16 18.5a1.5 1.5 0 0 1-3 0C13 14.9 10.1 12 6.5 12A1.5 1.5 0 0 1 5 10.5ZM6.5 16a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"/>
5
+ </svg>
6
+ </span>
7
+ </a>
Binary file
@@ -1,8 +0,0 @@
1
- <br>
2
- <h2>Rss Feeds</h2>
3
- <p>
4
- <a class="rssfeed" href="<%= feed%>" title="Pad Feed">
5
- <img src="../static/plugins/ep_rss/static/img/rss.gif">
6
- </a>
7
-
8
- </p>
@@ -1 +0,0 @@
1
- <a class="rssfeed" href="feed">RSS Feed</a>