mirror of
https://github.com/logos-co/assembly.git
synced 2026-08-27 11:11:08 +00:00
Merge branch 'v4' of https://github.com/logos-co/assembly into v4
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+1606
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
|
||||
Reference in New Issue
Block a user