diff --git a/README.md b/README.md index 432df7e..650149f 100644 --- a/README.md +++ b/README.md @@ -64,15 +64,16 @@ gh release create vYY.MM.N --verify-tag --generate-notes --title vYY.MM.N ## Configuration -| Setting | Description | Type | Default | -| ------------------- | -------------------------------------------------------------------- | -------- | -------------------------------------- | -| `bypass_cors` | Route the RSS feed URL through the CORS proxy | optional | `true` | -| `cache_interval` | How often (in seconds) to refresh the feed | required | `1800` | -| `display_errors` | Show errors on screen for debugging purposes | optional | `false` | -| `override_locale` | Override the locale used for date formatting (e.g. `en`, `fr`, `de`) | optional | `en` | -| `override_timezone` | Override the timezone for date display (e.g. `Europe/London`) | optional | system timezone | -| `rss_title` | Source label displayed on each card | required | `BBC News` | -| `rss_url` | URL of the RSS feed to display | required | `http://feeds.bbci.co.uk/news/rss.xml` | +| Setting | Description | Type | Default | +| ------------------- | ------------------------------------------------------------------------------------------------------------ | -------- | -------------------------------------- | +| `bypass_cors` | Route the RSS feed URL through the CORS proxy | optional | `true` | +| `cache_interval` | How often (in seconds) to refresh the feed | required | `1800` | +| `display_errors` | Show errors on screen for debugging purposes | optional | `false` | +| `mode` | Layout: `headlines` (lead story with image, plus a list) or `list` (headlines only, for maximum readability) | optional | `headlines` | +| `override_locale` | Override the locale used for date formatting (e.g. `en`, `fr`, `de`) | optional | `en` | +| `override_timezone` | Override the timezone for date display (e.g. `Europe/London`) | optional | system timezone | +| `rss_title` | Source name shown in the header | required | `BBC News` | +| `rss_url` | URL of the RSS feed to display | required | `http://feeds.bbci.co.uk/news/rss.xml` | ## Example Feeds diff --git a/src/utils.test.ts b/src/utils.test.ts index 5604ef2..ad9f8ba 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -104,26 +104,45 @@ describe('getTitleSizeClass', () => { }) describe('resizeImageUrl', () => { - const BBC = + const ACE = 'https://ichef.bbci.co.uk/ace/standard/240/cpsprodpb/50a9/live/ece0.jpg' - const bbcAt = (width: number) => - `https://ichef.bbci.co.uk/ace/standard/${width}/cpsprodpb/50a9/live/ece0.jpg` - - test('rounds up to a width the BBC image CDN serves', () => { + const aceAt = (size: number) => + `https://ichef.bbci.co.uk/ace/standard/${size}/cpsprodpb/50a9/live/ece0.jpg` + const NEWS = 'https://ichef.bbci.co.uk/news/240/cpsprodpb/50a9/live/ece0.jpg' + const newsAt = (size: number) => + `https://ichef.bbci.co.uk/news/${size}/cpsprodpb/50a9/live/ece0.jpg` + const IC = 'https://ichef.bbci.co.uk/images/ic/240x135/p0l7jnbt.jpg' + const icAt = (size: string) => + `https://ichef.bbci.co.uk/images/ic/${size}/p0l7jnbt.jpg` + + test('rounds /ace/ URLs up to a width the CDN serves', () => { // 1080 itself is a 404 on the CDN - expect(resizeImageUrl(BBC, 1080)).toBe(bbcAt(1280)) - expect(resizeImageUrl(BBC, 480)).toBe(bbcAt(480)) - expect(resizeImageUrl(BBC, 270)).toBe(bbcAt(480)) - expect(resizeImageUrl(BBC, 120)).toBe(bbcAt(240)) + expect(resizeImageUrl(ACE, 1080)).toBe(aceAt(1280)) + expect(resizeImageUrl(ACE, 480)).toBe(aceAt(480)) + expect(resizeImageUrl(ACE, 270)).toBe(aceAt(480)) + expect(resizeImageUrl(ACE, 120)).toBe(aceAt(240)) + expect(resizeImageUrl(ACE, 4096)).toBe(aceAt(2048)) + }) + + test('skips the widths /news/ URLs do not serve', () => { + // 1280 is a 404 on this shape + expect(resizeImageUrl(NEWS, 1080)).toBe(newsAt(1536)) + expect(resizeImageUrl(NEWS, 270)).toBe(newsAt(480)) + expect(resizeImageUrl(NEWS, 4096)).toBe(newsAt(2048)) }) - test('caps at the largest served width', () => { - expect(resizeImageUrl(BBC, 4096)).toBe(bbcAt(2048)) + test('resizes /images/ic/ URLs by their width-by-height segment', () => { + expect(resizeImageUrl(IC, 1080)).toBe(icAt('1280x720')) + expect(resizeImageUrl(IC, 270)).toBe(icAt('480x270')) + // 2048x1152 is a 403, so this shape tops out lower + expect(resizeImageUrl(IC, 4096)).toBe(icAt('1920x1080')) }) test('leaves other feeds untouched', () => { const nyt = 'https://static01.nyt.com/images/2026/11/03/240/photo.jpg' expect(resizeImageUrl(nyt, 1080)).toBe(nyt) + const otherBbc = 'https://ichef.bbci.co.uk/onesport/cps/240/photo.jpg' + expect(resizeImageUrl(otherBbc, 1080)).toBe(otherBbc) expect(resizeImageUrl('', 1080)).toBe('') }) }) diff --git a/src/utils.ts b/src/utils.ts index 8988efc..0128480 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,17 +25,44 @@ export function getTitleSizeClass(title: string): string { } // BBC feeds only advertise 240px thumbnails, which blur on a screen-sized lead -// story. Their image CDN serves other widths from the same path: any width up -// to 999, but above that only a fixed set (1080, for one, is a 404). Round up -// to a width it is known to serve. Other feeds' URLs are left untouched. -const BBC_IMAGE_WIDTH = /^(https?:\/\/ichef\.bbci\.co\.uk\/[^/]+\/[^/]+\/)\d+\// -const BBC_WIDTHS = [240, 480, 640, 976, 1280, 1536, 2048] +// story. Their image CDN serves the same image at other sizes, but each URL +// shape accepts its own set (/ace/ 404s on 1080, /news/ on 1280, /images/ic/ +// 403s on 2048x1152), so round up to a size each shape is known to serve. +// Other feeds' URLs are left untouched. +const BBC_IMAGE_SHAPES: { pattern: RegExp; sizes: string[] }[] = [ + { + // https://ichef.bbci.co.uk/ace/standard/240/cpsprodpb/... + pattern: /^(https?:\/\/ichef\.bbci\.co\.uk\/ace\/[^/]+\/)\d+\//, + sizes: ['240', '480', '640', '976', '1280', '1536', '2048'], + }, + { + // https://ichef.bbci.co.uk/news/240/cpsprodpb/... + pattern: /^(https?:\/\/ichef\.bbci\.co\.uk\/news\/)\d+\//, + sizes: ['240', '480', '640', '976', '1536', '2048'], + }, + { + // https://ichef.bbci.co.uk/images/ic/240x135/p0l7jnbt.jpg (video items) + pattern: /^(https?:\/\/ichef\.bbci\.co\.uk\/images\/ic\/)\d+x\d+\//, + sizes: [ + '240x135', + '480x270', + '640x360', + '976x549', + '1280x720', + '1536x864', + '1920x1080', + ], + }, +] export function resizeImageUrl(url: string, width: number): string { - const snapped = - BBC_WIDTHS.find((candidate) => candidate >= width) ?? - BBC_WIDTHS[BBC_WIDTHS.length - 1] - return url.replace(BBC_IMAGE_WIDTH, `$1${snapped}/`) + const shape = BBC_IMAGE_SHAPES.find(({ pattern }) => pattern.test(url)) + if (!shape) return url + const { pattern, sizes } = shape + const size = + sizes.find((candidate) => parseInt(candidate, 10) >= width) ?? + sizes[sizes.length - 1] + return url.replace(pattern, `$1${size}/`) } export function stripHtml(html: string): string {