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');