Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/distill.js
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,7 @@ export function feedToHTML(text) {
|| rssLink(entry)
|| (guidIsPermalink ? field(entry, 'guid') : '');
const author = field(entry, 'author name') || field(entry, 'author');
const date = (field(entry, 'updated') || field(entry, 'published') || field(entry, 'pubdate')).slice(0, 10);
const date = feedDate(field(entry, 'updated') || field(entry, 'published') || field(entry, 'pubdate'));
const byline = [author && `by ${author}`, date].filter(Boolean).join(', ');
// Atom escapes the entry body, so textContent of content/summary is the
// HTML itself, ready to be embedded and parsed like any page.
Expand Down Expand Up @@ -752,6 +752,27 @@ export function feedToHTML(text) {
return `<html><head><title>${esc(feedTitle)}</title></head><body>\n${parts.join('\n')}\n</body></html>`;
}

const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const RFC822_DATE = /^(?:[a-z]{3},\s*)?(\d{1,2})\s+([a-z]{3})\s+(\d{4})\b/i;

/**
* An entry's date as YYYY-MM-DD. Atom writes ISO 8601, so the day is its
* first ten characters. RSS 2.0 writes RFC 822 ('Mon, 17 Aug 2026 00:00:00
* GMT'), and ten characters of that are 'Mon, 17 Au'. Its day, month, and
* year are read as written rather than through Date, which would move a late
* evening post to the next day in UTC; the Atom slice keeps the feed's own
* day too. Anything else is printed as the feed wrote it, not cut.
* @param {string} s
* @returns {string}
*/
function feedDate(s) {
if (/^\d{4}-\d\d-\d\d/.test(s)) return s.slice(0, 10);
const m = s.match(RFC822_DATE);
const month = m ? MONTHS.indexOf(m[2].toLowerCase()) + 1 : 0;
if (!month) return s;
return `${m[3]}-${String(month).padStart(2, '0')}-${m[1].padStart(2, '0')}`;
}

const escHTML = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

/**
Expand Down
21 changes: 21 additions & 0 deletions tests/distill.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,27 @@ test('an RSS 2.0 entry is opened by its link, not a non-permalink guid', () => {
assert.ok(text.includes('Second lede.'), 'item without content:encoded lost its description');
});

test('an RSS 2.0 pubDate prints as a date, not as its first ten characters', () => {
// pubDate is RFC 822, the day name first. Cutting it where an Atom
// timestamp is cut printed "Mon, 17 Au" under every WordPress post.
const rss = readFileSync(new URL('./pages/rss20.xml', import.meta.url), 'utf8');
const lines = distill(rss, 'https://blog.example.test/feed.xml').blocks.map((b) => b.text);
assert.ok(lines.includes('2026-08-17'), `pubDate not read as a date: ${JSON.stringify(lines)}`);
assert.ok(lines.includes('2026-08-18'), 'second item lost its date');
assert.ok(!lines.some((l) => /\bAu$/.test(l)), 'a date was cut mid-month');
// The day is the one the feed wrote, in its own zone, the way the slice of
// an Atom timestamp already reads, not the UTC day it converts to.
const late = `<?xml version="1.0"?><rss version="2.0"><channel><title>Late</title>
<item><title>Late post</title><link>https://example.test/late</link>
<pubDate>Sat, 5 Sep 2026 23:30:00 -0700</pubDate><description>Body text here.</description></item>
</channel></rss>`;
assert.ok(distill(late, 'https://example.test/rss').blocks.some((b) => b.text === '2026-09-05'),
'a single-digit day or a zone offset threw the date off');
// A date in neither form is printed as the feed wrote it, rather than cut.
const odd = late.replace('Sat, 5 Sep 2026 23:30:00 -0700', 'yesterday');
assert.ok(distill(odd, 'https://example.test/rss').blocks.some((b) => b.text === 'yesterday'));
});

test('a youtube watch page renders as title, byline, description, and transcript links', () => {
const watch = readFileSync(new URL('./pages/youtube_watch.html', import.meta.url), 'utf8');
const p = distill(watch, 'https://www.youtube.com/watch?v=fixture123');
Expand Down
Loading