Skip to content

Commit 904c269

Browse files
committed
feat: implement blog RSS feed handler and update routing
- Add blog RSS feed handler in server/routes/blog-rss.ts - Wire /blog/rss.xml endpoint in server.ts for production - Remove old blog RSS route from React Router in app/routes.ts - Add Vitest tests for blog RSS handler and E2E tests for the RSS endpoint - Update README to reflect new routing behavior and migration status
1 parent 8a48e1a commit 904c269

File tree

7 files changed

+191
-57
lines changed

7 files changed

+191
-57
lines changed

app/remix/README.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Both systems run side-by-side in the same server during migration.
77
## Purpose
88

99
This document is the migration playbook for:
10+
1011
- route wiring conventions in `server.ts`
1112
- implementation constraints in `app/remix/`
1213
- guardrails that keep production stable while migration is in progress
@@ -31,37 +32,47 @@ Current `app/remix/` contents:
3132
app/remix/
3233
|- document.tsx
3334
|- tsconfig.json
35+
|- blog-rss.test.ts
3436
|- test-route.tsx
3537
|- test-route.test.ts
3638
`- README.md
3739
```
3840

3941
Current server wiring behavior:
42+
4043
- `/healthcheck` is handled directly in `server.ts` (stable operational endpoint)
44+
- `/blog/rss.xml` is handled directly in `server.ts` with a production-safe Remix handler
4145
- `/remix-test` is wired only in development as a Remix smoke test page
4246
- all remaining routes are handled by React Router via the catch-all
4347

4448
## Production constraints
4549

4650
- Dynamic `.tsx` route imports are currently considered development-only.
4751
- Production use of dynamic Remix route modules depends on the precompile/asset strategy (still in progress).
48-
- Keep `/healthcheck` on a stable server handler until that strategy is finalized.
52+
- For production migrations today, prefer stable handlers that can be imported directly by `server.ts` (no Vite-only transforms like `import.meta.glob`).
53+
- Keep `/healthcheck` on a stable server handler until the dynamic asset strategy is finalized.
4954

5055
## Dual-route strategy
5156

5257
When a user-facing route is migrated to Remix, keep the equivalent React Router route in `app/routes.ts` until linking pages are also migrated.
5358

5459
Why this is required:
60+
5561
- React Router intercepts client-side `<Link>` navigation.
5662
- If a route is missing from the React Router manifest, client navigation can resolve through catch-all and produce incorrect behavior.
5763

5864
Expected behavior during migration:
65+
5966
- direct URL access: fetch-router can serve Remix route
6067
- client-side navigation from React Router pages: React Router serves its route module
6168
- links from Remix pages using plain `<a>`: full reload, then fetch-router serves Remix route
6269

6370
Remove route entries from `app/routes.ts` only after inbound linking surfaces are migrated.
6471

72+
Exception:
73+
74+
- Non-navigated resource endpoints (for example RSS/robots/sitemap-style URLs) can be removed from `app/routes.ts` once they are mapped explicitly in `server.ts` for production.
75+
6576
## Conventions
6677

6778
### JSX pragma (required)
@@ -102,9 +113,7 @@ import { Document } from "../document";
102113

103114
export default async function handler() {
104115
const html = await renderToString(
105-
<Document title="Page Title">
106-
{/* page content */}
107-
</Document>,
116+
<Document title="Page Title">{/* page content */}</Document>,
108117
);
109118

110119
return new Response("<!DOCTYPE html>" + html, {
@@ -132,13 +141,26 @@ Current preferred pattern in this repo:
132141

133142
1. Define route map(s) with `route(...)` from `remix/fetch-router/routes`
134143
2. Register handlers with `router.map(routeMap, controller)`
135-
3. For development-only smoke routes, register only when Vite dev server is present
136-
4. Keep React Router entries in `app/routes.ts` for user-facing routes during dual-stack period
137-
5. Add a colocated Vitest test where possible
144+
3. Put production-ready Remix routes in the always-on route map (outside the Vite-only block)
145+
4. Reserve `if (viteDevServer)` route maps for smoke tests and dynamic dev-only module imports
146+
5. Keep React Router entries in `app/routes.ts` for user-facing routes during dual-stack period
147+
6. Add a colocated Vitest test where possible
148+
149+
Production-safe handler placement:
150+
151+
- If a handler needs Node-only APIs (for example filesystem access), implement it in `server/routes/` and call it from `server.ts`.
152+
- Do not duplicate these handlers in `app/remix/`; keep a single source of truth in `server/routes/`.
153+
154+
Handler loading pattern (important):
155+
156+
- In development, map Node-backed route handlers through `loadRemixModule(...)` so handler edits reload without restarting the dev server.
157+
- In production, map those same handlers with static imports from `server/routes/*` for boot-time validation and predictable runtime behavior.
158+
- Avoid dynamic `import()` on production request paths unless there is a specific need and monitoring/error handling is in place.
138159

139160
### HTML attributes
140161

141162
`remix/component` uses HTML attributes, not React camelCase:
163+
142164
- `class` not `className`
143165
- `innerHTML` not `dangerouslySetInnerHTML`
144166
- `fill-rule` not `fillRule`
@@ -167,16 +189,19 @@ Before opening a PR that changes `app/remix/` or fetch-router mappings:
167189
- Do not import modules that pull in `react-router` types
168190
- Keep `/healthcheck` on stable server path unless migration strategy explicitly changes
169191
- Keep user-facing route entries in `app/routes.ts` until dual-route migration is complete
192+
- For production migrations, prefer handlers that can run from `server.ts` without Vite-only transforms
170193
- Add or update tests for new route handlers and server route wiring
171194

172195
## Migration status (living section)
173196

174197
In progress:
198+
175199
- `app/remix/` scaffolding (`document.tsx`, isolated tsconfig, smoke-test route)
176200
- server route map wiring pattern using fetch-router `route(...)`
201+
- production Remix handler for `/blog/rss.xml` wired in `server.ts`
177202

178203
Not yet fully migrated:
179-
- user-facing Remix route modules wired in production
204+
180205
- client asset/precompile strategy for production Remix route imports
181206
- blog pages (`/blog`, `/blog/:slug`)
182207
- OG image generation (`/img/:slug`)

app/routes.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export default [
99
route("newsletter", "routes/marketing/newsletter.tsx"),
1010
route("blog", "routes/marketing/blog-index.tsx"),
1111
route("blog/:slug", "routes/marketing/blog-post.tsx"),
12-
route("blog/rss.xml", "routes/resources/blog-rss.tsx"),
1312
]),
1413

1514
route("jam", "routes/jam/pages/layout.tsx", [

app/routes/resources/blog-rss.tsx

Lines changed: 0 additions & 39 deletions
This file was deleted.

e2e/blog-rss.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
test.describe("Blog RSS endpoint", () => {
4+
test("returns XML with expected feed metadata", async ({ request }) => {
5+
const response = await request.get("/blog/rss.xml");
6+
7+
expect(response.ok()).toBe(true);
8+
expect(response.headers()["content-type"]).toContain("application/xml");
9+
10+
const xml = await response.text();
11+
expect(xml).toContain("<rss");
12+
expect(xml).toContain("<title>Remix Blog</title>");
13+
expect(xml).toContain("<link>https://remix.run/blog</link>");
14+
});
15+
});

server.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { route } from "remix/fetch-router/routes";
1010
import { compression } from "remix/compression-middleware";
1111
import { staticFiles } from "remix/static-middleware";
1212
import { rateLimit, filteredLogger } from "./server/middleware.ts";
13+
import blogRssHandler from "./server/routes/blog-rss.ts";
1314
import sourceMapSupport from "source-map-support";
1415

1516
sourceMapSupport.install();
@@ -78,6 +79,7 @@ const router = createRouter({
7879
// ---------------------------------------------------------------------------
7980
const remixRoutes = route({
8081
healthcheck: "/healthcheck",
82+
blogRss: "/blog/rss.xml",
8183
});
8284

8385
async function loadRemixModule(path: string) {
@@ -90,17 +92,24 @@ async function loadRemixModule(path: string) {
9092

9193
// Keep healthcheck on a stable path during migration so deploy checks never
9294
// depend on the in-progress Remix route asset strategy.
93-
router.map(remixRoutes, {
94-
healthcheck() {
95-
return new Response("OK", {
96-
headers: {
97-
"Cache-Control": "no-store",
98-
"Content-Type": "text/plain; charset=utf-8",
99-
},
100-
});
101-
},
95+
router.map(remixRoutes.healthcheck, () => {
96+
return new Response("OK", {
97+
headers: {
98+
"Cache-Control": "no-store",
99+
"Content-Type": "text/plain; charset=utf-8",
100+
},
101+
});
102102
});
103103

104+
if (viteDevServer) {
105+
router.map(remixRoutes.blogRss, async () => {
106+
const mod = await loadRemixModule("./server/routes/blog-rss.ts");
107+
return mod.default();
108+
});
109+
} else {
110+
router.map(remixRoutes.blogRss, blogRssHandler);
111+
}
112+
104113
if (viteDevServer) {
105114
const devRemixRoutes = route({
106115
remixTest: "/remix-test",

server/routes/blog-rss.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it, expect } from "vitest";
2+
import { CACHE_CONTROL } from "../../app/lib/cache-control";
3+
import { buildBlogRssResponse } from "./blog-rss";
4+
5+
describe("blog RSS route handler", () => {
6+
it("returns an RSS XML response with cache headers", async () => {
7+
const response = buildBlogRssResponse([
8+
{
9+
slug: "hello-world",
10+
title: "Hello World",
11+
summary: "A first post",
12+
date: new Date("2025-01-01T00:00:00.000Z"),
13+
},
14+
]);
15+
16+
expect(response).toBeInstanceOf(Response);
17+
expect(response.status).toBe(200);
18+
expect(response.headers.get("Content-Type")).toBe("application/xml");
19+
expect(response.headers.get("Cache-Control")).toBe(CACHE_CONTROL.DEFAULT);
20+
21+
const xml = await response.text();
22+
expect(xml).toContain("<rss");
23+
expect(xml).toContain("<title>Remix Blog</title>");
24+
expect(xml).toContain(
25+
"<description>Thoughts about building excellent user experiences with Remix.</description>",
26+
);
27+
expect(xml).toContain("<link>https://remix.run/blog/hello-world</link>");
28+
});
29+
});

server/routes/blog-rss.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { readdir, readFile } from "node:fs/promises";
2+
import { Feed } from "feed";
3+
import parseFrontMatter from "front-matter";
4+
import { CACHE_CONTROL } from "../../app/lib/cache-control.ts";
5+
6+
interface BlogRssFrontmatter {
7+
title?: string;
8+
summary?: string;
9+
date?: string | Date;
10+
draft?: boolean;
11+
}
12+
13+
export interface BlogRssPost {
14+
slug: string;
15+
title: string;
16+
summary: string;
17+
date: Date;
18+
}
19+
20+
const POSTS_DIRECTORY = new URL("../../data/posts/", import.meta.url);
21+
22+
export async function getBlogRssPosts(): Promise<BlogRssPost[]> {
23+
const entries = await readdir(POSTS_DIRECTORY, { withFileTypes: true });
24+
const posts: BlogRssPost[] = [];
25+
26+
for (const entry of entries) {
27+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
28+
continue;
29+
}
30+
31+
const fileUrl = new URL(entry.name, POSTS_DIRECTORY);
32+
const source = await readFile(fileUrl, "utf8");
33+
const { attributes } = parseFrontMatter<BlogRssFrontmatter>(source);
34+
35+
if (attributes.draft || !attributes.title || !attributes.summary) {
36+
continue;
37+
}
38+
39+
const parsedDate =
40+
attributes.date instanceof Date
41+
? attributes.date
42+
: new Date(String(attributes.date ?? ""));
43+
44+
if (Number.isNaN(parsedDate.getTime())) {
45+
continue;
46+
}
47+
48+
posts.push({
49+
slug: entry.name.replace(/\.md$/, ""),
50+
title: attributes.title,
51+
summary: attributes.summary,
52+
date: parsedDate,
53+
});
54+
}
55+
56+
return posts.sort((a, b) => b.date.getTime() - a.date.getTime());
57+
}
58+
59+
export function buildBlogRssResponse(posts: BlogRssPost[]) {
60+
const blogUrl = "https://remix.run/blog";
61+
62+
const feed = new Feed({
63+
id: blogUrl,
64+
title: "Remix Blog",
65+
description:
66+
"Thoughts about building excellent user experiences with Remix.",
67+
link: blogUrl,
68+
language: "en",
69+
updated: posts.length > 0 ? posts[0].date : new Date(),
70+
generator: "https://github.com/jpmonette/feed",
71+
copyright: "© Shopify, Inc.",
72+
});
73+
74+
for (const post of posts) {
75+
const postLink = `${blogUrl}/${post.slug}`;
76+
feed.addItem({
77+
id: postLink,
78+
title: post.title,
79+
link: postLink,
80+
date: post.date,
81+
description: post.summary,
82+
});
83+
}
84+
85+
return new Response(feed.rss2(), {
86+
headers: {
87+
"Content-Type": "application/xml",
88+
"Cache-Control": CACHE_CONTROL.DEFAULT,
89+
},
90+
});
91+
}
92+
93+
export default async function blogRssHandler() {
94+
const posts = await getBlogRssPosts();
95+
return buildBlogRssResponse(posts);
96+
}

0 commit comments

Comments
 (0)