This commit is contained in:
Dmitrii Magas 2026-01-27 13:27:23 -05:00 committed by GitHub
commit 379b8bab57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 340 additions and 3 deletions

View File

@ -8,7 +8,7 @@ Quartz also has the ability to hook into various providers to enable readers to
![[giscus-example.png]] ![[giscus-example.png]]
As of today, only [Giscus](https://giscus.app/) is supported out of the box but PRs to support other providers are welcome! As of today, [Giscus](https://giscus.app/) and [Bluesky](https://bsky.app/) are supported out of the box, and PRs to support other providers are welcome!
## Providers ## Providers
@ -52,6 +52,31 @@ afterBody: [
], ],
``` ```
### Bluesky
Bluesky comments allow you to pull a conversation from a [Bluesky](https://bsky.app) post directly into your Quartz pages.
#### Setup
1. Add `Component.BlueskyComments()` to the `afterBody` field of `sharedPageComponents` in `quartz.layout.ts`:
```ts title="quartz.layout.ts"
afterBody: [
Component.BlueskyComments(),
],
```
1. On any page where you want comments to appear, add the `blueskyUrl` field to your frontmatter pointing to the Bluesky post:
```markdown
---
title: My Cool Post
blueskyUrl: https://bsky.app/profile/jacky.zys.xyz/post/3lf...
---
```
The component will automatically resolve the thread and render the replies recursively.
### Customization ### Customization
Quartz also exposes a few of the other Giscus options as well and you can provide them the same way `repo`, `repoId`, `category`, and `categoryId` are provided. Quartz also exposes a few of the other Giscus options as well and you can provide them the same way `repo`, `repoId`, `category`, and `categoryId` are provided.
@ -125,7 +150,7 @@ afterBody: [
Quartz can conditionally display the comment box based on a field `comments` in the frontmatter. By default, all pages will display comments, to disable it for a specific page, set `comments` to `false`. Quartz can conditionally display the comment box based on a field `comments` in the frontmatter. By default, all pages will display comments, to disable it for a specific page, set `comments` to `false`.
``` ```markdown
--- ---
title: Comments disabled here! title: Comments disabled here!
comments: false comments: false

View File

@ -5,7 +5,7 @@ import * as Component from "./quartz/components"
export const sharedPageComponents: SharedLayout = { export const sharedPageComponents: SharedLayout = {
head: Component.Head(), head: Component.Head(),
header: [], header: [],
afterBody: [], afterBody: [Component.BlueskyComments()],
footer: Component.Footer({ footer: Component.Footer({
links: { links: {
GitHub: "https://github.com/jackyzha0/quartz", GitHub: "https://github.com/jackyzha0/quartz",

View File

@ -0,0 +1,37 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
// @ts-ignore
import script from "./scripts/bluesky.inline"
import style from "./styles/bluesky.scss"
const BlueskyComments: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => {
const blueskyUrl = fileData.frontmatter?.blueskyUrl as string | undefined
if (!blueskyUrl) {
return null
}
return (
<div
class={classNames(displayClass, "bluesky-comments-container")}
id="bluesky-comments"
data-url={blueskyUrl}
>
<h2>Comments</h2>
<p class="bluesky-meta">
Post a reply on{" "}
<a href={blueskyUrl} target="_blank" rel="noopener noreferrer">
Bluesky
</a>{" "}
to join the conversation.
</p>
<div id="bluesky-comments-list">
<p>Loading comments...</p>
</div>
</div>
)
}
BlueskyComments.afterDOMLoaded = script
BlueskyComments.css = style
export default (() => BlueskyComments) satisfies QuartzComponentConstructor

View File

@ -23,6 +23,7 @@ import Breadcrumbs from "./Breadcrumbs"
import Comments from "./Comments" import Comments from "./Comments"
import Flex from "./Flex" import Flex from "./Flex"
import ConditionalRender from "./ConditionalRender" import ConditionalRender from "./ConditionalRender"
import BlueskyComments from "./BlueskyComments"
export { export {
ArticleTitle, ArticleTitle,
@ -50,4 +51,5 @@ export {
Comments, Comments,
Flex, Flex,
ConditionalRender, ConditionalRender,
BlueskyComments,
} }

View File

@ -0,0 +1,196 @@
document.addEventListener("nav", async () => {
const container = document.getElementById("bluesky-comments")
if (!container) return
const url = container.getAttribute("data-url")
if (!url) return
try {
const { handle, postId } = parseBlueskyUrl(url)
const did = await resolveHandle(handle)
const replies = await fetchThread(did, postId)
renderComments(replies)
} catch (e) {
console.error("Error loading Bluesky comments:", e)
const list = document.getElementById("bluesky-comments-list")
if (list) {
list.textContent = "Error loading comments."
}
}
})
function parseBlueskyUrl(url: string) {
const urlParts = new URL(url).pathname.split("/")
const handle = urlParts[2]
const postId = urlParts[4]
if (!handle || !postId) throw new Error("Invalid Bluesky URL")
return { handle, postId }
}
async function resolveHandle(handle: string) {
const resolveRes = await fetch(
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`,
)
if (!resolveRes.ok) throw new Error("Could not resolve handle")
const { did } = await resolveRes.json()
return did
}
async function fetchThread(did: string, postId: string) {
const atUri = `at://${did}/app.bsky.feed.post/${postId}`
const threadRes = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${atUri}&depth=10&parentHeight=0`,
)
if (!threadRes.ok) throw new Error("Could not fetch thread")
const data = await threadRes.json()
return data.thread.replies
}
function renderComments(replies: any[]) {
const commentsList = document.getElementById("bluesky-comments-list")
if (!commentsList) return
if (replies && replies.length > 0) {
commentsList.innerHTML = "" // Clear loading text
// Sort by likes
replies.sort((a: any, b: any) => (b.post.likeCount || 0) - (a.post.likeCount || 0))
replies.forEach((reply: any) => {
if (!reply.post) return
commentsList.appendChild(createCommentNode(reply))
})
} else {
commentsList.textContent = "No comments yet."
}
}
function createCommentNode(reply: any): HTMLElement {
const post = reply.post
const author = post.author
const date = new Date(post.indexedAt).toLocaleDateString()
const div = document.createElement("div")
div.className = "comment"
// Header
const header = document.createElement("div")
header.className = "comment-header"
if (author.avatar) {
const avatar = document.createElement("img")
avatar.src = author.avatar
avatar.alt = author.handle
header.appendChild(avatar)
}
const handleLink = document.createElement("a")
const postId = post.uri.split("/").pop()
handleLink.href = `https://bsky.app/profile/${author.did}/post/${postId}`
handleLink.target = "_blank"
handleLink.className = "handle"
handleLink.textContent = author.displayName || author.handle
header.appendChild(handleLink)
const timeSpan = document.createElement("span")
timeSpan.className = "time"
timeSpan.textContent = date
header.appendChild(timeSpan)
div.appendChild(header)
// Body
div.appendChild(renderPostBody(post))
// Actions
const actions = document.createElement("div")
actions.className = "comment-actions"
const likes = document.createElement("span")
likes.textContent = `❤️ ${post.likeCount || 0}`
actions.appendChild(likes)
const reposts = document.createElement("span")
reposts.textContent = `🔁 ${post.repostCount || 0}`
actions.appendChild(reposts)
div.appendChild(actions)
// Recursive rendering for nested replies
if (reply.replies && reply.replies.length > 0) {
const repliesContainer = document.createElement("div")
repliesContainer.className = "replies"
reply.replies.forEach((child: any) => {
if (!child.post) return
repliesContainer.appendChild(createCommentNode(child))
})
div.appendChild(repliesContainer)
}
return div
}
function renderPostBody(post: any): HTMLElement {
const text = post.record.text
const facets = post.record.facets
const body = document.createElement("div")
body.className = "comment-body"
if (!facets || facets.length === 0) {
body.textContent = text
return body
}
facets.sort((a: any, b: any) => a.index.byteStart - b.index.byteStart)
const bytes = new TextEncoder().encode(text)
let lastByteIndex = 0
for (const facet of facets) {
const { byteStart, byteEnd } = facet.index
if (byteStart > lastByteIndex) {
const slice = bytes.slice(lastByteIndex, byteStart)
const textNode = document.createTextNode(new TextDecoder().decode(slice))
body.appendChild(textNode)
}
const slice = bytes.slice(byteStart, byteEnd)
const facetText = new TextDecoder().decode(slice)
const feature = facet.features[0]
if (feature["$type"] === "app.bsky.richtext.facet#link") {
const link = document.createElement("a")
link.href = feature.uri
link.target = "_blank"
link.rel = "noopener noreferrer"
link.textContent = facetText
body.appendChild(link)
} else if (feature["$type"] === "app.bsky.richtext.facet#mention") {
const link = document.createElement("a")
link.href = `https://bsky.app/profile/${feature.did}`
link.target = "_blank"
link.rel = "noopener noreferrer"
link.textContent = facetText
body.appendChild(link)
} else if (feature["$type"] === "app.bsky.richtext.facet#tag") {
const link = document.createElement("a")
link.href = `https://bsky.app/hashtag/${feature.tag}`
link.target = "_blank"
link.rel = "noopener noreferrer"
link.textContent = facetText
body.appendChild(link)
} else {
body.appendChild(document.createTextNode(facetText))
}
lastByteIndex = byteEnd
}
if (lastByteIndex < bytes.length) {
const slice = bytes.slice(lastByteIndex)
body.appendChild(document.createTextNode(new TextDecoder().decode(slice)))
}
return body
}

View File

@ -0,0 +1,77 @@
.bluesky-comments-container {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--lightgray);
h2 {
margin-bottom: 1rem;
}
.bluesky-meta {
margin-bottom: 1.5rem;
color: var(--gray);
font-size: 0.9rem;
a {
color: var(--secondary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.comment {
margin-top: 1rem;
padding-left: 1rem;
border-left: 2px solid var(--lightgray);
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
img {
width: 24px;
height: 24px;
border-radius: 50%;
}
.handle {
font-weight: bold;
color: var(--dark);
font-size: 0.9rem;
}
.time {
color: var(--gray);
font-size: 0.8rem;
}
}
.comment-body {
font-size: 0.95rem;
color: var(--darkgray);
white-space: pre-wrap;
a {
color: var(--secondary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.comment-actions {
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--gray);
display: flex;
gap: 1rem;
}
}
}