g-comments 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to `g-comments` are listed here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - `theme="forum"` (provider, `<Comments>`, `<discourse-comments>`): applies the
14
+ forum's own palettes, fonts and "Embedded CSS" to the widget, from the
15
+ plugin's `GET /comments/theme`. Forum colours are exposed as
16
+ `--gc-forum-<name>`; tokens you set still win.
17
+ - `client.theme()` and `useForumTheme()` return the same data for sites that
18
+ style their own UI after the forum.
19
+
20
+ ### Changed
21
+
22
+ - The custom elements attach their default styles as `<style>` elements
23
+ instead of constructed stylesheets, so a stylesheet added to the shadow root
24
+ later (such as the forum's) wins ties.
25
+
26
+ ## [0.1.0] - Unreleased
27
+
28
+ First release, against the `discourse-comments` forum plugin.
29
+
30
+ ### Added
31
+
32
+ - `g-comments`: the framework-agnostic client. Threads (open, sort, paging,
33
+ per-branch replies, context), posting, editing, deleting, likes, pins,
34
+ flags, mentions, previews and uploads; popup sign-in with a redirect
35
+ fallback (PKCE); realtime through the forum's MessageBus with a polling
36
+ fallback; batched comment counts; `en` and `pt-BR` messages; a page-wide
37
+ client registry; typed `CommentsError`s with setup hints.
38
+ - `g-comments/react`: `<DiscourseComments>` provider, the drop-in `<Comments>`
39
+ built from `Comments.*` primitives, `<CommentCount>`, `useThread`,
40
+ `useCommenter` and `useCommentCount`; inline, on-submit and manual sign-in
41
+ modes; a diagnostics panel on development hosts.
42
+ - `g-comments/styles.css`: the default theme with `--gc-*` tokens, light, dark,
43
+ `auto` and `inherit` colour schemes.
44
+ - `g-comments/element`: `<discourse-comments>` and `<discourse-comment-count>`
45
+ custom elements for pages without React. Self-contained (React and the
46
+ stylesheet are bundled), rendered in a shadow root, attributes reflected as
47
+ properties, every `gc-*` class exposed through `::part()`, widget events
48
+ dispatched as `gc:*` custom events.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gabriel Grubba
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,390 @@
1
- # Temporary Holding Version
1
+ # g-comments
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Comments for any web page, powered by a Discourse forum running the
4
+ `discourse-comments` plugin. The package root is the framework-agnostic client;
5
+ the React components (`g-comments/react`), the custom elements
6
+ (`g-comments/element`) and the default stylesheet (`g-comments/styles.css`)
7
+ build on it.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pnpm add g-comments
13
+ ```
14
+
15
+ On the forum: install the plugin, then add your site's host under
16
+ **Admin → Customize → Embedding** (or to `discourse_comments_allowed_origins`).
17
+
18
+ ## React
19
+
20
+ React 18 or 19 is a peer dependency of `g-comments/react` only.
21
+
22
+ ### Option A: provider and drop-in components
23
+
24
+ ```tsx
25
+ import { CommentCount, Comments, DiscourseComments } from "g-comments/react";
26
+ import "g-comments/styles.css";
27
+
28
+ // Once, near the root of the app.
29
+ <DiscourseComments
30
+ forum="https://forum.example.com"
31
+ theme="auto" // "light" | "dark" | "auto" (prefers-color-scheme) | "inherit" | "forum"
32
+ locale="en" // "pt-BR" is bundled too; `messages` overrides single strings
33
+ appearance={{ variables: { accent: "var(--brand)", radius: "0" } }}
34
+ onEvent={(event) => analytics.track(event.type, event)}
35
+ >
36
+ <App />
37
+ </DiscourseComments>
38
+
39
+ // Per page. Without props the page URL (canonical link, else the address) and
40
+ // document.title identify the thread.
41
+ <Comments />
42
+ <Comments identifier="blog-1433307" url={canonicalUrl} title={post.title} sort="newest" />
43
+
44
+ <CommentCount url={postUrl} /> // "12 comments", fetched once, batched with the others
45
+ <CommentCount identifier="blog-1433307" render={(n) => n} />
46
+ ```
47
+
48
+ `<Comments>` props: `identifier`, `url`, `title`, `sort`, `maxDepth` (visual
49
+ nesting, default 2; deeper replies collapse into "Continue this thread"),
50
+ `signIn` (`"inline"` panel with the forum's providers, `"on-submit"` signs in
51
+ when an anonymous reader posts and keeps the draft, `"manual"` leaves it to your
52
+ UI), `onSignInRequired` (manual mode: called when an anonymous reader tries to
53
+ like or reply; without it those actions are unavailable, and manual mode never
54
+ opens the forum's popup itself), `readOnly`, `highlight` (comment id; defaults
55
+ to the `#gc-<id>` fragment), `guidelinesUrl`, `onPosted(result)`, `appearance`,
56
+ `unstyled`, `theme`, `locale`, `className`.
57
+
58
+ Open reply, edit, report and delete panels close as soon as the reader loses
59
+ the right to use them (read-only, forum degraded, signed out), and a post
60
+ waiting for an on-submit sign-in is dropped if the reader cancels it or the
61
+ widget goes away first.
62
+
63
+ The provider also accepts the client options (`fetch`, `headers` such as
64
+ `Authorization` for a forum behind basic auth, `storage`, `clientId`) or a
65
+ ready-made `client`, plus `messages` and `debug`. On localhost (or with
66
+ `debug`) a failing widget shows a diagnostics panel naming the probable setup
67
+ problem; production readers only see "Comments are unavailable right now."
68
+
69
+ `onEvent` receives `comment.posted`, `comment.pending` (sent to the review
70
+ queue), `comment.liked`, `auth.signedIn`, `auth.signedOut` (`expired` when the
71
+ forum ended the session) and `error` (with the failing `action`).
72
+
73
+ ### Option B: no provider
74
+
75
+ ```tsx
76
+ <Comments forum="https://forum.example.com" identifier="blog-1433307" />
77
+ <CommentCount forum="https://forum.example.com" identifier="blog-1433307" />
78
+ ```
79
+
80
+ Clients come from a page-wide registry keyed by the normalized forum URL, so
81
+ every component (and a provider) naming the same forum shares one client: one
82
+ sign-in state, one config fetch, one realtime connection. The first options
83
+ used for a forum win. `clientRegistry` is exported from `g-comments` for
84
+ non-React code.
85
+
86
+ ### Option C: hooks and primitives
87
+
88
+ ```tsx
89
+ import { Comments, useCommenter, useCommentCount, useThread } from "g-comments/react";
90
+
91
+ const { thread, snapshot, error, retry } = useThread({ identifier, sort: "newest" });
92
+ // snapshot: comments (tree), status, hasMore, pendingNew, branches, awaitingApproval, …
93
+ // thread: post, edit, remove, like, flag, pin, loadMore, loadReplies, showPending, context, …
94
+
95
+ const me = useCommenter();
96
+ // me.user, me.status, me.providers, me.degraded, me.signIn({ provider }), me.cancelSignIn(), me.signOut()
97
+
98
+ const { count } = useCommentCount({ url: postUrl });
99
+ ```
100
+
101
+ The drop-in `<Comments>` is assembled from primitives you can rearrange or
102
+ replace:
103
+
104
+ ```tsx
105
+ <Comments.Root identifier="blog-1433307" signIn="manual">
106
+ <Comments.Header>
107
+ <Comments.Count as="h3" />
108
+ <Comments.Sort />
109
+ </Comments.Header>
110
+ <Comments.SignIn>{(me) => <MyButton onClick={() => me.signIn()}>Join</MyButton>}</Comments.SignIn>
111
+ <Comments.Composer placeholder="Say something…" />
112
+ <Comments.List>
113
+ {(comment, depth) => (
114
+ <Comments.Item comment={comment} depth={depth}>
115
+ <Comments.Avatar />
116
+ <Comments.Body />
117
+ <Comments.Actions />
118
+ </Comments.Item>
119
+ )}
120
+ </Comments.List>
121
+ <Comments.LoadMore />
122
+ </Comments.Root>
123
+ ```
124
+
125
+ Also available: `Comments.Meta` (author, badges, permalink time),
126
+ `Comments.NewComments` (the live "N new comments" pill),
127
+ `Comments.AwaitingApproval`, `Comments.Notice` (closed, read-only) and
128
+ `Comments.DevPanel`. The `List` render function is reused for replies at every
129
+ depth, and `Item` keeps the inline reply, edit, report and delete panels.
130
+
131
+ ## Styling
132
+
133
+ `g-comments/styles.css` styles everything under the widget root
134
+ (`.gc-root.gc-root--styled`); nothing outside it is touched. Class names follow
135
+ BEM (`gc-comment`, `gc-comment__body`, `gc-composer--replying`,
136
+ `gc-button--primary`, …) and stay in place with `unstyled`, which only drops
137
+ `gc-root--styled`. Selectors carry one class of specificity, so your own class
138
+ rules win when loaded after the stylesheet. Cooked comment HTML is wrapped in
139
+ `.gc-prose`.
140
+
141
+ `appearance.elements` adds class names: `root`, `header`, `count`, `sort`,
142
+ `signIn`, `composer`, `textarea`, `toolbar`, `list`, `comment`, `avatar`,
143
+ `meta`, `body`, `actions`, `menu`, `button`, `primaryButton`, `loadMore`,
144
+ `pill`, `notice`, `dialog`.
145
+
146
+ Tokens can be set through `appearance.variables` (camelCase keys), on the
147
+ widget root, or on any ancestor such as `:root`; values you set apply in every
148
+ colour scheme.
149
+
150
+ | Token | `variables` key | Light default | Dark default |
151
+ | --- | --- | --- | --- |
152
+ | `--gc-font` | `font` | system UI stack | |
153
+ | `--gc-fg` | `fg` | `#1f2328` | `#e6edf3` |
154
+ | `--gc-fg-muted` | `fgMuted` | `#59636e` | `#9198a1` |
155
+ | `--gc-bg` | `bg` | `#ffffff` | `#0d1117` |
156
+ | `--gc-bg-subtle` | `bgSubtle` | `#f6f8fa` | `#151b23` |
157
+ | `--gc-border` | `border` | `#d1d9e0` | `#3d444d` |
158
+ | `--gc-accent` | `accent` | `#0969da` | `#4493f8` |
159
+ | `--gc-accent-fg` | `accentFg` | `#ffffff` | `#0d1117` |
160
+ | `--gc-danger` | `danger` | `#cf222e` | `#f85149` |
161
+ | `--gc-radius` | `radius` | `8px` | |
162
+ | `--gc-shadow` | `shadow` | soft drop shadow | darker drop shadow |
163
+ | `--gc-space` | `space` | `0.5rem` (spacing unit) | |
164
+ | `--gc-avatar-size` | `avatarSize` | `2.25rem` (`2rem` in narrow containers) | |
165
+
166
+ `theme` sets `data-gc-theme` on the root: `light`, `dark`, or `auto`, which
167
+ switches with `prefers-color-scheme`. `inherit` sets nothing and derives the
168
+ neutral colours from the page's text colour, for sites that theme the widget
169
+ themselves. `forum` applies the forum's own look (below). The layout works
170
+ down to 320px wide.
171
+
172
+ ### Use the forum's look
173
+
174
+ ```tsx
175
+ <DiscourseComments forum="https://forum.example.com" theme="forum">
176
+ ```
177
+
178
+ `theme="forum"` loads the forum's theme once per forum (`GET /comments/theme`)
179
+ and installs it where the widget lives: the page's `<head>`, or the custom
180
+ element's shadow root. Three things arrive:
181
+
182
+ - **Colours.** Every custom property the forum's colour definitions emit is
183
+ available on the widget root as `--gc-forum-<name>` (`--gc-forum-primary`,
184
+ `--gc-forum-tertiary`, `--gc-forum-primary-low`, …), and the widget tokens
185
+ default to them: `fg` ← `primary`, `fgMuted` ← `primary-medium`, `bg` ←
186
+ `secondary`, `bgSubtle` ← `primary-very-low`, `border` ← `primary-low`,
187
+ `accent` ← `tertiary`, `accentFg` ← `secondary`, `danger` ← `danger`. When
188
+ the theme has a dark palette it applies under `prefers-color-scheme: dark`.
189
+ - **Fonts.** `--gc-font` defaults to the forum's body font;
190
+ `--gc-forum-font-body`, `--gc-forum-font-heading` and
191
+ `--gc-forum-font-monospace` carry the stacks, and `@font-face` rules are
192
+ added to the page for fonts the forum serves.
193
+ - **Theme CSS.** The default theme's "Embedded CSS" (Admin → Customize →
194
+ Themes → Edit CSS/HTML → Embedded) is loaded as a stylesheet, so forum
195
+ admins can style the `gc-*` classes without touching the site.
196
+
197
+ Precedence: tokens you set (`appearance.variables`, or `--gc-*` on the root
198
+ or an ancestor) win over the forum's, which win over the defaults. The forum's
199
+ stylesheet is appended after the page's, so plain CSS rules tie-break in the
200
+ forum's favour; out-specify them (`.blog .gc-comment`) where the site must
201
+ win. `useForumTheme()` returns the same data for sites that style their own
202
+ UI after the forum.
203
+
204
+ ## Custom elements (no framework)
205
+
206
+ `g-comments/element` registers `<discourse-comments>` and
207
+ `<discourse-comment-count>` when imported. The module is self-contained: React,
208
+ ReactDOM, the client and the default stylesheet are bundled (about 110 kB
209
+ gzipped), so one script tag works on any page and nothing else needs
210
+ installing. On a site that already renders with React, use `g-comments/react`
211
+ instead; the element keeps its own React and cannot share the page's.
212
+
213
+ ```html
214
+ <script type="module" src="https://cdn.jsdelivr.net/npm/g-comments/dist/element.js"></script>
215
+ <!-- or, with a bundler: import "g-comments/element"; -->
216
+
217
+ <discourse-comments forum="https://forum.example.com" identifier="blog-1433307"></discourse-comments>
218
+ <discourse-comment-count forum="https://forum.example.com" identifier="blog-1433307"></discourse-comment-count>
219
+ ```
220
+
221
+ Attributes mirror the React props, in kebab-case:
222
+
223
+ | `<discourse-comments>` | Prop | Values |
224
+ | --- | --- | --- |
225
+ | `forum` | `forum` | forum URL (required, or set `element.client`) |
226
+ | `identifier`, `url`, `title` | same | thread identity; `url` and `title` default to the page |
227
+ | `sort` | `sort` | `best`, `hot`, `newest`, `oldest` |
228
+ | `sign-in` | `signIn` | `inline` (default), `on-submit`, `manual` |
229
+ | `theme` | `theme` | `auto` (default), `light`, `dark`, `inherit`, `forum` |
230
+ | `locale` | `locale` | `en`, `pt-BR` |
231
+ | `max-depth` | `maxDepth` | number, default 2 |
232
+ | `read-only`, `unstyled`, `debug` | same | boolean: present means true (`"false"` is false) |
233
+ | `highlight` | `highlight` | comment id |
234
+ | `guidelines-url` | `guidelinesUrl` | URL |
235
+
236
+ `<discourse-comment-count forum identifier host url topic-id format>` takes
237
+ the `<CommentCount>` props the same way; `format="number"` renders just the
238
+ number instead of "12 comments". Counts on one page share one request.
239
+
240
+ Every attribute is also a property with the prop's name
241
+ (`element.theme = "dark"` sets `theme="dark"`; `element.readOnly = false`
242
+ removes `read-only`), so frameworks that bind properties work unchanged. Values
243
+ that are not valid for an attribute are ignored and the default applies. A
244
+ `client` property (not an attribute) takes a client from `createClient`, for
245
+ options such as `headers`; otherwise the element uses the page-wide shared
246
+ client for its `forum`, available as `element.client`.
247
+
248
+ The widget's events are dispatched from the element as bubbling, composed
249
+ `CustomEvent`s named `gc:` plus the event type, with the event as `detail`:
250
+ `gc:comment.posted`, `gc:comment.pending`, `gc:comment.liked`,
251
+ `gc:auth.signedIn`, `gc:auth.signedOut` and `gc:error`. With
252
+ `sign-in="manual"`, `gc:sign-in-required` fires when an anonymous reader asks
253
+ to like or reply; start your own sign-in there (for instance
254
+ `element.client.auth.signIn()`).
255
+
256
+ ```js
257
+ document.querySelector("discourse-comments").addEventListener("gc:comment.posted", (event) => {
258
+ analytics.track("comment", { id: event.detail.comment.id });
259
+ });
260
+ ```
261
+
262
+ The widget renders in an open shadow root with `g-comments/styles.css`
263
+ applied, so the page's CSS does not reach it and its CSS does not leak out.
264
+ Style it through the `--gc-*` tokens, which cross the shadow boundary from the
265
+ element or any ancestor, and through `::part()`: every `gc-*` class inside the
266
+ widget is exposed as a part of the same name, modifiers included.
267
+
268
+ ```css
269
+ discourse-comments {
270
+ --gc-accent: var(--brand);
271
+ }
272
+ discourse-comments::part(gc-button--primary) {
273
+ border-radius: 999px;
274
+ }
275
+ discourse-comments::part(gc-comment--pinned) {
276
+ outline: 1px solid var(--brand);
277
+ }
278
+ ```
279
+
280
+ `unstyled` drops the default stylesheet's effect (the classes and parts stay),
281
+ `theme="inherit"` derives the neutral colours from the page's text colour, and
282
+ `theme="forum"` loads the forum's palettes, fonts and theme CSS into the shadow
283
+ root (see "Use the forum's look"). The count element has no styles of its own
284
+ and inherits the page's font.
285
+
286
+ `defineElements({ comments: "my-comments", count: "my-count" })` registers the
287
+ same elements under other names; names that are already defined are left
288
+ alone, so loading the module twice is harmless. Importing the module on a
289
+ server is a no-op, and the elements render nothing until they are connected
290
+ in a browser. The classes (`DiscourseCommentsElement`,
291
+ `DiscourseCommentCountElement`) and the `gc:*` event map are exported and
292
+ typed, including `HTMLElementTagNameMap` entries for `querySelector`.
293
+
294
+ ## Example apps
295
+
296
+ Neither is published. `examples/vite-react` renders the React components
297
+ against a forum, from the SDK source:
298
+
299
+ ```sh
300
+ VITE_FORUM=http://localhost:3000 pnpm --filter example dev # http://localhost:5174
301
+ ```
302
+
303
+ `examples/plain-html` is a page with no framework that loads the built custom
304
+ element (`pnpm build` first); `?forum=` points it at another forum:
305
+
306
+ ```sh
307
+ pnpm --filter example-plain-html dev # http://localhost:5175
308
+ ```
309
+
310
+ Allow `localhost:5174` and `localhost:5175` as embedding hosts on that forum
311
+ first (`bin/rake "discourse_comments:seed_dev[localhost:5175]"` from a
312
+ Discourse checkout with the plugin does it for one host).
313
+
314
+ ## Publishing
315
+
316
+ `pnpm build`, `pnpm test`, `pnpm typecheck` and `pnpm --filter g-comments
317
+ lint:package` (publint) should all pass; `pnpm --filter g-comments pack`
318
+ shows what ships: `dist/`, `styles/`, this file, the changelog and the
319
+ license.
320
+
321
+ ## Create a client
322
+
323
+ Without React, the same client powers everything below.
324
+
325
+ ```ts
326
+ import { createClient } from "g-comments";
327
+
328
+ const comments = createClient({ forum: "https://forum.example.com" });
329
+ // or share one per forum across the page:
330
+ // const comments = clientRegistry.acquire({ forum: "https://forum.example.com" });
331
+ ```
332
+
333
+ Options: `fetch`, `headers` (e.g. `Authorization` for a forum behind basic
334
+ auth), `storage` (defaults to `localStorage`), `clientId`, `locale`, `debug`.
335
+
336
+ ## Open the page's thread
337
+
338
+ ```ts
339
+ // url defaults to <link rel="canonical"> or location.href, title to document.title
340
+ const thread = await comments.threads.open({ identifier: "blog-1433307" });
341
+
342
+ thread.subscribe(() => render(thread.snapshot()));
343
+ // snapshot(): { thread, comments (tree), sort, status, hasMore, pendingNew, error, … }
344
+
345
+ await thread.setSort("newest");
346
+ await thread.loadMore();
347
+ await thread.loadReplies(comment.id);
348
+ ```
349
+
350
+ ## Sign in and comment
351
+
352
+ ```ts
353
+ await comments.auth.signIn(); // popup; falls back to a redirect when blocked
354
+ comments.auth.onChange(({ status, user }) => updateHeader(status, user));
355
+
356
+ thread.composer.start(); // when the composer opens
357
+ thread.composer.typing(); // on input; lets first posts skip the review queue
358
+ const result = await thread.post({ body: "Nice post!", replyTo: comment.id });
359
+ if (result.type === "pending") showAwaitingApproval(result.pending);
360
+
361
+ await thread.like(comment.id);
362
+ await comments.auth.signOut();
363
+ ```
364
+
365
+ Redirect sign-ins finish on page load (`#gc_code=…`), as long as the page
366
+ creates its client on load.
367
+
368
+ ## Realtime
369
+
370
+ Threads follow the forum's MessageBus anonymously; new top-level comments from
371
+ others wait in `snapshot().pendingNew` until `thread.showPending()`, while
372
+ replies, edits, likes and deletions apply in place. When live updates are not
373
+ available the thread refreshes every 30 s (backing off to 2 min, paused in
374
+ background tabs); `snapshot().connection` says which mode is active.
375
+
376
+ ## Counts
377
+
378
+ ```ts
379
+ const counts = await comments.counts([{ url: "https://example.com/blog/1" }, { identifier: "blog-2" }]);
380
+ // { "url:https://example.com/blog/1": 3, "id:example.com/blog-2": 0 }
381
+ ```
382
+
383
+ Lookups made in the same task share one request.
384
+
385
+ ## Errors
386
+
387
+ Every failure is a `CommentsError` with a `type` (`rate_limit` carries
388
+ `waitSeconds`) and `status`. `unreachable` means no response at all; its `hint`
389
+ lists the usual setup causes (origin not allowed, basic auth blocking CORS
390
+ preflights, plugin disabled).