From 15491d85ad4f007deb1ee90eaab59da7c16929e9 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:35:32 +0900 Subject: [PATCH] Print an RSS pubDate as a date, not its first ten characters A feed entry's byline took the first ten characters of its date. That is the day of an Atom timestamp (2026-08-17T...), but RSS 2.0 writes pubDate in RFC 822, day name first, so every WordPress or Hacker News item was dated "Mon, 17 Au". An RFC 822 date is now read for its day, month, and year as written. Date would parse it too, but it converts to UTC, which moves a post from late evening in a western zone to the next day, while the Atom slice keeps the day the feed wrote. A date in neither form is printed as the feed wrote it instead of being cut. Co-Authored-By: Claude Opus 5.5 --- src/distill.js | 23 ++++++++++++++++++++++- tests/distill.test.js | 21 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/distill.js b/src/distill.js index d9274c1..0451bbd 100644 --- a/src/distill.js +++ b/src/distill.js @@ -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. @@ -752,6 +752,27 @@ export function feedToHTML(text) { return `${esc(feedTitle)}\n${parts.join('\n')}\n`; } +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, '&').replace(//g, '>').replace(/"/g, '"'); /** diff --git a/tests/distill.test.js b/tests/distill.test.js index d365fda..11549c8 100644 --- a/tests/distill.test.js +++ b/tests/distill.test.js @@ -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 = `Late + Late posthttps://example.test/late + Sat, 5 Sep 2026 23:30:00 -0700Body text here. + `; + 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');