Skip to content

Commit 3548bc5

Browse files
committed
feat(blog): add BlueSky engagement component and auto-update workflow
- Add BlueskyEngagement component to display likes and reposts on posts - Add blueskyPostId field to content schema - Update PostLayout to show engagement between post and comments - Add workflow job to update frontmatter with BlueSky post ID after posting - Add custom Giscus theme to hide redundant comment count - Show comment count in Comments header via Giscus metadata - Disable Giscus reactions (using BlueSky engagement instead)
1 parent 69dc872 commit 3548bc5

10 files changed

Lines changed: 362 additions & 5 deletions

File tree

.github/workflows/bluesky-new-post.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,77 @@ jobs:
5959
secrets:
6060
BLUESKY_USERNAME: ${{ secrets.BLUESKY_USERNAME }}
6161
BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}
62+
63+
update-frontmatter:
64+
needs: [detect, notify]
65+
if: needs.detect.outputs.has_posts == 'true' && needs.notify.outputs.post_id != ''
66+
runs-on: ubuntu-latest
67+
steps:
68+
- name: Checkout blog repo
69+
uses: actions/checkout@v4
70+
with:
71+
token: ${{ secrets.GITHUB_TOKEN }}
72+
73+
- name: Find and update blog post
74+
run: |
75+
POST_URL="${{ needs.detect.outputs.post_url }}"
76+
POST_ID="${{ needs.notify.outputs.post_id }}"
77+
78+
echo "Post URL: $POST_URL"
79+
echo "Post ID: $POST_ID"
80+
81+
# Extract slug from URL (e.g., https://www.codingwithcalvin.net/my-post/ -> my-post)
82+
SLUG=$(echo "$POST_URL" | sed -E 's|https?://[^/]+/([^/]+)/?|\1|')
83+
echo "Extracted slug: $SLUG"
84+
85+
# Find the markdown file (could be in any year directory)
86+
FILE=$(find src/content/blog -path "*/${SLUG}/index.md" | head -1)
87+
88+
if [ -z "$FILE" ]; then
89+
echo "Could not find file for slug: $SLUG"
90+
exit 1
91+
fi
92+
93+
echo "Found file: $FILE"
94+
95+
# Check if blueskyPostId already exists
96+
if grep -q "^blueskyPostId:" "$FILE"; then
97+
echo "blueskyPostId already exists, skipping"
98+
exit 0
99+
fi
100+
101+
# Add blueskyPostId before the closing --- of frontmatter
102+
# Using awk for more reliable YAML manipulation
103+
awk -v post_id="$POST_ID" '
104+
BEGIN { in_frontmatter = 0; frontmatter_end = 0 }
105+
/^---$/ {
106+
if (in_frontmatter == 0) {
107+
in_frontmatter = 1
108+
print
109+
next
110+
} else {
111+
print "blueskyPostId: \"" post_id "\""
112+
in_frontmatter = 0
113+
frontmatter_end = 1
114+
}
115+
}
116+
{ print }
117+
' "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
118+
119+
echo "Updated $FILE with blueskyPostId: $POST_ID"
120+
echo "New frontmatter:"
121+
head -20 "$FILE"
122+
123+
- name: Commit and push
124+
run: |
125+
git config user.name "github-actions[bot]"
126+
git config user.email "github-actions[bot]@users.noreply.github.com"
127+
128+
git add -A
129+
if git diff --staged --quiet; then
130+
echo "No changes to commit"
131+
exit 0
132+
fi
133+
134+
git commit -m "chore(blog): add blueskyPostId to post frontmatter"
135+
git push

public/giscus-theme.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@import url('https://giscus.app/themes/dark.css');
2+
3+
.gsc-comments-count {
4+
display: none;
5+
}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
---
2+
interface Props {
3+
postId: string;
4+
}
5+
6+
const { postId } = Astro.props;
7+
const handle = "codingwithcalvin.net";
8+
---
9+
10+
<div
11+
class="bluesky-engagement mt-8 p-6 bg-background-2 rounded-lg"
12+
data-post-id={postId}
13+
data-handle={handle}
14+
>
15+
<div class="flex items-center justify-between mb-4">
16+
<div class="flex items-center gap-2">
17+
<svg class="w-5 h-5 text-[#0085ff]" viewBox="0 0 568 501" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
18+
<path d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 189.086 552.071 210.685C534.135 274.363 494.427 288.674 458.331 284.323C347.591 270.773 378.291 346.014 455.423 366.672C543.113 390.192 558.377 455.107 454.018 464.097C375.969 470.64 336.053 439.194 307.553 411.717C290.099 394.915 283.99 394.875 284 394.915C284.008 394.875 277.896 394.915 260.447 411.717C231.944 439.194 192.031 470.64 113.982 464.097C9.61951 455.107 24.8869 390.192 112.577 366.672C189.709 346.014 220.409 270.773 109.669 284.323C73.5731 288.674 33.8647 274.363 15.9289 210.685C9.94525 189.086 0 75.2916 0 57.9464C0 -28.9064 76.1339 -1.61183 123.121 33.6637Z"/>
19+
</svg>
20+
<span class="text-text-muted text-sm">Engagement on Bluesky</span>
21+
</div>
22+
<a
23+
class="bluesky-link inline-flex items-center gap-2 text-[#0085ff] hover:underline text-sm"
24+
href="#"
25+
target="_blank"
26+
rel="noopener noreferrer"
27+
>
28+
Join the conversation
29+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
30+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
31+
</svg>
32+
</a>
33+
</div>
34+
35+
<div class="engagement-content hidden">
36+
<div class="flex gap-4">
37+
<div class="likers-section w-1/2">
38+
<div class="flex items-center gap-2 mb-2">
39+
<svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 24 24">
40+
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
41+
</svg>
42+
<span class="likes-count font-medium">0</span>
43+
<span class="likes-label text-text-muted text-sm">likes from</span>
44+
</div>
45+
<div class="likers-avatars flex flex-wrap gap-1">
46+
<!-- Populated by JS -->
47+
</div>
48+
</div>
49+
50+
<div class="reposters-section w-1/2">
51+
<div class="flex items-center gap-2 mb-2">
52+
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
53+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
54+
</svg>
55+
<span class="reposts-count font-medium">0</span>
56+
<span class="reposts-label text-text-muted text-sm">reposts from</span>
57+
</div>
58+
<div class="reposters-avatars flex flex-wrap gap-1">
59+
<!-- Populated by JS -->
60+
</div>
61+
</div>
62+
</div>
63+
</div>
64+
65+
<div class="engagement-loading text-text-muted text-sm">
66+
Loading engagement data...
67+
</div>
68+
69+
<div class="engagement-error hidden text-text-muted text-sm">
70+
<!-- Hidden on error - component just disappears gracefully -->
71+
</div>
72+
</div>
73+
74+
<script>
75+
async function loadBlueskyEngagement() {
76+
const containers = document.querySelectorAll('.bluesky-engagement');
77+
78+
for (const container of containers) {
79+
const postId = container.dataset.postId;
80+
const handle = container.dataset.handle;
81+
82+
if (!postId || !handle) {
83+
container.classList.add('hidden');
84+
continue;
85+
}
86+
87+
const content = container.querySelector('.engagement-content');
88+
const loading = container.querySelector('.engagement-loading');
89+
const errorEl = container.querySelector('.engagement-error');
90+
const likesCount = container.querySelector('.likes-count');
91+
const repostsCount = container.querySelector('.reposts-count');
92+
const likersAvatars = container.querySelector('.likers-avatars');
93+
const blueskyLink = container.querySelector('.bluesky-link');
94+
95+
try {
96+
// Resolve handle to DID
97+
const resolveRes = await fetch(
98+
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`
99+
);
100+
101+
if (!resolveRes.ok) throw new Error('Failed to resolve handle');
102+
103+
const { did } = await resolveRes.json();
104+
const atUri = `at://${did}/app.bsky.feed.post/${postId}`;
105+
106+
// Get post details (includes like/repost counts)
107+
const postRes = await fetch(
108+
`https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${encodeURIComponent(atUri)}`
109+
);
110+
111+
if (!postRes.ok) throw new Error('Failed to fetch post');
112+
113+
const postData = await postRes.json();
114+
115+
if (!postData.posts || postData.posts.length === 0) {
116+
throw new Error('Post not found');
117+
}
118+
119+
const post = postData.posts[0];
120+
const likes = post.likeCount || 0;
121+
const reposts = post.repostCount || 0;
122+
123+
// Update counts and labels with proper pluralization
124+
if (likesCount) likesCount.textContent = likes.toString();
125+
if (repostsCount) repostsCount.textContent = reposts.toString();
126+
127+
const likesLabel = container.querySelector('.likes-label');
128+
const repostsLabel = container.querySelector('.reposts-label');
129+
if (likesLabel) likesLabel.textContent = likes === 1 ? 'like from' : 'likes from';
130+
if (repostsLabel) repostsLabel.textContent = reposts === 1 ? 'repost from' : 'reposts from';
131+
132+
// Hide likers section if no likes
133+
const likersSection = container.querySelector('.likers-section');
134+
if (likes === 0 && likersSection) {
135+
likersSection.classList.add('hidden');
136+
}
137+
138+
// Hide reposters section if no reposts
139+
const repostersSection = container.querySelector('.reposters-section');
140+
if (reposts === 0 && repostersSection) {
141+
repostersSection.classList.add('hidden');
142+
}
143+
144+
// Update Bluesky link
145+
if (blueskyLink) {
146+
blueskyLink.setAttribute(
147+
'href',
148+
`https://bsky.app/profile/${handle}/post/${postId}`
149+
);
150+
}
151+
152+
// Fetch likers for avatars (limit to 50)
153+
if (likes > 0 && likersAvatars) {
154+
const likesRes = await fetch(
155+
`https://public.api.bsky.app/xrpc/app.bsky.feed.getLikes?uri=${encodeURIComponent(atUri)}&limit=50`
156+
);
157+
158+
if (likesRes.ok) {
159+
const likesData = await likesRes.json();
160+
161+
if (likesData.likes && likesData.likes.length > 0) {
162+
likersAvatars.innerHTML = likesData.likes
163+
.map((like: { actor: { avatar?: string; displayName?: string; handle: string } }) => {
164+
const avatar = like.actor.avatar;
165+
const name = like.actor.displayName || like.actor.handle;
166+
167+
if (avatar) {
168+
// Use thumbnail size for performance
169+
const thumbUrl = avatar.replace('/img/avatar/', '/img/avatar_thumbnail/');
170+
return `<img
171+
src="${thumbUrl}"
172+
alt="${name}"
173+
title="${name}"
174+
class="w-8 h-8 rounded-full border-2 border-background"
175+
loading="lazy"
176+
/>`;
177+
}
178+
// Default avatar for users without one
179+
return `<div
180+
class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-medium"
181+
title="${name}"
182+
>${name.charAt(0).toUpperCase()}</div>`;
183+
})
184+
.join('');
185+
}
186+
}
187+
}
188+
189+
// Fetch reposters for avatars (limit to 50)
190+
const repostersAvatars = container.querySelector('.reposters-avatars');
191+
if (reposts > 0 && repostersAvatars) {
192+
const repostsRes = await fetch(
193+
`https://public.api.bsky.app/xrpc/app.bsky.feed.getRepostedBy?uri=${encodeURIComponent(atUri)}&limit=50`
194+
);
195+
196+
if (repostsRes.ok) {
197+
const repostsData = await repostsRes.json();
198+
199+
if (repostsData.repostedBy && repostsData.repostedBy.length > 0) {
200+
repostersAvatars.innerHTML = repostsData.repostedBy
201+
.map((user: { avatar?: string; displayName?: string; handle: string }) => {
202+
const avatar = user.avatar;
203+
const name = user.displayName || user.handle;
204+
205+
if (avatar) {
206+
// Use thumbnail size for performance
207+
const thumbUrl = avatar.replace('/img/avatar/', '/img/avatar_thumbnail/');
208+
return `<img
209+
src="${thumbUrl}"
210+
alt="${name}"
211+
title="${name}"
212+
class="w-8 h-8 rounded-full border-2 border-background"
213+
loading="lazy"
214+
/>`;
215+
}
216+
// Default avatar for users without one
217+
return `<div
218+
class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-medium"
219+
title="${name}"
220+
>${name.charAt(0).toUpperCase()}</div>`;
221+
})
222+
.join('');
223+
}
224+
}
225+
}
226+
227+
// Show content, hide loading
228+
loading?.classList.add('hidden');
229+
content?.classList.remove('hidden');
230+
231+
} catch (error) {
232+
// On any error, just hide the entire component gracefully
233+
container.classList.add('hidden');
234+
}
235+
}
236+
}
237+
238+
// Run on page load
239+
document.addEventListener('DOMContentLoaded', loadBlueskyEngagement);
240+
241+
// Also run on Astro page transitions (View Transitions API)
242+
document.addEventListener('astro:page-load', loadBlueskyEngagement);
243+
</script>

src/content/blog/2026/introducing-dtvem/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ title: "Introducing the Developer Tools Virtual Environment Manager!"
33
date: "2026-01-05T12:00:00-05:00"
44
categories: [golang, cli, python, node, ruby]
55
description: "A unified, cross-platform runtime version manager that actually works on Windows"
6+
blueskyPostId: 3mbpawpkn4z26
67
---
78

89
If you've ever tried to manage multiple versions of Python, Node.js, or Ruby on Windows, you know the pain. Tools like `nvm`, `pyenv`, and `rbenv` work great on macOS and Linux, but Windows support ranges from "hacky workarounds" to "just use WSL." And even on Unix systems, you're juggling three different tools with three different configurations.

src/content/blog/2026/introducing-the-visual-studio-toolbox/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ title: "Introducing the Visual Studio Toolbox!"
33
date: "2026-01-02T12:00:00-05:00"
44
categories: [dotnet, csharp, visualstudio, winui]
55
description: "Mission Control for your Visual Studio Installations, inspired by JetBrains Toolbox"
6+
blueskyPostId: 3mbhgd6et4b26
67
---
78

89
If you've ever used JetBrains Toolbox, you know how convenient it is. It's the central hub for all your JetBrains IDEs — install new tools, manage updates, launch any version with a single click. Everything in one place, tucked away in the system tray.

src/content/blog/2026/introducing-vscwhere/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ title: "Introducing vscwhere!"
33
date: "2026-01-08T12:00:00-05:00"
44
categories: [rust, cli, vscode]
55
description: "A CLI tool for locating Visual Studio Code installations on Windows, inspired by Microsoft's vswhere"
6+
blueskyPostId: 3mbwhet7cbl24
67
---
78

89
If you've ever used Microsoft's [vswhere](https://github.com/microsoft/vswhere), you know how handy it is. Need to find where Visual Studio is installed? Run `vswhere`. Need the path for a CI/CD script? `vswhere -latest -property installationPath`. It just works.

src/content/blog/2026/sdk-style-projects-for-your-visual-studio-extensions/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ title: "SDK-style Projects for your Visual Studio Extensions!"
33
date: "2026-01-01T12:00:00-05:00"
44
categories: [dotnet, csharp, vsix]
55
description: "Remember that MSBuild SDK post from last week? Well, I actually built something with it - an SDK that brings modern project files to Visual Studio extension development."
6+
blueskyPostId: 3mbezw2qfgt2m
67
---
78

9+
810
Remember [that post I wrote last week](https://www.codingwithcalvin.net/creating-your-own-msbuild-sdk-it-s-easier-than-you-think) about creating MSBuild SDKs? Well, I wasn't just writing that for fun - I was actually building something with all that knowledge.
911

1012
I've released [CodingWithCalvin.VsixSdk](https://www.nuget.org/packages/CodingWithCalvin.VsixSdk/), an MSBuild SDK that brings modern SDK-style `.csproj` files to Visual Studio extension development. No more XML soup!

src/content/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const blog = defineCollection({
1010
description: z.string().optional(),
1111
image: image().optional(),
1212
youtube: z.string().optional(),
13+
blueskyPostId: z.string().optional(),
1314
}),
1415
});
1516

0 commit comments

Comments
 (0)