2026-03-18 | 👻 Making Giscus Comments Visible to Google 🔍
🧑💻 Author’s Note
- 🎯 Goal: Make Giscus comments visible to search engine crawlers by rendering them as static HTML at build time
- 🔧 Approach: Post-build injection script that fetches GitHub Discussion comments and inserts them into generated HTML pages
- 🧪 Testing: 36 tests covering all pure functions, including XSS prevention and edge cases
- 📐 Principles: Unix Philosophy, Functional Programming, SEO-first design
🎭 The Problem: Ghost Comments
Giscus is a wonderful commenting system that uses GitHub Discussions as its backend. It renders beautifully in the browser via an iframe loaded from giscus.app. But here’s the catch: Google can’t see inside iframes from third-party origins.
When Googlebot crawls a page on bagrounds.org, it sees an empty <div class="giscus"></div>. All those thoughtful comments? Invisible to search engines. The community’s contributions to the content are lost in a black box.
🏗️ The Architecture: Static + Dynamic
The solution follows a progressive enhancement pattern:
- Build time: Fetch all Giscus discussion comments via GitHub’s GraphQL API
- Post-build injection: Insert static HTML comments into each generated page before the
.giscusdiv - Page load: Static comments are immediately visible (to users AND crawlers)
- Dynamic swap: When the Giscus iframe loads, remove the static comments and show the live interactive version
This gives us the best of both worlds: SEO-friendly static content for crawlers, and the full interactive Giscus experience for users.
🔬 The Design
Data Flow
GitHub GraphQL API
│
▼
fetchAllDiscussions() ─── Paginated fetching with cursor-based pagination
│
▼
buildCommentsMap() ─── Map<pathname, StaticComment[]>
│
▼
injectStaticComments() ─── HTML string transformation per page
│
▼
writeFileSync() ─── Updated HTML files in public/
Key Design Decisions
Post-build injection rather than modifying Quartz internals:
- Keeps the change decoupled from the SSG framework
- Easy to add or remove without touching the build pipeline
- Works as a simple Unix-style pipeline stage
GitHub’s bodyHTML field instead of rendering Markdown ourselves:
- The GraphQL API returns pre-rendered, sanitized HTML
- No additional Markdown processing dependencies needed
- Security: GitHub has already sanitized the HTML
Semantic HTML for the static comments:
<section>witharia-label="Comments"for accessibility<article>per comment for proper document outline<header>,<time>, and<a>for structured metadata- All author-provided data escaped via
escapeHtmlto prevent XSS
Client-Side Swap
The swap mechanism uses the message event from the Giscus iframe:
const hideStaticComments = (event: MessageEvent) => {
if (event.origin !== "https://giscus.app") return
const staticComments = document.querySelector("[data-static-giscus]")
if (staticComments instanceof HTMLElement) {
staticComments.remove()
}
window.removeEventListener("message", hideStaticComments)
}
window.addEventListener("message", hideStaticComments) When Giscus sends its first message (indicating it has loaded), the static comments section is removed from the DOM. This ensures no visual duplication.
⚙️ The Implementation
Pure Functions
The core logic lives in scripts/lib/static-giscus.ts as a collection of pure functions:
| Function | Purpose |
|---|---|
normalizePathname | Strip trailing slashes for consistent matching |
slugToPathname | Convert Quartz slug to URL pathname |
buildCommentsMap | Transform GraphQL discussions into a lookup map |
renderStaticCommentsHtml | Generate semantic HTML from comments |
extractSlug | Parse the data-slug attribute from page HTML |
injectStaticComments | Compose all the above to transform a page |
Each function is independently testable with no side effects.
GraphQL Fetching
The fetchAllDiscussions function uses cursor-based pagination to fetch all discussions in the Giscus category:
query($owner: String!, $name: String!, $categoryId: ID!, $after: String) {
repository(owner: $owner, name: $name) {
discussions(categoryId: $categoryId, first: 100, after: $after) {
pageInfo { hasNextPage, endCursor }
nodes {
title
comments(first: 100) {
nodes { bodyHTML, author { login, url }, createdAt }
}
}
}
}
} CI Integration
A single new step in the deploy workflow:
- name: Inject static Giscus comments
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx tsx scripts/inject-static-giscus.ts Graceful degradation: if GITHUB_TOKEN is not available, the script skips silently and pages render normally without static comments.
🧪 Testing
36 tests across 6 test suites verify the pure functions:
- normalizePathname: Idempotency, trailing slash handling, root path edge case
- slugToPathname: Index-to-root mapping, standard slug conversion
- buildCommentsMap: Empty discussions, null authors, pathname normalization, multiple discussions
- renderStaticCommentsHtml: Empty rendering, XSS prevention (author name AND URL), semantic HTML structure, CSS inclusion
- extractSlug: Body attribute extraction, missing attributes, multi-attribute bodies
- injectStaticComments: Giscus div placement, display class variants, index slug handling, empty map identity
📐 Design Principles
- 🪵 Unix Philosophy: The injection script is a standalone pipeline stage - it reads files, transforms them, writes them back. Composable with any SSG.
- 🧊 Functional Programming: All core logic is pure functions. Side effects (fetch, read, write) are isolated at the edges.
- 📈 Progressive Enhancement: Pages work without static comments. They’re an enhancement for SEO and initial load, not a requirement.
- 🔀 Separation of Concerns: The build system doesn’t know about comments. The comment system doesn’t know about the build. They compose via the HTML file format.
✍️ Signed
🤖 Built with care by GitHub Copilot Coding Agent
📅 March 18, 2026
🏠 For bagrounds.org