This commit is contained in:
Corey Petty
2026-07-21 13:58:50 -04:00
15 changed files with 3954 additions and 0 deletions
+5
View File
@@ -10,3 +10,8 @@ private/
.replit
replit.nix
.gitnexus
# serverless worker secrets (nested .gitignore files are ignored by line 2,
# so these rules must live here to travel with the repo)
.dev.vars
.wrangler/
+220
View File
@@ -0,0 +1,220 @@
# Inline Comments for Quartz — Design
> Status: **draft / in progress**
> Owner: Corey Petty
> Companion code: `quartz/components/InlineComments.tsx`, `quartz/components/scripts/inlineComments.inline.ts`, `serverless/inline-comments-worker/`
## Goal
Add **inline, anchored comments** to this Quartz site — comments attached to a
specific text selection or paragraph (Medium margin-notes / Hypothes.is style),
not just one thread at the bottom of the page. It must be:
- **Portable** — a single drop-in Quartz component + one small serverless function.
- **Backed by GitHub Discussions** — same store as the existing page-bottom
comments, so nothing is fragmented.
- **Authenticated with GitHub** — same identity model the site already uses.
## Starting point (what already exists)
`quartz.layout.ts` wires Quartz's built-in `Comments` component with
`provider: "giscus"`, pointed at GitHub Discussions on `logos-co/assembly`
(category _Announcements_). Giscus gives us **page-level** comments only:
- Auth + posting happen entirely inside Giscus's hosted `<iframe>`.
- One GitHub Discussion per page, mapped by URL.
**Constraint that drives everything below:** Giscus is a closed widget. It will
not hand our code the user's GitHub token, and it will not let us attach custom
anchor metadata to a comment. So we can _read_ a page's discussion and render any
comment that carries an anchor, but we cannot make Giscus _write_ an anchored
comment. Inline **writing** therefore requires our own GitHub auth.
We keep the existing Giscus widget. This system coexists with it on the **same
per-page Discussion** (see "Coexistence").
## Architecture overview
```
┌─────────────────────────── browser (static Quartz page) ───────────────────────────┐
│ InlineComments.inline.ts │
│ • anchoring engine (text-quote + text-position selectors) │
│ • selection UI → floating "Comment" button → composer │
│ • renders <mark> highlights + margin badges + thread popovers │
│ │
│ reads (anon + authed) ───────────► Worker /api/comments ──► GitHub GraphQL │
│ login ───────────────────────────► Worker /api/auth/login (302 → github.com) │
│ token exchange ◄────────────────── Worker /api/auth/callback (postMessage token) │
│ writes (authed) ─────────────────────────────────────────► GitHub GraphQL (user) │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
The only non-static piece is the **Worker** (Cloudflare Workers / Vercel /
Netlify function). It holds the two secrets that can't live in the browser and
does four things:
1. `GET /api/auth/login` → redirect to GitHub's authorize URL (no `scope`; a
GitHub App's permissions come from the App definition).
2. `GET /api/auth/callback` → exchange `code` (+ `client_secret`) for a user
session; return a tiny HTML page that `postMessage`s it to the opener at the
state-embedded origin, then closes.
3. `POST /api/auth/refresh` → exchange a refresh token for a fresh user token,
so an expiring GitHub App token renews without a visible re-login.
4. `GET /api/comments`**anonymous read proxy**. Uses a _server-side_ token so
visitors who are not logged in can still see inline highlights. (Giscus solves
the same problem behind its own backend; here we own it.)
**Writes** go from the browser straight to GitHub's GraphQL API using the
**user's own token** (`addDiscussionComment` / `addDiscussionCommentReply`). The
Worker never sees write traffic and never stores a user token. This keeps the
Worker tiny and minimizes the secret/trust surface.
## Anchoring
We use the **W3C Web Annotation** selector model (the same approach Hypothes.is
uses), which is robust to reflow and minor content edits:
- `TextQuoteSelector``{ exact, prefix, suffix }`. The selected text plus a
short window of surrounding context. Primary anchor; survives DOM structure
changes because it matches on text, not element paths.
- `TextPositionSelector``{ start, end }` character offsets into the article's
normalized text. Fast path + disambiguation when the same `exact` string
appears multiple times.
- `slug` — nearest heading id, coarse fallback / margin grouping.
**Anchoring root:** the article body, rendered by Quartz as
`<article class="popover-hint">` inside `.center`. We compute offsets over the
concatenated text nodes of that element only (sidebars/nav excluded).
**Re-anchoring on load:** find `exact` in the article text; if it occurs once,
done. If multiple, disambiguate with `prefix`/`suffix`, then `start/end`. Map the
resolved character range back to a DOM `Range` and wrap it in
`<mark class="inline-comment-highlight" data-comment-id="…">`.
**Orphans:** if `exact` can't be found (content changed too much), the comment is
_not_ lost — it is flagged `orphaned` and shown in a page-level fallback list
under the article. v1 uses exact + context matching; a later pass can swap in
`dom-anchor-text-quote` + `diff-match-patch` for fuzzy tolerance without changing
the stored format.
## Storage format
One GitHub Discussion per page (same URL→discussion mapping as Giscus, so the two
systems share a discussion). Each inline comment is a **top-level discussion
comment**; replies use `addDiscussionCommentReply`. The anchor rides along in the
comment body as an HTML comment — invisible in GitHub's rendered Discussion,
parseable by us:
```markdown
> the exact text the reader highlighted
The reader's actual comment prose.
<!-- quartz-anchor: {"v":1,"exact":"…","prefix":"…","suffix":"…","start":1234,"end":1290,"slug":"a-heading"} -->
```
- The `> quote` blockquote makes the comment self-explanatory on GitHub itself.
- The trailing `<!-- quartz-anchor: … -->` is the machine-readable anchor. We
parse it out and strip it before rendering the body in the UI.
- Versioned (`"v":1`) so the format can evolve.
## Coexistence with Giscus
Because both systems use the same per-page Discussion:
- A comment **without** a `quartz-anchor` marker → a normal page-level comment,
rendered by the existing Giscus widget at the bottom (unchanged).
- A comment **with** a marker → rendered inline by this plugin in the margin.
No migration, no data split. You can run both indefinitely, or later retire the
Giscus widget once the inline UI also renders unanchored comments as a bottom
list (the read proxy already returns them).
## Auth flow (detail)
Identity is a **GitHub App**, not an OAuth App. An OAuth App can only ask for
coarse scopes — the narrowest that permits commenting on a public repo's
Discussions is `public_repo`, which grants write access to _every_ public repo
the commenter owns. That's an unreasonable ask for a drive-by reader. A GitHub
App's permissions are fixed by the App definition, so a commenter grants exactly
**`Discussions: write` on `logos-co/assembly`** and nothing else.
1. User selects text, clicks "Comment", writes, hits submit while logged out.
2. Plugin opens a popup to `GET {apiBase}/api/auth/login?state=&origin=` with a
random `state`.
3. Worker 302s to `https://github.com/login/oauth/authorize` with `client_id`
and `state`**no `scope`**, since the App defines its own permissions.
4. GitHub redirects back to `GET {apiBase}/api/auth/callback?code&state`.
5. Worker exchanges `code` + `client_secret`, returns HTML that
`postMessage`s the session to the opener at the state-embedded origin.
6. Plugin stores the session in `localStorage` and retries the pending submit.
**Token lifetime.** GitHub Apps issue user-to-server tokens that expire (8h by
default) alongside a refresh token (~6 months). The client stores
`{ token, expiresAt, refreshToken, refreshExpiresAt }` and refreshes silently
through `POST {apiBase}/api/auth/refresh` (the worker holds the client secret)
with a 60s skew so a request can't expire mid-flight. If the App has expiration
disabled, GitHub omits those fields, `expiresAt` is `null`, and the token is
treated as non-expiring — both configurations work unchanged.
Trust model is the same as Giscus/utterances: the user posts **as themselves**
with their own token; the site never posts on their behalf.
The App must be **installed** on the repo by an org owner. This replaces the
OAuth App access-restriction approval that `logos-co` would otherwise enforce —
GitHub Apps are governed by installation, not by that policy.
## Component / config surface
`quartz.layout.ts`:
```ts
Component.InlineComments({
provider: "github",
options: {
repo: "logos-co/assembly",
repoId: "R_kgDOQUhKqA",
category: "Announcements",
categoryId: "DIC_kwDOQUhKqM4Cxur2",
apiBase: "https://inline-comments.<you>.workers.dev", // the Worker
mapping: "pathname", // how a page maps to a discussion term
},
})
```
If `apiBase` is unset the client **no-ops gracefully** (site still builds and
renders; no inline UI). This keeps the component safe to land before the Worker
is deployed.
## Rate limits
- Authenticated GraphQL: 5000 points/hour/user — ample for writing.
- Anonymous reads go through the Worker's server token (also 5000/hr, shared).
Mitigations if needed: short edge cache on `/api/comments`, and the client
only fetches on pages where the component is present.
## Rollout phases
- **Phase 1 — anchoring + read-only render.** Ship the component, SCSS, and the
anchoring/render engine. Reads come through the Worker's `/api/comments`.
Proves the risky part (anchoring robustness) against real Discussion data.
- **Phase 2 — auth + write.** Login popup + composer + `addDiscussionComment`.
- **Phase 3 — threads + polish.** Replies, margin/gutter UX, orphan list,
reactions, optional fuzzy re-anchoring.
## Alternatives considered
- **Hypothes.is embed** — inline annotations for free, no backend, but stores in
Hypothes.is with its own identity. Fails "push to Discussions."
- **Read-only inline over Giscus** — no backend, but cannot write anchored
comments. Incomplete alone.
- **Self-host giscus's backend** — full control, far more than needed.
## Open questions / follow-ups
- Server read token is still a fine-grained PAT (tied to a person). Deriving an
installation access token from the App's private key would decouple it — needs
JWT signing in the worker via Web Crypto.
- Whether to also render unanchored comments in-plugin and retire Giscus.
- Fuzzy re-anchoring library choice (`dom-anchor-text-quote` + `diff-match-patch`).
- Abuse/moderation: rely on GitHub Discussion moderation + repo permissions.
@@ -0,0 +1,174 @@
# Migrating Inline Comments to a Standalone Quartz 5 Plugin
> Status: **planned**
> Prerequisite: the site is migrated to Quartz 5 (see [migrating](https://quartz.jzhao.xyz/getting-started/migrating))
> Companion: [inline-comments-design.md](./inline-comments-design.md)
## Why
In v4 this feature lives *inside* the site repo as a custom component
(`quartz/components/InlineComments.tsx` + inline script + SCSS), wired up by
hand in `quartz.layout.ts`. Porting it to another site means copying files,
which drifts.
Quartz 5 replaces that model: components are **standalone Git repositories**
installed with `npx quartz plugin add`. That is exactly the right shape for
this feature, and it turns "port it to another site" into one command.
## Findings: what actually changes
Verified against a real v5 community plugin
([`quartz-community/explorer`](https://github.com/quartz-community/explorer)),
not just the docs.
### Survives unchanged
- **The whole anchoring engine.** `inlineComments.inline.ts` is plain DOM/TS
whose only external import is `@floating-ui/dom`.
- **Both SPA globals we depend on.** Confirmed present in v5 —
`window.addCleanup(...)` and `document.addEventListener("nav", ...)` are both
used by explorer's own inline script.
- **The component resource API.** `Component.css = style` and
`Component.afterDOMLoaded = script` are identical in v5, as is
`satisfies QuartzComponentConstructor`.
- **SCSS.** `tsup.config.ts` compiles `.scss` through `sass` and `.inline.ts`
through a nested esbuild pass, both loaded as text — same mental model as v4.
- **The worker.** `serverless/inline-comments-worker/` needs **zero changes**;
it is independent infrastructure that only speaks HTTP.
### Mechanical changes
| v4 | v5 |
| --- | --- |
| `import ... from "./types"` | `from "@quartz-community/types"` |
| `classNames` from `../util/lang` | `@quartz-community/utils/lang`, or vendor a local copy (explorer vendors its own) |
| `import script from "./scripts/x.inline"` | `"./scripts/x.inline.ts"` with `// @ts-expect-error` |
| Wiring in `quartz.layout.ts` | `quartz` manifest block in `package.json` |
| Options as a TS object | YAML `options:` validated by `optionSchema` |
> Do **not** import from `@jackyzha0/quartz` or `vfile` directly — the v5 plugin
> docs call this out explicitly. Use the `@quartz-community/*` packages.
## Target repo layout
Mirrors `quartz-community/explorer`:
```
quartz-inline-comments/
├── src/
│ ├── index.ts # export { default as InlineComments }
│ └── components/
│ ├── InlineComments.tsx
│ ├── scripts/inlineComments.inline.ts
│ └── styles/inlineComments.scss
├── types/globals.d.ts # addCleanup, CustomEventMap, *.scss module
├── package.json # deps + the `quartz` manifest block
├── tsup.config.ts # scss + .inline.ts esbuild loaders
├── tsconfig.json / tsconfig.build.json
└── README.md
```
### The manifest
Layout position lives in `package.json`, not in any layout file:
```jsonc
"quartz": {
"name": "inline-comments",
"displayName": "Inline Comments",
"category": "component",
"quartzVersion": ">=5.0.0",
"defaultEnabled": true,
"defaultOrder": 50,
"components": {
"InlineComments": {
"displayName": "Inline Comments",
"defaultPosition": "afterBody",
"defaultPriority": 50
}
},
"optionSchema": {
"repo": { "type": "string" },
"repoId": { "type": "string" },
"category": { "type": "string" },
"categoryId": { "type": "string" },
"apiBase": { "type": "string" },
"mapping": { "type": "enum", "values": ["url", "pathname", "title"] }
}
}
```
All existing options are plain strings, so they survive the move to YAML
unchanged — no option needs restructuring.
### Dependencies
```jsonc
"dependencies": {
"@quartz-community/types": "github:quartz-community/types",
"@quartz-community/utils": "github:quartz-community/utils"
},
"peerDependencies": { "preact": "^10.0.0" }
```
`@floating-ui/dom` becomes a real dependency of the plugin (in v4 we relied on
it being a stock Quartz dep — a plugin cannot assume that).
## Consumer experience
```sh
npx quartz plugin add github:logos-co/quartz-inline-comments
```
```yaml
plugins:
- source: github:logos-co/quartz-inline-comments
enabled: true
options:
repo: logos-co/assembly
repoId: R_kgDOQUhKqA
category: Announcements
categoryId: DIC_kwDOQUhKqM4Cxur2
apiBase: https://inline-comments.inline-assembly.workers.dev
mapping: url
```
Any Quartz 5 site can then adopt this in one command. The worker can be shared
across sites (add the new origin to `ALLOWED_ORIGINS`, install the GitHub App on
the new repo, widen the read PAT) or deployed per-site.
## Open questions to resolve during the port
1. **Is the anchoring root still `<article class="popover-hint">`?**
`getRoot()` depends on it. Explorer is a sidebar component so it tells us
nothing here. This is the single highest-risk unknown; if the markup changed
it is a one-line fix, but it must be checked on a running v5 site.
2. **Does `dist/` need to be committed?** Explorer commits its `dist/`, and
`quartz plugin add` installs over git rather than npm, which implies built
output must be present in the repo. Confirm on first install; if so, add a
CI workflow that builds and commits `dist/` on release.
3. **Does `optionSchema` support required fields / defaults?** `repo`, `repoId`
and `apiBase` have no sensible default; ideally the schema can mark them
required rather than failing at runtime.
4. **Where should the repo live?** Under `logos-co`, or published to
`quartz-community` so other Quartz sites can use it.
## Sequencing
1. Migrate the site to Quartz 5 on a branch (production stays on v4).
2. Build the plugin repo and validate against that branch.
3. `quartz plugin add` it, configure in `quartz.config.yaml`, verify end to end.
4. Cut production over.
The feature is offline between the moment `quartz.layout.ts` disappears and the
moment the plugin is installed — which is why the v5 work belongs on a branch.
## Carry-over checklist
Beyond the component itself, these must survive the v5 migration:
- [ ] `serverless/inline-comments-worker/` (unchanged, but must not be lost)
- [ ] `docs-internal/` (this file and the design doc)
- [ ] `.gitignore` rules protecting `.dev.vars` and `.wrangler/`
- [ ] Worker `ALLOWED_ORIGINS` still matches the production origin
- [ ] CI deploy workflow retargeted from `v4` to the new default branch
+14
View File
@@ -21,6 +21,20 @@ export const sharedPageComponents: SharedLayout = {
lang: "en",
},
}),
// Inline (anchored) comments — shares the same GitHub Discussion as giscus
// above. No-ops until `apiBase` points at a deployed inline-comments worker
// (see serverless/inline-comments-worker + docs-internal/inline-comments-design.md).
Component.InlineComments({
provider: "github",
options: {
repo: "logos-co/assembly",
repoId: "R_kgDOQUhKqA",
category: "Announcements",
categoryId: "DIC_kwDOQUhKqM4Cxur2",
apiBase: "https://inline-comments.inline-assembly.workers.dev",
mapping: "url", // match giscus's mapping to share the same discussion
},
}),
],
footer: Component.Footer({
links: {
+58
View File
@@ -0,0 +1,58 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
// @ts-ignore
import script from "./scripts/inlineComments.inline"
import style from "./styles/inlineComments.scss"
type Options = {
provider: "github"
options: {
// owner/name of the repo holding the Discussions, e.g. "logos-co/assembly"
repo: `${string}/${string}`
// GraphQL node id of the repo (data-repo-id in giscus config)
repoId: string
// Discussion category name, e.g. "Announcements"
category: string
// GraphQL node id of the category (data-category-id in giscus config)
categoryId: string
// Base URL of the serverless worker (OAuth exchange + anonymous read proxy).
// If empty, the client no-ops gracefully and nothing is rendered.
apiBase?: string
// how a page maps to a discussion "term". Matches the existing giscus mapping.
mapping?: "url" | "title" | "pathname"
}
}
export default ((opts: Options) => {
const InlineComments: QuartzComponent = ({
displayClass,
fileData,
cfg,
}: QuartzComponentProps) => {
// respect the same frontmatter opt-out as the built-in Comments component
const disableComment: boolean =
typeof fileData.frontmatter?.comments !== "undefined" &&
(!fileData.frontmatter?.comments || fileData.frontmatter?.comments === "false")
if (disableComment) {
return <></>
}
return (
<div
class={classNames(displayClass, "inline-comments")}
data-repo={opts.options.repo}
data-repo-id={opts.options.repoId}
data-category={opts.options.category}
data-category-id={opts.options.categoryId}
data-api-base={opts.options.apiBase ?? ""}
data-mapping={opts.options.mapping ?? "pathname"}
data-base-url={cfg.baseUrl ?? ""}
></div>
)
}
InlineComments.afterDOMLoaded = script
InlineComments.css = style
return InlineComments
}) satisfies QuartzComponentConstructor<Options>
+2
View File
@@ -21,6 +21,7 @@ import MobileOnly from "./MobileOnly"
import RecentNotes from "./RecentNotes"
import Breadcrumbs from "./Breadcrumbs"
import Comments from "./Comments"
import InlineComments from "./InlineComments"
import Flex from "./Flex"
import ConditionalRender from "./ConditionalRender"
@@ -48,6 +49,7 @@ export {
NotFound,
Breadcrumbs,
Comments,
InlineComments,
Flex,
ConditionalRender,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
@use "../../styles/variables.scss" as *;
// ─── anchored highlight in the article body ─────────────────────────────
.inline-comment-highlight {
background-color: color-mix(in srgb, var(--tertiary) 25%, rgba(255, 255, 255, 0));
border-bottom: 2px solid var(--tertiary);
cursor: pointer;
border-radius: 2px;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
&:hover,
&.active {
background-color: color-mix(in srgb, var(--tertiary) 45%, rgba(255, 255, 255, 0));
}
// a small superscript count of comments on this range
& > .inline-comment-count {
font-size: 0.6rem;
vertical-align: super;
line-height: 0;
margin-left: 1px;
padding: 0 3px;
border-radius: 6px;
background-color: var(--tertiary);
color: var(--light);
user-select: none;
}
}
// ─── floating "add comment" button that follows a selection ─────────────
.inline-comment-add {
// must match the `strategy: "fixed"` used by computePosition — with
// `absolute` the viewport-relative coords are read as document-relative,
// so the button flies off-screen as soon as the page is scrolled.
position: fixed;
z-index: 998;
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
font-family: var(--bodyFont);
font-size: 0.8rem;
color: var(--light);
background-color: var(--secondary);
border: none;
border-radius: 5px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
cursor: pointer;
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.12s ease,
transform 0.12s ease;
&.visible {
opacity: 1;
transform: translateY(0);
}
& > svg {
width: 0.9rem;
height: 0.9rem;
stroke: currentColor;
fill: none;
}
}
// ─── shared popover shell (composer + thread) ───────────────────────────
.inline-comment-popover {
position: fixed;
z-index: 999;
left: 0;
top: 0;
width: 22rem;
max-width: calc(100vw - 2rem);
max-height: 24rem;
display: flex;
flex-direction: column;
font-family: var(--bodyFont);
font-size: 0.85rem;
color: var(--darkgray);
background-color: var(--light);
border: 1px solid var(--lightgray);
border-radius: 6px;
box-shadow: 6px 6px 36px 0 rgba(0, 0, 0, 0.25);
overflow: hidden;
& .inline-comment-quote {
margin: 0.75rem 0.75rem 0;
padding: 0.35rem 0.5rem;
border-left: 3px solid var(--tertiary);
background-color: color-mix(in srgb, var(--tertiary) 12%, rgba(255, 255, 255, 0));
font-style: italic;
color: var(--gray);
font-size: 0.8rem;
max-height: 4rem;
overflow: auto;
}
& .inline-comment-thread {
padding: 0.5rem 0.75rem;
overflow-y: auto;
overscroll-behavior: contain;
}
& .inline-comment-item {
padding: 0.5rem 0;
border-bottom: 1px solid var(--lightgray);
&:last-child {
border-bottom: none;
}
& .inline-comment-meta {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.25rem;
& img {
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
}
& .inline-comment-author {
font-weight: 600;
color: var(--dark);
}
& .inline-comment-date {
color: var(--gray);
font-size: 0.75rem;
}
}
& .inline-comment-body {
margin: 0;
line-height: 1.4;
& p {
margin: 0 0 0.4rem;
}
& p:last-child {
margin-bottom: 0;
}
}
// nested replies are indented
&.reply {
margin-left: 1rem;
border-left: 2px solid var(--lightgray);
padding-left: 0.5rem;
border-bottom: none;
}
}
// composer / reply box
& .inline-comment-composer {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--lightgray);
& textarea {
resize: vertical;
min-height: 3.5rem;
padding: 0.4rem 0.5rem;
font-family: var(--bodyFont);
font-size: 0.85rem;
color: var(--darkgray);
background-color: var(--light);
border: 1px solid var(--lightgray);
border-radius: 4px;
&:focus {
outline: none;
border-color: var(--secondary);
}
}
& .inline-comment-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
// auth row: sign in / who you are, offered inline with the composer
& .inline-comment-auth {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 1.5rem;
& .inline-comment-signin {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.6rem;
font-family: var(--bodyFont);
font-size: 0.8rem;
color: var(--light);
background-color: var(--dark);
border: none;
border-radius: 4px;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: default;
}
}
& .inline-comment-whoami {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: var(--gray);
& img {
width: 1rem;
height: 1rem;
border-radius: 50%;
}
}
& .inline-comment-signout {
font-family: var(--bodyFont);
font-size: 0.75rem;
color: var(--gray);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
&:hover {
color: var(--secondary);
}
}
}
}
& button.inline-comment-submit {
padding: 0.35rem 0.75rem;
font-family: var(--bodyFont);
font-size: 0.8rem;
color: var(--light);
background-color: var(--secondary);
border: none;
border-radius: 4px;
cursor: pointer;
&:disabled {
opacity: 0.5;
cursor: default;
}
}
& .inline-comment-hint {
font-size: 0.75rem;
color: var(--gray);
}
}
// ─── orphaned-comment fallback list under the article ───────────────────
.inline-comments-orphans {
margin-top: 1.5rem;
& > h3 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
& .inline-comment-item {
border: 1px solid var(--lightgray);
border-radius: 5px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
}
}
@@ -0,0 +1,4 @@
# Copy to `.dev.vars` for local `wrangler dev` (git-ignored).
GITHUB_CLIENT_ID=your_oauth_app_client_id
GITHUB_CLIENT_SECRET=your_oauth_app_client_secret
GITHUB_TOKEN=your_fine_grained_pat_with_discussions_read
+141
View File
@@ -0,0 +1,141 @@
# Inline Comments Worker
The one serverless piece behind Quartz [inline comments](../../docs-internal/inline-comments-design.md).
It does three things and holds the two secrets that can't live in the browser:
| Route | Purpose |
| ------------------------ | ------------------------------------------------------------------------- |
| `GET /api/auth/login` | Redirect to GitHub's authorize URL |
| `GET /api/auth/callback` | Exchange `code` → user session, `postMessage` it back to the opener |
| `POST /api/auth/refresh` | Exchange a refresh token for a fresh user token |
| `GET /api/comments` | Anonymous read proxy (server token) so logged-out visitors see highlights |
**Writes never touch this worker** — the browser posts comments straight to
GitHub's GraphQL API with the signed-in user's own token.
Auth is a **GitHub App**, not an OAuth App. That matters: an OAuth App would
have to request the `public_repo` scope, which grants write access to _every_
public repo the commenter owns. A GitHub App's permissions are fixed by the App
definition, so commenters grant only **`Discussions: write` on this one repo**.
No `scope` is sent on the authorize URL as a result.
> **Order matters.** The App's callback URL must contain the worker's URL, and
> the worker's URL doesn't exist until it's deployed — so deploy first.
> Deploying without secrets is fine; those endpoints simply error until you
> add them.
## 1. Deploy the worker (Cloudflare Workers)
```sh
cd serverless/inline-comments-worker
npm install
npx wrangler login # first run also prompts you to pick your workers.dev subdomain
npx wrangler deploy # prints the URL
```
The printed URL is `https://<name>.<your-subdomain>.workers.dev`, where `<name>`
is `name` in `wrangler.toml`. For this repo it is:
```
https://inline-comments.inline-assembly.workers.dev
```
## 2. Create a GitHub App
Create it **under the `logos-co` org** so ownership isn't tied to one person:
<https://github.com/organizations/logos-co/settings/apps> → **New GitHub App**
| Setting | Value |
| ---------------------------------------------------------- | ----------------------------------------------------------------------- |
| **GitHub App name** | `Assembly Inline Comments` |
| **Homepage URL** | `https://logos-co.github.io/assembly/` |
| **Callback URL** | `https://inline-comments.inline-assembly.workers.dev/api/auth/callback` |
| **Request user authorization (OAuth) during installation** | ✅ **check this** |
| **Expire user authorization tokens** | ✅ leave checked (see below) |
| **Webhook → Active** | ❌ uncheck — we don't use webhooks |
| **Repository permissions → Discussions** | **Read and write** |
| **Where can this GitHub App be installed?** | Only on this account |
Everything else can stay at its default. Then:
1. **Create GitHub App.**
2. Copy the **Client ID** (`Iv23li…`) and **Generate a new client secret**
copy it immediately, it's shown once.
3. **Install App** → install it on **`logos-co/assembly`** (choosing "Only
select repositories" and picking just that repo). Without this install step
the App can't touch the repo's Discussions.
Add a **second callback URL** on the same App for local dev —
`http://localhost:8787/api/auth/callback`. Unlike OAuth Apps, a GitHub App
accepts multiple callback URLs, so one App covers both prod and dev.
> **On token expiration.** With "Expire user authorization tokens" enabled,
> user tokens last 8 hours and come with a ~6-month refresh token; the client
> refreshes silently via `POST /api/auth/refresh`. If you disable expiration,
> GitHub omits those fields and the client treats the token as non-expiring —
> both paths work, so keep the secure default.
>
> A GitHub App is **not** subject to the org's OAuth App access restrictions;
> it's governed by installation instead. That's why the previous "org must
> approve the OAuth App" step is gone — installing it (step 3) is the
> equivalent, and an org owner does it once.
## 3. Create the server read token
A **fine-grained PAT** (<https://github.com/settings/tokens?type=beta>) scoped to
`logos-co/assembly` with **Discussions: read**. This lets anonymous visitors load
existing comments. Store it as `GITHUB_TOKEN`.
## 4. Set secrets and origins
```sh
npx wrangler secret put GITHUB_CLIENT_ID
npx wrangler secret put GITHUB_CLIENT_SECRET
npx wrangler secret put GITHUB_TOKEN
```
Secrets apply immediately. `ALLOWED_ORIGINS` lives in `wrangler.toml` `[vars]`
and **requires a redeploy** to take effect:
```sh
npx wrangler deploy
```
### Local dev
```sh
cp .dev.vars.example .dev.vars # fill in the three secrets (git-ignored)
npm run dev # serves on http://localhost:8787
```
`ALLOWED_ORIGINS` already includes `http://localhost:8080` (Quartz's dev server).
## 5. Point Quartz at the worker
In `quartz.layout.ts`:
```ts
Component.InlineComments({
provider: "github",
options: {
repo: "logos-co/assembly",
repoId: "R_kgDOQUhKqA",
category: "Announcements",
categoryId: "DIC_kwDOQUhKqM4Cxur2",
apiBase: "https://inline-comments.inline-assembly.workers.dev",
mapping: "url", // must match giscus's mapping to share a discussion
},
})
```
If `apiBase` is empty the component no-ops, so it's safe to land before the
worker exists.
## Notes / limits
- Comments + replies are fetched 100-at-a-time (no pagination yet).
- `mapping` must match on read and write. To share the _same_ discussion as the
existing giscus widget, use the mapping giscus is configured with.
- Ports to Vercel/Netlify functions are straightforward — the handler is a
single `fetch(request, env)`; only the deploy wrapper changes.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
{
"name": "inline-comments-worker",
"version": "0.1.0",
"private": true,
"description": "OAuth exchange + anonymous read proxy for Quartz inline comments",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241106.0",
"typescript": "^5.6.0",
"wrangler": "^3.86.0"
}
}
@@ -0,0 +1,381 @@
// Inline Comments worker — the only non-static piece of the system.
//
// Auth is a **GitHub App** (not an OAuth App): permissions come from the App
// definition, so users are asked for fine-grained `Discussions: write` on the
// one repo the App is installed on — never `public_repo` across every public
// repo they own. Consequently no `scope` is sent on the authorize URL.
//
// GitHub Apps issue user-to-server tokens that expire (8h by default) with a
// refresh token (~6 months), hence /api/auth/refresh. If the App has token
// expiration disabled, GitHub simply omits those fields and everything still
// works — the client treats a missing expiry as "never expires".
//
// Endpoints:
// GET /api/auth/login?state=&origin= → 302 to GitHub's authorize URL
// GET /api/auth/callback?code=&state= → exchange code → postMessage session to opener
// POST /api/auth/refresh → refresh_token → new session (CORS)
// GET /api/comments?repo=&category=&term=
// → anonymous read proxy (server token) so
// logged-out visitors can see highlights
//
// Secrets (wrangler secret put ..., or the Cloudflare dashboard as encrypted):
// GITHUB_CLIENT_ID GitHub App client id (Iv23li…)
// GITHUB_CLIENT_SECRET GitHub App client secret
// GITHUB_TOKEN server read token (fine-grained PAT: Discussions read)
// Vars (wrangler.toml [vars]):
// ALLOWED_ORIGINS comma-separated site origins, e.g. "https://logos-co.github.io"
export interface Env {
GITHUB_CLIENT_ID: string
GITHUB_CLIENT_SECRET: string
GITHUB_TOKEN: string
ALLOWED_ORIGINS: string
}
const GITHUB_GRAPHQL = "https://api.github.com/graphql"
const USER_AGENT = "quartz-inline-comments"
// ─── helpers ──────────────────────────────────────────────────────────────
function allowedOrigins(env: Env): string[] {
return (env.ALLOWED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
}
function isAllowed(origin: string, env: Env): boolean {
const list = allowedOrigins(env)
return list.includes("*") || list.includes(origin)
}
function corsHeaders(origin: string, env: Env): Record<string, string> {
const allow = isAllowed(origin, env) ? origin : (allowedOrigins(env)[0] ?? "")
return {
"Access-Control-Allow-Origin": allow,
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
Vary: "Origin",
}
}
function json(data: unknown, status: number, extra: Record<string, string> = {}): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", ...extra },
})
}
function b64urlEncode(s: string): string {
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function b64urlDecode(s: string): string {
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4))
return atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad)
}
async function githubGraphQL<T>(token: string, query: string, variables: object): Promise<T> {
const res = await fetch(GITHUB_GRAPHQL, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({ query, variables }),
})
const body = (await res.json()) as { data?: T; errors?: { message: string }[] }
if (body.errors?.length) throw new Error(body.errors[0].message)
if (!body.data) throw new Error("empty GraphQL response")
return body.data
}
// ─── auth: login ────────────────────────────────────────────────────────
function handleLogin(url: URL, env: Env): Response {
const clientState = url.searchParams.get("state") ?? ""
const origin = url.searchParams.get("origin") ?? ""
if (!isAllowed(origin, env)) return new Response("origin not allowed", { status: 403 })
const ghState = b64urlEncode(JSON.stringify({ cs: clientState, o: origin }))
const redirectUri = `${url.origin}/api/auth/callback`
const authorize = new URL("https://github.com/login/oauth/authorize")
authorize.searchParams.set("client_id", env.GITHUB_CLIENT_ID)
authorize.searchParams.set("redirect_uri", redirectUri)
// NOTE: deliberately no `scope` — a GitHub App's permissions are fixed by
// the App definition. Sending a scope here is what made the OAuth App ask
// for `public_repo` across all of the user's public repositories.
authorize.searchParams.set("state", ghState)
authorize.searchParams.set("allow_signup", "true")
return Response.redirect(authorize.toString(), 302)
}
// ─── auth: callback ───────────────────────────────────────────────────────
// GitHub's token response. `expires_in` / `refresh_token` are present only
// when the App has expiring user tokens enabled (the default for new Apps).
type TokenResponse = {
access_token?: string
expires_in?: number
refresh_token?: string
refresh_token_expires_in?: number
error?: string
error_description?: string
}
// Absolute epoch-ms expiry, or null when the token never expires.
function absoluteExpiry(seconds: number | undefined, now: number): number | null {
return typeof seconds === "number" ? now + seconds * 1000 : null
}
function sessionFrom(t: TokenResponse, now: number) {
return {
token: t.access_token,
expiresAt: absoluteExpiry(t.expires_in, now),
refreshToken: t.refresh_token ?? null,
refreshExpiresAt: absoluteExpiry(t.refresh_token_expires_in, now),
}
}
function callbackPage(
t: TokenResponse,
clientState: string,
origin: string,
now: number,
): Response {
// JSON-encode + neutralize "</script>" so nothing can break out of the tag.
const payload = JSON.stringify({
type: "inline-comments-token",
state: clientState,
...sessionFrom(t, now),
}).replace(/</g, "\\u003c")
const targetOrigin = JSON.stringify(origin).replace(/</g, "\\u003c")
const html = `<!doctype html><meta charset="utf-8"><title>Signing in…</title>
<body style="font-family:sans-serif;padding:2rem">Signing you in…
<script>
(function () {
var msg = ${payload};
if (window.opener) { window.opener.postMessage(msg, ${targetOrigin}); }
window.close();
})();
</script></body>`
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } })
}
async function handleCallback(url: URL, env: Env): Promise<Response> {
const code = url.searchParams.get("code")
const ghState = url.searchParams.get("state") ?? ""
let clientState = ""
let origin = ""
try {
const parsed = JSON.parse(b64urlDecode(ghState)) as { cs: string; o: string }
clientState = parsed.cs
origin = parsed.o
} catch {
return new Response("invalid state", { status: 400 })
}
if (!code || !isAllowed(origin, env)) return new Response("bad request", { status: 400 })
const res = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({
client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: `${url.origin}/api/auth/callback`,
}),
})
const tokenJson = (await res.json()) as TokenResponse
if (!tokenJson.access_token) {
const detail = tokenJson.error_description ?? tokenJson.error ?? "unknown"
return new Response(`oauth error: ${detail}`, { status: 400 })
}
return callbackPage(tokenJson, clientState, origin, Date.now())
}
// ─── auth: refresh ────────────────────────────────────────────────────────
// Exchanges a refresh token for a fresh user-to-server token. Needs the client
// secret, which is why it lives here rather than in the browser.
async function handleRefresh(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get("Origin") ?? ""
const cors = corsHeaders(origin, env)
if (!isAllowed(origin, env)) return json({ error: "origin not allowed" }, 403, cors)
let refreshToken = ""
try {
const body = (await request.json()) as { refresh_token?: string }
refreshToken = body.refresh_token ?? ""
} catch {
return json({ error: "invalid body" }, 400, cors)
}
if (!refreshToken) return json({ error: "missing refresh_token" }, 400, cors)
const res = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": USER_AGENT,
},
body: JSON.stringify({
client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
})
const tokenJson = (await res.json()) as TokenResponse
if (!tokenJson.access_token) {
// refresh token expired or revoked — the client should re-run sign-in
const detail = tokenJson.error_description ?? tokenJson.error ?? "unknown"
return json({ error: detail }, 401, cors)
}
return json(sessionFrom(tokenJson, Date.now()), 200, cors)
}
// ─── read proxy: comments ──────────────────────────────────────────────────
type GHComment = {
id: string
url: string
createdAt: string
bodyHTML: string
body: string
author: { login: string; avatarUrl: string } | null
replies?: { nodes: GHComment[] }
}
type GHDiscussion = {
id: string
number: number
title: string
category: { name: string }
comments: { totalCount: number; nodes: GHComment[] }
}
const SEARCH_DISCUSSION = `
query ($q: String!) {
search(query: $q, type: DISCUSSION, first: 10) {
nodes {
... on Discussion {
id
number
title
category { name }
comments(first: 100) {
totalCount
nodes {
id url createdAt bodyHTML body
author { login avatarUrl }
replies(first: 100) {
nodes { id url createdAt bodyHTML body author { login avatarUrl } }
}
}
}
}
}
}
}`
function mapComment(c: GHComment): unknown {
return {
id: c.id,
url: c.url,
createdAt: c.createdAt,
bodyHTML: c.bodyHTML,
body: c.body,
author: c.author ?? { login: "ghost", avatarUrl: "" },
replies: (c.replies?.nodes ?? []).map(mapComment),
}
}
async function handleComments(request: Request, url: URL, env: Env): Promise<Response> {
const origin = request.headers.get("Origin") ?? ""
const cors = corsHeaders(origin, env)
const repo = url.searchParams.get("repo") ?? ""
const category = url.searchParams.get("category") ?? ""
const term = url.searchParams.get("term") ?? ""
const [owner, name] = repo.split("/")
if (!owner || !name || !term) return json({ error: "missing repo/term" }, 400, cors)
// A logged-in reader may pass their own token; else use the server token.
const authHeader = request.headers.get("Authorization")
const token =
authHeader && authHeader.startsWith("Bearer ") ? authHeader.slice(7) : env.GITHUB_TOKEN
if (!token) return json({ error: "no token configured" }, 500, cors)
const q = `repo:${owner}/${name} in:title "${term.replace(/"/g, "")}"`
try {
const data = await githubGraphQL<{ search: { nodes: GHDiscussion[] } }>(
token,
SEARCH_DISCUSSION,
{
q,
},
)
const matches = data.search.nodes.filter(
(d) => d && d.title === term && (!category || d.category?.name === category),
)
if (matches.length === 0) {
return json({ discussionId: null, discussionNumber: null, comments: [] }, 200, cors)
}
// Two discussions can share a title — giscus races and creates a duplicate
// if a page is opened twice at once (see #11/#12 in this repo). Search
// order is not guaranteed, so picking the first match could silently land
// on the empty twin and show no comments at all. Prefer the one actually
// holding the conversation, tie-breaking on the lower number for stability.
const match = matches.reduce((best, d) => {
const better = d.comments.totalCount > best.comments.totalCount
const tie = d.comments.totalCount === best.comments.totalCount && d.number < best.number
return better || tie ? d : best
})
return json(
{
discussionId: match.id,
discussionNumber: match.number,
comments: match.comments.nodes.map(mapComment),
},
200,
cors,
)
} catch (err) {
return json({ error: err instanceof Error ? err.message : "read failed" }, 502, cors)
}
}
// ─── router ─────────────────────────────────────────────────────────────
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: corsHeaders(request.headers.get("Origin") ?? "", env),
})
}
switch (url.pathname) {
case "/api/auth/login":
return handleLogin(url, env)
case "/api/auth/callback":
return handleCallback(url, env)
case "/api/auth/refresh":
if (request.method !== "POST") return new Response("method not allowed", { status: 405 })
return handleRefresh(request, env)
case "/api/comments":
return handleComments(request, url, env)
default:
return new Response("not found", { status: 404 })
}
},
}
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["esnext"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noEmit": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,16 @@
name = "inline-comments"
main = "src/index.ts"
compatibility_date = "2024-11-01"
# Public site origin(s) allowed to use this worker (comma-separated).
# Used for CORS on /api/comments and as the postMessage target on auth callback.
# NOTE: origins only — scheme + host, no path. The site lives at
# https://logos-co.github.io/assembly/ but the origin is the bare host.
# Changing these requires a redeploy (`wrangler deploy`); secrets do not.
[vars]
ALLOWED_ORIGINS = "https://logos-co.github.io,http://localhost:8080"
# Secrets are NOT set here. Set them with:
# wrangler secret put GITHUB_CLIENT_ID
# wrangler secret put GITHUB_CLIENT_SECRET
# wrangler secret put GITHUB_TOKEN