Skip to content

Commit 9ec8a77

Browse files
Add plain text response support for release and PR pages (#221)
* feat: add text/plain markdown responses for release and PR routes Requests with an Accept: text/plain header to /release, /release/:version, /release/compare/:from/:to, and /pr/:number now receive a pure markdown representation of the page content instead of the HTML UI. - Add wantsTextPlain/textPlainResponse helpers - Extract groupReleaseNotes (raw markdown) from renderGroupedReleaseNotes - Expose rawTitle/rawBody on cached PR details (cache key bumped to v2) * fix: use load context to pass text/plain body through to entry server Remix unwraps Response bodies from component-route loaders during document requests instead of sending them directly. Work around this by stashing the markdown body on the load context and short-circuiting in handleRequest (which receives loadContext as its fifth argument) before React renders. * feat: respect HTTP(S)_PROXY env vars for outbound fetch calls Use undici's EnvHttpProxyAgent as the global dispatcher when proxy environment variables are set, so release data and GitHub API calls work behind corporate proxies. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cbaade6 commit 9ec8a77

File tree

10 files changed

+213
-22
lines changed

10 files changed

+213
-22
lines changed

app/data/github-data.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export const getPRDetails = memoize(
274274
backportOf = {
275275
number: parentPR.number,
276276
title: renderPRTitleMarkdownSafely(parentPR.title),
277+
rawTitle: parentPR.title,
277278
author: parentPR.user?.login || 'Unknown User',
278279
authorAvatar: parentPR.user?.avatar_url || null,
279280
authorUrl: parentPR.user?.html_url || null,
@@ -290,7 +291,9 @@ export const getPRDetails = memoize(
290291
return {
291292
number: pr.number,
292293
title: renderPRTitleMarkdownSafely(pr.title),
294+
rawTitle: pr.title,
293295
body: styleHtmlContent((pr as unknown as Record<string, string>).body_html),
296+
rawBody: pr.body || '',
294297
url: pr.html_url,
295298
state: pr.state,
296299
createdAt: pr.created_at,
@@ -328,7 +331,7 @@ export const getPRDetails = memoize(
328331
return null;
329332
}
330333
},
331-
getKeyvCache('github-pr-details'),
334+
getKeyvCache('github-pr-details-v2'),
332335
{
333336
// Cache for 10 minutes
334337
ttl: 10 * 60 * 1_000,

app/data/markdown.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const knownSections = [
4444
'Unknown',
4545
];
4646

47-
export const renderGroupedReleaseNotes = (versions: { version: string; content: string }[]) => {
47+
export const groupReleaseNotes = (versions: { version: string; content: string }[]) => {
4848
const groups: Record<string, { version: string; content: string }[]> = Object.create(null);
4949
for (const key of knownSections) {
5050
groups[key] = [];
@@ -57,7 +57,7 @@ export const renderGroupedReleaseNotes = (versions: { version: string; content:
5757
groups[groupName] = groups[groupName] || [];
5858
groups[groupName].unshift({
5959
version,
60-
content: DOMPurify.sanitize(noListMD.render(groupContent.trim())),
60+
content: groupContent.trim(),
6161
});
6262
}
6363
}
@@ -70,3 +70,14 @@ export const renderGroupedReleaseNotes = (versions: { version: string; content:
7070

7171
return groups;
7272
};
73+
74+
export const renderGroupedReleaseNotes = (versions: { version: string; content: string }[]) => {
75+
const groups = groupReleaseNotes(versions);
76+
for (const key of Object.keys(groups)) {
77+
groups[key] = groups[key].map(({ version, content }) => ({
78+
version,
79+
content: DOMPurify.sanitize(noListMD.render(content)),
80+
}));
81+
}
82+
return groups;
83+
};

app/entry.server.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,42 @@
11
import { PassThrough } from 'node:stream';
22

3-
import type { ActionFunctionArgs, EntryContext, LoaderFunctionArgs } from '@remix-run/node';
3+
import type {
4+
ActionFunctionArgs,
5+
AppLoadContext,
6+
EntryContext,
7+
LoaderFunctionArgs,
8+
} from '@remix-run/node';
49
import { createReadableStreamFromReadable } from '@remix-run/node';
510
import { RemixServer } from '@remix-run/react';
611
import { isbot } from 'isbot';
712
import { renderToPipeableStream } from 'react-dom/server';
13+
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
814
import { startDataRefreshTimer } from './data/fresh-interval';
915

16+
if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) {
17+
setGlobalDispatcher(new EnvHttpProxyAgent());
18+
}
19+
1020
export const streamTimeout = 10_000;
1121

1222
export default function handleRequest(
1323
request: Request,
1424
responseStatusCode: number,
1525
responseHeaders: Headers,
1626
remixContext: EntryContext,
27+
loadContext: AppLoadContext,
1728
) {
29+
if (typeof loadContext.textPlainBody === 'string') {
30+
responseHeaders.set('Content-Type', 'text/plain; charset=utf-8');
31+
if (loadContext.cacheControl) {
32+
responseHeaders.set('Cache-Control', loadContext.cacheControl as string);
33+
}
34+
return new Response(loadContext.textPlainBody, {
35+
status: responseStatusCode,
36+
headers: responseHeaders,
37+
});
38+
}
39+
1840
return isbot(request.headers.get('user-agent') || '')
1941
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
2042
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);

app/helpers/request.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { AppLoadContext, TypedResponse } from '@remix-run/node';
2+
3+
export function wantsTextPlain(request: Request) {
4+
const accept = request.headers.get('accept');
5+
if (!accept) return false;
6+
return accept
7+
.split(',')
8+
.map((type) => type.split(';')[0].trim())
9+
.includes('text/plain');
10+
}
11+
12+
export function textPlainResponse(
13+
context: AppLoadContext,
14+
body: string,
15+
cacheControl: string,
16+
): TypedResponse<never> {
17+
context.textPlainBody = body;
18+
context.cacheControl = cacheControl;
19+
// Remix will unwrap this into loader data, but entry.server.tsx
20+
// short-circuits before rendering when textPlainBody is set.
21+
return new Response(null, { status: 200 }) as TypedResponse<never>;
22+
}

app/routes/pr/details.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { PageHeader } from '~/components/PageHeader';
2121
import { SemverBlock } from '~/components/SemverBlock';
2222
import { NoBackports } from '~/components/NoBackports';
23+
import { textPlainResponse, wantsTextPlain } from '~/helpers/request';
2324
import { guessTimeZoneFromRequest } from '~/helpers/timezone';
2425
import { prettyDateString } from '~/helpers/time';
2526

@@ -45,6 +46,57 @@ export const loader = async (args: LoaderFunctionArgs) => {
4546
}
4647

4748
let pr = await getPRDetails(parseInt(number, 10));
49+
50+
if (wantsTextPlain(args.request)) {
51+
if (!pr) {
52+
return textPlainResponse(args.context, `# PR #${number}\n\nNot found.\n`, 'private, max-age=60');
53+
}
54+
const state = pr.merged ? 'merged' : pr.state;
55+
const lines = [
56+
`# PR #${pr.number}: ${pr.rawTitle}`,
57+
'',
58+
`- State: ${state}`,
59+
`- Author: ${pr.author}`,
60+
`- Created: ${pr.createdAt}`,
61+
];
62+
if (pr.mergedAt) lines.push(`- Merged: ${pr.mergedAt}`);
63+
lines.push(`- Target branch: ${pr.targetBranch}`);
64+
if (pr.semver) lines.push(`- Semver impact: ${pr.semver}`);
65+
if (pr.releasedIn) lines.push(`- Released in: v${pr.releasedIn}`);
66+
lines.push(`- URL: ${pr.url}`, '');
67+
68+
if (pr.backportOf) {
69+
const parentState = pr.backportOf.merged ? 'merged' : pr.backportOf.state;
70+
lines.push(
71+
'## Backport Of',
72+
'',
73+
`- PR: #${pr.backportOf.number}: ${pr.backportOf.rawTitle}`,
74+
`- Author: ${pr.backportOf.author}`,
75+
`- State: ${parentState}`,
76+
);
77+
if (pr.backportOf.mergedAt) lines.push(`- Merged: ${pr.backportOf.mergedAt}`);
78+
lines.push(`- URL: ${pr.backportOf.url}`, '');
79+
}
80+
81+
if (pr.backports) {
82+
lines.push('## Backports', '');
83+
if (pr.backports.length === 0) {
84+
lines.push('No backports.', '');
85+
} else {
86+
lines.push('| Branch | State | PR | Released In |', '| --- | --- | --- | --- |');
87+
for (const bp of pr.backports) {
88+
const prRef = bp.backportPRNumber ? `#${bp.backportPRNumber}` : '';
89+
const released = bp.releasedIn ? `v${bp.releasedIn}` : '';
90+
lines.push(`| ${bp.targetBranch} | ${bp.state} | ${prRef} | ${released} |`);
91+
}
92+
lines.push('');
93+
}
94+
}
95+
96+
lines.push('## Description', '', pr.rawBody.trim(), '');
97+
return textPlainResponse(args.context, lines.join('\n'), 'private, max-age=60');
98+
}
99+
48100
if (pr) {
49101
const timeZone = guessTimeZoneFromRequest(args.request);
50102

app/routes/release/all.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Pagination } from '~/components/Pagination';
88
import { ReleaseTable } from '~/components/ReleaseTable';
99
import { Select } from '~/components/Select';
1010
import { getReleasesOrUpdate } from '~/data/release-data';
11+
import { textPlainResponse, wantsTextPlain } from '~/helpers/request';
1112
import { guessTimeZoneFromRequest } from '~/helpers/timezone';
1213

1314
export const meta: MetaFunction = () => {
@@ -75,6 +76,27 @@ export const loader = async (args: LoaderFunctionArgs) => {
7576
return redirect(`/release?channel=${encodeURIComponent(channel)}`);
7677
}
7778

79+
if (wantsTextPlain(args.request)) {
80+
const channelName =
81+
channel === 'stable' ? 'Stable' : channel === 'pre' ? 'Pre-release' : 'Nightly';
82+
const majorSuffix = major !== null ? ` (v${major})` : '';
83+
const lines = [
84+
`# Electron Releases — ${channelName}${majorSuffix}`,
85+
'',
86+
`Page ${page} of ${Math.max(maxPage, 1)}`,
87+
'',
88+
'| Version | Date | Chromium | Node.js | V8 |',
89+
'| --- | --- | --- | --- | --- |',
90+
];
91+
for (const release of inChannel.slice(start, end)) {
92+
lines.push(
93+
`| v${release.version} | ${release.fullDate} | ${release.chrome} | ${release.node} | ${release.v8} |`,
94+
);
95+
}
96+
lines.push('');
97+
return textPlainResponse(args.context, lines.join('\n'), 'private, max-age=30');
98+
}
99+
78100
const timeZone = guessTimeZoneFromRequest(args.request);
79101

80102
args.context.cacheControl = 'private, max-age=30';

app/routes/release/compare.tsx

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
} from '@icons-pack/react-simple-icons';
1919
import { getGitHubReleaseNotes } from '~/data/github-data';
2020
import { getAllVersionsInMajor, getReleaseForVersion, VersionFilter } from '~/data/release-data';
21-
import { renderGroupedReleaseNotes } from '~/data/markdown';
21+
import { groupReleaseNotes, renderGroupedReleaseNotes } from '~/data/markdown';
22+
import { textPlainResponse, wantsTextPlain } from '~/helpers/request';
2223
import { ArrowDown, ArrowRight } from 'lucide-react';
2324
import { VersionInfo } from '~/components/VersionInfo';
2425
import { useCallback } from 'react';
@@ -79,22 +80,50 @@ export const loader = async (args: LoaderFunctionArgs) => {
7980
return redirect('/release');
8081
}
8182

82-
const grouped = renderGroupedReleaseNotes(
83-
versionsForNotes.map((version, i) => {
84-
let releaseNotes = githubReleaseNotes[i]!;
85-
const parsed = semverParse(version);
86-
if (parsed?.prerelease.length) {
87-
releaseNotes = releaseNotes?.split(new RegExp(`@${escapeRegExp(version)}\`?.`))[1];
83+
const processedNotes = versionsForNotes.map((version, i) => {
84+
let releaseNotes = githubReleaseNotes[i]!;
85+
const parsed = semverParse(version);
86+
if (parsed?.prerelease.length) {
87+
releaseNotes = releaseNotes?.split(new RegExp(`@${escapeRegExp(version)}\`?.`))[1];
88+
}
89+
releaseNotes =
90+
releaseNotes?.replace(/# Release Notes for [^\r\n]+(?:(?:\n)|(?:\r\n))/i, '') || 'Missing...';
91+
return {
92+
version,
93+
content: releaseNotes,
94+
};
95+
});
96+
97+
if (wantsTextPlain(args.request)) {
98+
const dep = (name: string, from: string, to: string) =>
99+
from === to ? `- ${name}: ${from}` : `- ${name}: ${from}${to}`;
100+
const lines = [
101+
`# Electron Release Comparison: ${fromVersion}${toVersion}`,
102+
'',
103+
`${toVersion} includes changes from ${versionsBetween.length + 1} version${
104+
versionsBetween.length ? 's' : ''
105+
} since ${fromVersion}.`,
106+
'',
107+
'## Dependency Changes',
108+
'',
109+
dep('Chromium', fromElectronRelease.chrome, toElectronRelease.chrome),
110+
dep('Node.js', fromElectronRelease.node, toElectronRelease.node),
111+
dep('V8', fromElectronRelease.v8, toElectronRelease.v8),
112+
'',
113+
'## Combined Release Notes',
114+
'',
115+
];
116+
const rawGrouped = groupReleaseNotes(processedNotes);
117+
for (const groupName of Object.keys(rawGrouped)) {
118+
lines.push(`### ${groupName}`, '');
119+
for (const { version, content } of rawGrouped[groupName]) {
120+
lines.push(`#### v${version}`, '', content, '');
88121
}
89-
releaseNotes =
90-
releaseNotes?.replace(/# Release Notes for [^\r\n]+(?:(?:\n)|(?:\r\n))/i, '') ||
91-
'Missing...';
92-
return {
93-
version,
94-
content: releaseNotes,
95-
};
96-
}),
97-
);
122+
}
123+
return textPlainResponse(args.context, lines.join('\n'), 'private, max-age=300');
124+
}
125+
126+
const grouped = renderGroupedReleaseNotes(processedNotes);
98127

99128
args.context.cacheControl = 'private, max-age=300';
100129

app/routes/release/single.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
VersionFilter,
1919
} from '~/data/release-data';
2020
import { renderMarkdownSafely } from '~/data/markdown';
21+
import { textPlainResponse, wantsTextPlain } from '~/helpers/request';
2122
import { VersionInfo } from '~/components/VersionInfo';
2223
import { useCallback } from 'react';
2324
import { PageHeader } from '~/components/PageHeader';
@@ -66,6 +67,33 @@ export const loader = async (args: LoaderFunctionArgs) => {
6667
const isLatestStable = latestReleases.latestSupported[0]?.version === version.substr(1);
6768
const isLatestPreRelease = latestReleases.lastPreRelease?.version === version.substr(1);
6869

70+
if (wantsTextPlain(args.request)) {
71+
const tags: string[] = [];
72+
if (isLatestStable) tags.push('Latest Stable');
73+
if (isLatestPreRelease) tags.push('Latest Pre Release');
74+
const lines = [
75+
`# Electron ${version}${tags.length ? ` (${tags.join(', ')})` : ''}`,
76+
'',
77+
'## Install',
78+
'',
79+
'```',
80+
`npm install --save-dev electron@${version.substring(1)}`,
81+
'```',
82+
'',
83+
'## Dependencies',
84+
'',
85+
`- Chromium: ${electronRelease.chrome}`,
86+
`- Node.js: ${electronRelease.node}`,
87+
`- V8: ${electronRelease.v8}`,
88+
'',
89+
'## Release Notes',
90+
'',
91+
releaseNotes.trim(),
92+
'',
93+
];
94+
return textPlainResponse(args.context, lines.join('\n'), 'private, max-age=300');
95+
}
96+
6997
args.context.cacheControl = 'private, max-age=300';
7098
return {
7199
allVersionsInMajor,

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"markdown-it": "^14.1.1",
3737
"react": "^18.2.0",
3838
"react-dom": "^18.2.0",
39-
"semver": "^7.7.1"
39+
"semver": "^7.7.1",
40+
"undici": "^6.24.1"
4041
},
4142
"devDependencies": {
4243
"@eslint/compat": "^1.2.8",

yarn.lock

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9207,6 +9207,7 @@ __metadata:
92079207
semver: "npm:^7.7.1"
92089208
tailwindcss: "npm:^3.4.4"
92099209
typescript: "npm:^5.1.6"
9210+
undici: "npm:^6.24.1"
92109211
vite: "npm:^7.3.1"
92119212
vite-tsconfig-paths: "npm:^4.2.1"
92129213
vitest: "npm:^3.1.2"
@@ -10676,7 +10677,7 @@ __metadata:
1067610677
languageName: node
1067710678
linkType: hard
1067810679

10679-
"undici@npm:^6.21.2":
10680+
"undici@npm:^6.21.2, undici@npm:^6.24.1":
1068010681
version: 6.24.1
1068110682
resolution: "undici@npm:6.24.1"
1068210683
checksum: 10c0/53fdbaa357139a2c12deed34f67d67fc6ad269630ba85a1507e7717f53ad2d3a02c95fbd17d3ab321e34c60b6f0a716cdc2f7e2eca1e07178702dc89cc3a73c4

0 commit comments

Comments
 (0)