Skip to content

Add Substack feed integration to blog posts#1

Open
jhwheeler wants to merge 2 commits intomainfrom
claude/deploy-fluidlang-blog-Gojyo
Open

Add Substack feed integration to blog posts#1
jhwheeler wants to merge 2 commits intomainfrom
claude/deploy-fluidlang-blog-Gojyo

Conversation

@jhwheeler
Copy link
Copy Markdown
Owner

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

  • New Substack feed parser (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 handling
  • Updated Post type (src/lib/types.ts): Added source, substackUrl, and htmlContent fields to support both local and Substack posts
  • Enhanced API endpoint (src/routes/api/posts/+server.ts): Now fetches both local markdown posts and Substack posts, combining and sorting them by date
  • Post page improvements (src/routes/posts/[slug]/+page.ts): Added logic to detect and load Substack posts by slug prefix, with fallback to local markdown posts
  • Post display updates (src/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 header
  • Article preview badge (src/lib/components/ArticlePreview.svelte): Added visual indicator badge for Substack posts in the post list
  • Deployment configuration: Updated to use Vercel adapter and added fast-xml-parser dependency

Implementation Details

  • Substack posts are identified by the substack- slug prefix
  • HTML content from Substack is sanitized and rendered with scoped styling to maintain visual consistency
  • Posts are merged and sorted chronologically regardless of source
  • Graceful error handling with console warnings if Substack feed fetch fails
  • Responsive styling for Substack content with proper line-height and spacing

https://claude.ai/code/session_012X9sbrzo8YoKKzX8VNXs4F

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
@jhwheeler jhwheeler requested a review from Copilot February 19, 2026 05:55
@jhwheeler jhwheeler self-assigned this Feb 19, 2026
@netlify
Copy link
Copy Markdown

netlify bot commented Feb 19, 2026

Deploy Preview for fluidlanguage failed.

Name Link
🔨 Latest commit e3946a8
🔍 Latest deploy log https://app.netlify.com/projects/fluidlanguage/deploys/6996a5d0f0300400081d87e7

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Post type to support source-specific fields (source, substackUrl, htmlContent).
  • Updated /api/posts to 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 converting e (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.

Comment on lines +26 to +27
error(404, 'Post not found');
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +33
const response = await fetch(SUBSTACK_FEED_URL);
if (!response.ok) {
console.warn(`Substack feed returned ${response.status}`);
return [];
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +40
const items: SubstackItem[] = parsed?.rss?.channel?.item;
if (!items || !Array.isArray(items)) return [];
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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];

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +60
date: new Date(item.pubDate).toISOString().split('T')[0],
languages: [],
source: 'substack' as const,
substackUrl: item.link,
htmlContent: item['content:encoded']
};
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +33
const allPosts = [...localPosts, ...substackPosts];
allPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());

return json(allPosts);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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).

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +10
const response = await fetch('/api/posts');
const posts = await response.json();
const post = posts.find((p: { slug: string }) => p.slug === params.slug);

if (post) {
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
<svelte:component this={data.content} />
{:else if data.htmlContent}
<div class="substack-content">
{@html data.htmlContent}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{@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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants