Skip to content

Commit ccef237

Browse files
authored
feat(status.app): serve the reworked news feeds under /v2 (#1297)
* refactor(status.app): restore the shipped news feed rendering as v1 The clients in the wild parse what production serves today, so that build is kept verbatim in its own module: unnamespaced `newsLink`, markup stripped down to text, Ghost's escaping passed through. It imports nothing but the XML parser, so a change to the reworked build cannot reach it. `handleRssFeed` picks the build from a version that defaults to `v1`, leaving the existing routes untouched. * feat(status.app): serve the reworked news feeds under /v2 `/desktop-news/rss/v2` and `/mobile-news/rss/v2` carry the lists, the body links, the escaping fixes and the namespaced call-to-action. The clients move over once status-go reads the new elements; until then `/desktop-news/rss` and `/mobile-news/rss` keep serving what they serve today.
1 parent 92f61f2 commit ccef237

6 files changed

Lines changed: 209 additions & 4 deletions

File tree

.changeset/tall-otters-refuse.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'status.app': patch
3+
---
4+
5+
feat(status.app): serve the reworked news feeds under `/v2`. `/desktop-news/rss/v2` and `/mobile-news/rss/v2` carry the lists, the body links, the escaping fixes and the namespaced call-to-action, while `/desktop-news/rss` and `/mobile-news/rss` keep serving the rendering the shipped clients were built against.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { buildLegacyNewsFeed } from './legacy-news-feed'
4+
5+
const DESKTOP_LINE_BREAK = '<br /><br />'
6+
const MOBILE_LINE_BREAK = '\n\n'
7+
8+
/** A Ghost item shaped the way the shipped clients receive it today. */
9+
const GHOST_FEED = (body: string) =>
10+
'<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">' +
11+
'<channel>' +
12+
'<item>' +
13+
'<title><![CDATA[Fixes & tweaks <beta>]]></title>' +
14+
`<description><![CDATA[${body}]]></description>` +
15+
`<content:encoded><![CDATA[${body}]]></content:encoded>` +
16+
'</item>' +
17+
'</channel>' +
18+
'</rss>'
19+
20+
const LIST_BODY =
21+
'<p>Fixes:</p>' +
22+
'<ul><li>High CPU usage</li>' +
23+
'<li>Endless loading, see the <a href="https://wikipedia.org/">test link</a> for details</li></ul>'
24+
25+
const BUTTON_CARD =
26+
'<p>Fixes for high CPU usage.</p>' +
27+
'<p>Thanks for testing.</p>' +
28+
'<div class="kg-card kg-button-card kg-align-center">' +
29+
'<a href="https://status.app/" class="kg-btn kg-btn-accent">Update your Status</a>' +
30+
'</div>'
31+
32+
describe('buildLegacyNewsFeed', () => {
33+
it('carries the call-to-action outside of any namespace', () => {
34+
const feed = buildLegacyNewsFeed(GHOST_FEED(BUTTON_CARD), MOBILE_LINE_BREAK)
35+
36+
expect(feed).toContain('<newsLink>https://status.app/</newsLink>')
37+
expect(feed).toContain('<newsLinkLabel>Update your Status</newsLinkLabel>')
38+
expect(feed).not.toContain('xmlns:status')
39+
})
40+
41+
it('joins the paragraphs with the line break the feed asks for', () => {
42+
expect(
43+
buildLegacyNewsFeed(GHOST_FEED(BUTTON_CARD), DESKTOP_LINE_BREAK)
44+
).toContain(
45+
'<description>Fixes for high CPU usage.<br /><br />Thanks for testing.</description>'
46+
)
47+
expect(
48+
buildLegacyNewsFeed(GHOST_FEED(BUTTON_CARD), MOBILE_LINE_BREAK)
49+
).toContain(
50+
'<description>Fixes for high CPU usage.\n\nThanks for testing.</description>'
51+
)
52+
})
53+
54+
/**
55+
* The bugs below are the ones `/v2` exists to fix -- an unescaped title makes
56+
* the feed invalid XML, list items run together and the anchor label is lost.
57+
* They are locked here because the shipped clients read around them, so this
58+
* test failing means a fix has leaked into the frozen feed.
59+
*/
60+
it('reproduces what production serves, bugs included', () => {
61+
const feed = buildLegacyNewsFeed(GHOST_FEED(LIST_BODY), MOBILE_LINE_BREAK)
62+
63+
expect(feed.slice(feed.indexOf('<item>'))).toBe(
64+
'<item>' +
65+
'<title>Fixes & tweaks <beta></title>' +
66+
'<description>Fixes:\n\nHigh CPU usageEndless loading, see the for details</description>' +
67+
'<content:encoded>Fixes:\n\nHigh CPU usageEndless loading, see the for details</content:encoded>' +
68+
'<newsLink>https://wikipedia.org/</newsLink>' +
69+
'<newsLinkLabel>test link</newsLinkLabel>' +
70+
'</item></channel></rss>'
71+
)
72+
})
73+
74+
it('leaves Ghost values escaped the way they arrive', () => {
75+
const feed = buildLegacyNewsFeed(
76+
GHOST_FEED('<p>Fixes &amp; tweaks</p>'),
77+
MOBILE_LINE_BREAK
78+
)
79+
80+
expect(feed).toContain('<description>Fixes &amp; tweaks</description>')
81+
})
82+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { XMLBuilder, XMLParser } from 'fast-xml-parser'
2+
3+
import type { X2jOptions } from 'fast-xml-parser'
4+
5+
/**
6+
* The news feed the clients in the wild already parse, kept byte for byte as it
7+
* is served today: unnamespaced `newsLink`/`newsLinkLabel`, markup stripped down
8+
* to text and Ghost's escaping passed through untouched. `/v2` carries the
9+
* lists, the links and the escaping fixes.
10+
*
11+
* Frozen on purpose -- nothing here may be shared with the `/v2` modules, or a
12+
* change over there would reach the clients that have not been updated.
13+
*/
14+
export function buildLegacyNewsFeed(body: string, lineBreak: string): string {
15+
const parser = new XMLParser({
16+
ignoreAttributes: false,
17+
processEntities: false,
18+
htmlEntities: true,
19+
cdataPropName: undefined,
20+
} as X2jOptions)
21+
22+
const xml = parser.parse(body)
23+
24+
if (xml.rss?.channel?.item) {
25+
if (Array.isArray(xml.rss.channel.item)) {
26+
xml.rss.channel.item.forEach((item: any) => processItem(item, lineBreak))
27+
} else {
28+
processItem(xml.rss.channel.item, lineBreak)
29+
}
30+
}
31+
32+
const builder = new XMLBuilder({
33+
ignoreAttributes: false,
34+
processEntities: false,
35+
cdataPropName: undefined,
36+
})
37+
38+
return builder.build(xml)
39+
}
40+
41+
function stripHtml(content: string, lineBreak: string) {
42+
return content
43+
.split(/<\/p>/i)
44+
.map((part: string) => part.replace(/<[^>]+>/g, '').trim())
45+
.filter(
46+
(part: string, index: number, arr: string[]) =>
47+
part || index < arr.length - 1
48+
)
49+
.join(lineBreak)
50+
}
51+
52+
function processField(item: any, field: string, lineBreak: string) {
53+
let content = stripHtml(item[field], lineBreak)
54+
if (item.newsLinkLabel) {
55+
content = content.replace(item.newsLinkLabel, '')
56+
}
57+
if (content.endsWith(lineBreak)) {
58+
content = content.slice(0, -lineBreak.length)
59+
}
60+
61+
return content
62+
}
63+
64+
function processItem(item: any, lineBreak: string) {
65+
const newsLink = item['content:encoded'].match(
66+
/<a[^>]*href="([^"]+)"[^>]*>([^<]*)<\/a>/
67+
)
68+
item.newsLink = newsLink?.[1]
69+
item.newsLinkLabel = newsLink?.[2]
70+
71+
item['content:encoded'] = processField(item, 'content:encoded', lineBreak)
72+
item.description = processField(item, 'description', lineBreak)
73+
}

apps/status.app/src/app/_utils/rss-handler.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { XMLBuilder, XMLParser } from 'fast-xml-parser'
22

33
import { clientEnv } from '~/config/env.client.mjs'
4+
import { buildLegacyNewsFeed } from '~app/_utils/legacy-news-feed'
45
import { buildNewsFeed } from '~app/_utils/news-feed'
56
import { baseUrl } from '~website/_lib/base-url'
67

@@ -10,22 +11,38 @@ import type { X2jOptions } from 'fast-xml-parser'
1011
const FEED = {
1112
'desktop-news': {
1213
format: 'html',
14+
lineBreak: '<br /><br />',
1315
path: '/tag/desktop-news/rss/',
1416
},
1517
'mobile-news': {
1618
format: 'text',
19+
lineBreak: '\n\n',
1720
path: '/tag/mobile-news/rss/',
1821
},
1922
main: {
2023
format: 'html',
24+
lineBreak: '',
2125
path: '/rss/',
2226
},
23-
} as const satisfies Record<string, { format: FeedFormat; path: string }>
27+
} as const satisfies Record<
28+
string,
29+
{ format: FeedFormat; lineBreak: string; path: string }
30+
>
2431

2532
type FeedType = keyof typeof FEED
2633

27-
export async function handleRssFeed(type: FeedType) {
28-
const { format, path } = FEED[type]
34+
/**
35+
* `v1` is what the shipped clients parse, so it stays on the rendering they were
36+
* built against; `v2` is served from its own URL because the fixes it carries --
37+
* the namespaced call-to-action above all -- need a client that expects them.
38+
*/
39+
type FeedVersion = 'v1' | 'v2'
40+
41+
export async function handleRssFeed(
42+
type: FeedType,
43+
version: FeedVersion = 'v1'
44+
) {
45+
const { format, lineBreak, path } = FEED[type]
2946

3047
try {
3148
const response = await fetch(
@@ -43,7 +60,11 @@ export async function handleRssFeed(type: FeedType) {
4360

4461
const body = await response.text()
4562
const newXml =
46-
type === 'main' ? buildBlogFeed(body) : buildNewsFeed(body, format)
63+
type === 'main'
64+
? buildBlogFeed(body)
65+
: version === 'v2'
66+
? buildNewsFeed(body, format)
67+
: buildLegacyNewsFeed(body, lineBreak)
4768

4869
return new Response(newXml, {
4970
headers: {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { handleRssFeed } from '~app/_utils/rss-handler'
2+
3+
export const dynamic = 'force-dynamic'
4+
5+
export async function GET() {
6+
const response = await handleRssFeed('desktop-news', 'v2')
7+
response.headers.set(
8+
'Cache-Control',
9+
'public, s-maxage=300, stale-while-revalidate=300'
10+
)
11+
return response
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { handleRssFeed } from '~app/_utils/rss-handler'
2+
3+
export const dynamic = 'force-dynamic'
4+
5+
export async function GET() {
6+
const response = await handleRssFeed('mobile-news', 'v2')
7+
response.headers.set(
8+
'Cache-Control',
9+
'public, s-maxage=300, stale-while-revalidate=300'
10+
)
11+
return response
12+
}

0 commit comments

Comments
 (0)