g-comments 0.0.0-stage → 0.1.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,31 @@
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.1.0] - Unreleased
10
+
11
+ First release, against the `discourse-comments` forum plugin.
12
+
13
+ ### Added
14
+
15
+ - `g-comments`: the framework-agnostic client. Threads (open, sort, paging,
16
+ per-branch replies, context), posting, editing, deleting, likes, pins,
17
+ flags, mentions, previews and uploads; popup sign-in with a redirect
18
+ fallback (PKCE); realtime through the forum's MessageBus with a polling
19
+ fallback; batched comment counts; `en` and `pt-BR` messages; a page-wide
20
+ client registry; typed `CommentsError`s with setup hints.
21
+ - `g-comments/react`: `<DiscourseComments>` provider, the drop-in `<Comments>`
22
+ built from `Comments.*` primitives, `<CommentCount>`, `useThread`,
23
+ `useCommenter` and `useCommentCount`; inline, on-submit and manual sign-in
24
+ modes; a diagnostics panel on development hosts.
25
+ - `g-comments/styles.css`: the default theme with `--gc-*` tokens, light, dark,
26
+ `auto` and `inherit` colour schemes.
27
+ - `g-comments/element`: `<discourse-comments>` and `<discourse-comment-count>`
28
+ custom elements for pages without React. Self-contained (React and the
29
+ stylesheet are bundled), rendered in a shadow root, attributes reflected as
30
+ properties, every `gc-*` class exposed through `::part()`, widget events
31
+ 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,355 @@
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"
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. The layout works down to 320px wide.
170
+
171
+ ## Custom elements (no framework)
172
+
173
+ `g-comments/element` registers `<discourse-comments>` and
174
+ `<discourse-comment-count>` when imported. The module is self-contained: React,
175
+ ReactDOM, the client and the default stylesheet are bundled (about 110 kB
176
+ gzipped), so one script tag works on any page and nothing else needs
177
+ installing. On a site that already renders with React, use `g-comments/react`
178
+ instead; the element keeps its own React and cannot share the page's.
179
+
180
+ ```html
181
+ <script type="module" src="https://cdn.jsdelivr.net/npm/g-comments/dist/element.js"></script>
182
+ <!-- or, with a bundler: import "g-comments/element"; -->
183
+
184
+ <discourse-comments forum="https://forum.example.com" identifier="blog-1433307"></discourse-comments>
185
+ <discourse-comment-count forum="https://forum.example.com" identifier="blog-1433307"></discourse-comment-count>
186
+ ```
187
+
188
+ Attributes mirror the React props, in kebab-case:
189
+
190
+ | `<discourse-comments>` | Prop | Values |
191
+ | --- | --- | --- |
192
+ | `forum` | `forum` | forum URL (required, or set `element.client`) |
193
+ | `identifier`, `url`, `title` | same | thread identity; `url` and `title` default to the page |
194
+ | `sort` | `sort` | `best`, `hot`, `newest`, `oldest` |
195
+ | `sign-in` | `signIn` | `inline` (default), `on-submit`, `manual` |
196
+ | `theme` | `theme` | `auto` (default), `light`, `dark`, `inherit` |
197
+ | `locale` | `locale` | `en`, `pt-BR` |
198
+ | `max-depth` | `maxDepth` | number, default 2 |
199
+ | `read-only`, `unstyled`, `debug` | same | boolean: present means true (`"false"` is false) |
200
+ | `highlight` | `highlight` | comment id |
201
+ | `guidelines-url` | `guidelinesUrl` | URL |
202
+
203
+ `<discourse-comment-count forum identifier host url topic-id format>` takes
204
+ the `<CommentCount>` props the same way; `format="number"` renders just the
205
+ number instead of "12 comments". Counts on one page share one request.
206
+
207
+ Every attribute is also a property with the prop's name
208
+ (`element.theme = "dark"` sets `theme="dark"`; `element.readOnly = false`
209
+ removes `read-only`), so frameworks that bind properties work unchanged. Values
210
+ that are not valid for an attribute are ignored and the default applies. A
211
+ `client` property (not an attribute) takes a client from `createClient`, for
212
+ options such as `headers`; otherwise the element uses the page-wide shared
213
+ client for its `forum`, available as `element.client`.
214
+
215
+ The widget's events are dispatched from the element as bubbling, composed
216
+ `CustomEvent`s named `gc:` plus the event type, with the event as `detail`:
217
+ `gc:comment.posted`, `gc:comment.pending`, `gc:comment.liked`,
218
+ `gc:auth.signedIn`, `gc:auth.signedOut` and `gc:error`. With
219
+ `sign-in="manual"`, `gc:sign-in-required` fires when an anonymous reader asks
220
+ to like or reply; start your own sign-in there (for instance
221
+ `element.client.auth.signIn()`).
222
+
223
+ ```js
224
+ document.querySelector("discourse-comments").addEventListener("gc:comment.posted", (event) => {
225
+ analytics.track("comment", { id: event.detail.comment.id });
226
+ });
227
+ ```
228
+
229
+ The widget renders in an open shadow root with `g-comments/styles.css`
230
+ applied, so the page's CSS does not reach it and its CSS does not leak out.
231
+ Style it through the `--gc-*` tokens, which cross the shadow boundary from the
232
+ element or any ancestor, and through `::part()`: every `gc-*` class inside the
233
+ widget is exposed as a part of the same name, modifiers included.
234
+
235
+ ```css
236
+ discourse-comments {
237
+ --gc-accent: var(--brand);
238
+ }
239
+ discourse-comments::part(gc-button--primary) {
240
+ border-radius: 999px;
241
+ }
242
+ discourse-comments::part(gc-comment--pinned) {
243
+ outline: 1px solid var(--brand);
244
+ }
245
+ ```
246
+
247
+ `unstyled` drops the default stylesheet's effect (the classes and parts stay),
248
+ and `theme="inherit"` derives the neutral colours from the page's text colour.
249
+ The count element has no styles of its own and inherits the page's font.
250
+
251
+ `defineElements({ comments: "my-comments", count: "my-count" })` registers the
252
+ same elements under other names; names that are already defined are left
253
+ alone, so loading the module twice is harmless. Importing the module on a
254
+ server is a no-op, and the elements render nothing until they are connected
255
+ in a browser. The classes (`DiscourseCommentsElement`,
256
+ `DiscourseCommentCountElement`) and the `gc:*` event map are exported and
257
+ typed, including `HTMLElementTagNameMap` entries for `querySelector`.
258
+
259
+ ## Example apps
260
+
261
+ Neither is published. `examples/vite-react` renders the React components
262
+ against a forum, from the SDK source:
263
+
264
+ ```sh
265
+ VITE_FORUM=http://localhost:3000 pnpm --filter example dev # http://localhost:5174
266
+ ```
267
+
268
+ `examples/plain-html` is a page with no framework that loads the built custom
269
+ element (`pnpm build` first); `?forum=` points it at another forum:
270
+
271
+ ```sh
272
+ pnpm --filter example-plain-html dev # http://localhost:5175
273
+ ```
274
+
275
+ Allow `localhost:5174` and `localhost:5175` as embedding hosts on that forum
276
+ first (`bin/rake "discourse_comments:seed_dev[localhost:5175]"` from a
277
+ Discourse checkout with the plugin does it for one host).
278
+
279
+ ## Publishing
280
+
281
+ `pnpm build`, `pnpm test`, `pnpm typecheck` and `pnpm --filter g-comments
282
+ lint:package` (publint) should all pass; `pnpm --filter g-comments pack`
283
+ shows what ships: `dist/`, `styles/`, this file, the changelog and the
284
+ license.
285
+
286
+ ## Create a client
287
+
288
+ Without React, the same client powers everything below.
289
+
290
+ ```ts
291
+ import { createClient } from "g-comments";
292
+
293
+ const comments = createClient({ forum: "https://forum.example.com" });
294
+ // or share one per forum across the page:
295
+ // const comments = clientRegistry.acquire({ forum: "https://forum.example.com" });
296
+ ```
297
+
298
+ Options: `fetch`, `headers` (e.g. `Authorization` for a forum behind basic
299
+ auth), `storage` (defaults to `localStorage`), `clientId`, `locale`, `debug`.
300
+
301
+ ## Open the page's thread
302
+
303
+ ```ts
304
+ // url defaults to <link rel="canonical"> or location.href, title to document.title
305
+ const thread = await comments.threads.open({ identifier: "blog-1433307" });
306
+
307
+ thread.subscribe(() => render(thread.snapshot()));
308
+ // snapshot(): { thread, comments (tree), sort, status, hasMore, pendingNew, error, … }
309
+
310
+ await thread.setSort("newest");
311
+ await thread.loadMore();
312
+ await thread.loadReplies(comment.id);
313
+ ```
314
+
315
+ ## Sign in and comment
316
+
317
+ ```ts
318
+ await comments.auth.signIn(); // popup; falls back to a redirect when blocked
319
+ comments.auth.onChange(({ status, user }) => updateHeader(status, user));
320
+
321
+ thread.composer.start(); // when the composer opens
322
+ thread.composer.typing(); // on input; lets first posts skip the review queue
323
+ const result = await thread.post({ body: "Nice post!", replyTo: comment.id });
324
+ if (result.type === "pending") showAwaitingApproval(result.pending);
325
+
326
+ await thread.like(comment.id);
327
+ await comments.auth.signOut();
328
+ ```
329
+
330
+ Redirect sign-ins finish on page load (`#gc_code=…`), as long as the page
331
+ creates its client on load.
332
+
333
+ ## Realtime
334
+
335
+ Threads follow the forum's MessageBus anonymously; new top-level comments from
336
+ others wait in `snapshot().pendingNew` until `thread.showPending()`, while
337
+ replies, edits, likes and deletions apply in place. When live updates are not
338
+ available the thread refreshes every 30 s (backing off to 2 min, paused in
339
+ background tabs); `snapshot().connection` says which mode is active.
340
+
341
+ ## Counts
342
+
343
+ ```ts
344
+ const counts = await comments.counts([{ url: "https://example.com/blog/1" }, { identifier: "blog-2" }]);
345
+ // { "url:https://example.com/blog/1": 3, "id:example.com/blog-2": 0 }
346
+ ```
347
+
348
+ Lookups made in the same task share one request.
349
+
350
+ ## Errors
351
+
352
+ Every failure is a `CommentsError` with a `type` (`rate_limit` carries
353
+ `waitSeconds`) and `status`. `unreachable` means no response at all; its `hint`
354
+ lists the usual setup causes (origin not allowed, basic auth blocking CORS
355
+ preflights, plugin disabled).