Conversation
Switch from adapter-auto to adapter-vercel for first-class Vercel support. Add Substack RSS feed integration that fetches posts from fluidlanguage.substack.com and merges them into the blog listing alongside local markdown posts. Substack posts are rendered with their full HTML content and tagged with a visual badge. https://claude.ai/code/session_012X9sbrzo8YoKKzX8VNXs4F
❌ Deploy Preview for fluidlanguage failed.
|
There was a problem hiding this comment.
Pull request overview
Integrates a Substack RSS feed into the blog so Substack posts can appear alongside local mdsvex markdown posts, with dedicated rendering for Substack HTML and deployment moved to Vercel.
Changes:
- Added a Substack RSS fetch/parse utility and expanded the
Posttype to support source-specific fields (source,substackUrl,htmlContent). - Updated
/api/poststo merge local markdown posts with Substack feed posts and sort them by date. - Updated post detail rendering to support Substack HTML content and added a Substack badge/link in the UI.
Reviewed changes
Copilot reviewed 8 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| svelte.config.js | Switches SvelteKit adapter to Vercel. |
| src/routes/posts/[slug]/+page.ts | Loads Substack posts (via /api/posts) when slug is prefixed with substack-; marks local posts as source: local. |
| src/routes/posts/[slug]/+page.svelte | Conditionally renders Substack HTML content and adds “Also on Substack” link + scoped styles. |
| src/routes/api/posts/+server.ts | Combines local posts with Substack posts in the API response and sorts by date. |
| src/lib/types.ts | Extends Post with Substack-related fields. |
| src/lib/substack.ts | Implements RSS fetching/parsing and maps items into Post objects. |
| src/lib/components/ArticlePreview.svelte | Adds a “Substack” badge for Substack-sourced posts. |
| package.json | Replaces adapter-auto with adapter-vercel and adds fast-xml-parser. |
| package-lock.json | Locks updated dependency graph for Vercel adapter and XML parser. |
| .gitignore | Ignores .vercel directory. |
Comments suppressed due to low confidence (1)
src/routes/posts/[slug]/+page.ts:41
- Similarly, the catch block calls
error(404, e)without throwing. Also consider convertinge(unknown) into a safe/serializable message/object rather than passing it directly.
} catch (e) {
error(404, e)
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| error(404, 'Post not found'); | ||
| } |
There was a problem hiding this comment.
In SvelteKit, error(...) should be thrown to abort the load function. Here error(404, 'Post not found') is called without throw, so the function can continue/fall through without returning a value.
| const response = await fetch(SUBSTACK_FEED_URL); | ||
| if (!response.ok) { | ||
| console.warn(`Substack feed returned ${response.status}`); | ||
| return []; | ||
| } |
There was a problem hiding this comment.
The Substack feed request has no timeout/cancellation. If the upstream hangs, this can stall /api/posts and any page that depends on it. Consider using an AbortController with a short timeout and/or adding caching (e.g., in-memory TTL or HTTP cache headers) to reduce upstream dependency and latency.
| const items: SubstackItem[] = parsed?.rss?.channel?.item; | ||
| if (!items || !Array.isArray(items)) return []; |
There was a problem hiding this comment.
fetchSubstackPosts assumes parsed.rss.channel.item is always an array. In RSS, item may be a single object when there’s only one entry, which would make this return [] and hide the post. Normalize to an array (wrap the object) before mapping.
| const items: SubstackItem[] = parsed?.rss?.channel?.item; | |
| if (!items || !Array.isArray(items)) return []; | |
| const rawItems = parsed?.rss?.channel?.item; | |
| if (!rawItems) return []; | |
| const items: SubstackItem[] = Array.isArray(rawItems) ? rawItems : [rawItems]; |
| date: new Date(item.pubDate).toISOString().split('T')[0], | ||
| languages: [], | ||
| source: 'substack' as const, | ||
| substackUrl: item.link, | ||
| htmlContent: item['content:encoded'] | ||
| }; |
There was a problem hiding this comment.
htmlContent: item['content:encoded'] is persisted and later rendered via {@html ...} without sanitization, which is an XSS risk. Also, new Date(item.pubDate).toISOString() can throw for invalid/missing dates, causing the whole fetch to fall back to []. Consider sanitizing/stripping unsafe tags before returning and parsing dates defensively (skip/bucket invalid items rather than failing the whole feed).
| const allPosts = [...localPosts, ...substackPosts]; | ||
| allPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); | ||
|
|
||
| return json(allPosts); |
There was a problem hiding this comment.
/api/posts now returns Substack posts including full htmlContent. This endpoint is also used for the index/list view, so returning full HTML for every Substack item increases payload size and latency unnecessarily. Consider splitting “list” vs “detail” (e.g., omit htmlContent here and add a /api/posts/[slug] endpoint or a query flag to include content for a single post).
| const response = await fetch('/api/posts'); | ||
| const posts = await response.json(); | ||
| const post = posts.find((p: { slug: string }) => p.slug === params.slug); | ||
|
|
||
| if (post) { |
There was a problem hiding this comment.
This load fetches the entire /api/posts list (which now includes Substack HTML) just to resolve a single post by slug. This is inefficient and couples the post page to the list endpoint shape. Prefer loading a single post via a dedicated endpoint (e.g. /api/posts/[slug]) or by fetching the RSS feed directly here and selecting the matching item.
| const response = await fetch('/api/posts'); | |
| const posts = await response.json(); | |
| const post = posts.find((p: { slug: string }) => p.slug === params.slug); | |
| if (post) { | |
| const response = await fetch(`/api/posts/${params.slug}`); | |
| if (response.ok) { | |
| const post = await response.json(); |
| <svelte:component this={data.content} /> | ||
| {:else if data.htmlContent} | ||
| <div class="substack-content"> | ||
| {@html data.htmlContent} |
There was a problem hiding this comment.
{@html data.htmlContent} renders raw HTML from the Substack RSS feed directly into the page, which creates a stored XSS risk if the feed content ever contains <script> tags, event handlers, or malicious HTML. An attacker who can control or compromise the Substack content could inject arbitrary JavaScript that runs in your site’s origin, allowing theft of session data or account takeover. To mitigate this, ensure htmlContent is passed through a robust HTML sanitizer on the server (whitelisting only safe tags/attributes) before rendering, or avoid {@html} entirely and render a safe subset of content.
Summary
This PR integrates Substack feed content into the blog, allowing posts from a Substack publication to be displayed alongside local markdown posts. The implementation includes fetching and parsing the Substack RSS feed, displaying posts with proper styling, and linking back to the original Substack articles.
Key Changes
src/lib/substack.ts): Fetches and parses the Substack RSS feed, converting feed items into the Post type with proper slug generation, HTML content extraction, and metadata handlingsrc/lib/types.ts): Addedsource,substackUrl, andhtmlContentfields to support both local and Substack postssrc/routes/api/posts/+server.ts): Now fetches both local markdown posts and Substack posts, combining and sorting them by datesrc/routes/posts/[slug]/+page.ts): Added logic to detect and load Substack posts by slug prefix, with fallback to local markdown postssrc/routes/posts/[slug]/+page.svelte): Added conditional rendering for Substack HTML content with dedicated styling for images, links, blockquotes, and headings; added "Also on Substack" link in post headersrc/lib/components/ArticlePreview.svelte): Added visual indicator badge for Substack posts in the post listfast-xml-parserdependencyImplementation Details
substack-slug prefixhttps://claude.ai/code/session_012X9sbrzo8YoKKzX8VNXs4F