@blogmcp/community 0.1.0 → 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.
Files changed (68) hide show
  1. package/README.md +108 -11
  2. package/dist/blogmcp-community.global.js +8 -8
  3. package/dist/chunks/{mount-DzjwnHyx.js → mount-WadUnaTa.js} +3909 -2070
  4. package/dist/core/attrs.d.ts +27 -1
  5. package/dist/core/badge.d.ts +30 -0
  6. package/dist/core/centre.d.ts +28 -0
  7. package/dist/core/digest-token.d.ts +2 -0
  8. package/dist/core/discussion.d.ts +36 -5
  9. package/dist/core/format.d.ts +7 -0
  10. package/dist/core/onboarding.d.ts +42 -2
  11. package/dist/core/outcomes.d.ts +2 -1
  12. package/dist/core/panel.d.ts +17 -1
  13. package/dist/core/profile.d.ts +40 -0
  14. package/dist/core/push.d.ts +63 -0
  15. package/dist/core/realtime.d.ts +35 -0
  16. package/dist/core/settings.d.ts +53 -0
  17. package/dist/core/text.d.ts +13 -0
  18. package/dist/element.d.ts +22 -1
  19. package/dist/global.d.ts +7 -4
  20. package/dist/index.d.ts +10 -2
  21. package/dist/index.js +34 -11
  22. package/dist/mount.d.ts +40 -0
  23. package/dist/react.d.ts +14 -3
  24. package/dist/react.js +24 -4
  25. package/dist/ui/BadgeView.vue.d.ts +13 -0
  26. package/dist/ui/DiscussionView.vue.d.ts +11 -0
  27. package/dist/ui/LinkedText.vue.d.ts +1 -0
  28. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  29. package/dist/ui/MessageComposer.vue.d.ts +2 -0
  30. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  31. package/dist/ui/OnboardingSteps.vue.d.ts +1 -1
  32. package/dist/ui/ProfileView.vue.d.ts +13 -0
  33. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  34. package/dist/vue.d.ts +114 -3
  35. package/dist/vue.js +109 -5
  36. package/package.json +3 -3
  37. package/src/core/attrs.ts +41 -2
  38. package/src/core/badge.ts +120 -0
  39. package/src/core/centre.ts +136 -0
  40. package/src/core/digest-token.ts +26 -0
  41. package/src/core/discussion.ts +203 -18
  42. package/src/core/format.ts +22 -0
  43. package/src/core/onboarding.ts +165 -18
  44. package/src/core/outcomes.ts +13 -1
  45. package/src/core/panel.ts +39 -4
  46. package/src/core/profile.ts +144 -0
  47. package/src/core/push.ts +174 -0
  48. package/src/core/realtime.ts +39 -0
  49. package/src/core/settings.ts +363 -0
  50. package/src/core/text.ts +32 -0
  51. package/src/element.ts +62 -6
  52. package/src/global.ts +18 -4
  53. package/src/index.ts +36 -1
  54. package/src/mount.ts +108 -1
  55. package/src/react.ts +47 -3
  56. package/src/ui/BadgeView.vue +51 -0
  57. package/src/ui/DiscussionView.vue +43 -3
  58. package/src/ui/LinkedText.vue +11 -5
  59. package/src/ui/MemberSettingsView.vue +175 -0
  60. package/src/ui/MessageComposer.vue +15 -4
  61. package/src/ui/MessageItem.vue +24 -6
  62. package/src/ui/MyCommunityView.vue +9 -0
  63. package/src/ui/NotificationCentreView.vue +159 -0
  64. package/src/ui/OnboardingSteps.vue +17 -6
  65. package/src/ui/ProfileView.vue +91 -0
  66. package/src/ui/WelcomeView.vue +72 -0
  67. package/src/ui/styles.css +16 -0
  68. package/src/vue.ts +129 -3
package/src/element.ts CHANGED
@@ -1,19 +1,30 @@
1
1
  /// <reference path="./shims.d.ts" />
2
2
  /**
3
- * The three custom elements (Vue inside a shadow root each), m1-contracts.md §10.4:
3
+ * The custom elements (Vue inside a shadow root each), m1-contracts.md §10.4 and m2-contracts.md §3.10:
4
4
  *
5
- * <blogmcp-discussion> article-id, api-base, site-base, lang, sign-in-href, routes, poll-ms, nonce
6
- * <blogmcp-notice-form> api-base, site-base, lang, turnstile-site-key, target | article-id, routes, nonce
7
- * <blogmcp-my-community> api-base, site-base, lang, decision-id, routes, sign-in-href, nonce
5
+ * <blogmcp-discussion> article-id, api-base, site-base, lang, sign-in-href, routes, poll-ms, nonce
6
+ * (+ the `realtime` property: a session factory the host passes, M2)
7
+ * <blogmcp-notice-form> api-base, site-base, lang, turnstile-site-key, target | article-id, routes, nonce
8
+ * <blogmcp-my-community> api-base, site-base, lang, decision-id, routes, sign-in-href, nonce
9
+ * <blogmcp-notification-centre> api-base, site-base, lang, routes, sign-in-href, nonce (M2)
10
+ * <blogmcp-badge> api-base, site-base, lang, routes, poll-ms, nonce (M2)
11
+ * <blogmcp-member-settings> api-base, site-base, lang, sw-path, routes, sign-in-href, nonce (M2)
12
+ * <blogmcp-profile> handle, api-base, site-base, lang, routes, sign-in-href, nonce (M2)
13
+ * <blogmcp-welcome> api-base, site-base, lang, routes, sign-in-href, nonce (M2)
8
14
  *
9
15
  * Each carries ONE inline stylesheet in its shadow root, with the CSP nonce honoured: the one given when the
10
16
  * elements are defined (the script build passes `document.currentScript.nonce`), else the element's own
11
17
  * `nonce` — read from the property, because a page with a CSP hides the attribute once it is connected.
12
18
  */
13
19
  import { defineCustomElement, type Component } from 'vue'
20
+ import BadgeView from './ui/BadgeView.vue'
14
21
  import DiscussionView from './ui/DiscussionView.vue'
22
+ import MemberSettingsView from './ui/MemberSettingsView.vue'
15
23
  import MyCommunityView from './ui/MyCommunityView.vue'
16
24
  import NoticeFormView from './ui/NoticeFormView.vue'
25
+ import NotificationCentreView from './ui/NotificationCentreView.vue'
26
+ import ProfileView from './ui/ProfileView.vue'
27
+ import WelcomeView from './ui/WelcomeView.vue'
17
28
  import css from './ui/styles.css?inline'
18
29
 
19
30
  /**
@@ -36,6 +47,12 @@ function ensureVueFeatureFlags(): void {
36
47
  export const DISCUSSION_ELEMENT_TAG = 'blogmcp-discussion'
37
48
  export const NOTICE_FORM_ELEMENT_TAG = 'blogmcp-notice-form'
38
49
  export const MY_COMMUNITY_ELEMENT_TAG = 'blogmcp-my-community'
50
+ /** M2 (m2-contracts.md §3.10). */
51
+ export const NOTIFICATION_CENTRE_ELEMENT_TAG = 'blogmcp-notification-centre'
52
+ export const BADGE_ELEMENT_TAG = 'blogmcp-badge'
53
+ export const MEMBER_SETTINGS_ELEMENT_TAG = 'blogmcp-member-settings'
54
+ export const PROFILE_ELEMENT_TAG = 'blogmcp-profile'
55
+ export const WELCOME_ELEMENT_TAG = 'blogmcp-welcome'
39
56
 
40
57
  export interface DefineCommunityElementOptions {
41
58
  /** Another tag name for the element. */
@@ -47,7 +64,16 @@ export interface DefineCommunityElementOptions {
47
64
  export interface DefineCommunityElementsOptions {
48
65
  /** CSP nonce for the elements' `<style>`. */
49
66
  nonce?: string
50
- tags?: { discussion?: string; noticeForm?: string; myCommunity?: string }
67
+ tags?: {
68
+ discussion?: string
69
+ noticeForm?: string
70
+ myCommunity?: string
71
+ notificationCentre?: string
72
+ badge?: string
73
+ memberSettings?: string
74
+ profile?: string
75
+ welcome?: string
76
+ }
51
77
  }
52
78
 
53
79
  /** The element's own nonce: the property (a CSP hides the attribute once connected), else the attribute. */
@@ -101,9 +127,39 @@ export function defineMyCommunityElement(opts: DefineCommunityElementOptions = {
101
127
  define(opts.tag ?? MY_COMMUNITY_ELEMENT_TAG, MyCommunityView, opts.nonce)
102
128
  }
103
129
 
104
- /** Register all three (once). Browser only. */
130
+ /** Register `<blogmcp-notification-centre>` (once). Browser only. */
131
+ export function defineNotificationCentreElement(opts: DefineCommunityElementOptions = {}): void {
132
+ define(opts.tag ?? NOTIFICATION_CENTRE_ELEMENT_TAG, NotificationCentreView, opts.nonce)
133
+ }
134
+
135
+ /** Register `<blogmcp-badge>` (once). Browser only. */
136
+ export function defineBadgeElement(opts: DefineCommunityElementOptions = {}): void {
137
+ define(opts.tag ?? BADGE_ELEMENT_TAG, BadgeView, opts.nonce)
138
+ }
139
+
140
+ /** Register `<blogmcp-member-settings>` (once). Browser only. */
141
+ export function defineMemberSettingsElement(opts: DefineCommunityElementOptions = {}): void {
142
+ define(opts.tag ?? MEMBER_SETTINGS_ELEMENT_TAG, MemberSettingsView, opts.nonce)
143
+ }
144
+
145
+ /** Register `<blogmcp-profile>` (once). Browser only. */
146
+ export function defineProfileElement(opts: DefineCommunityElementOptions = {}): void {
147
+ define(opts.tag ?? PROFILE_ELEMENT_TAG, ProfileView, opts.nonce)
148
+ }
149
+
150
+ /** Register `<blogmcp-welcome>` (once). Browser only. */
151
+ export function defineWelcomeElement(opts: DefineCommunityElementOptions = {}): void {
152
+ define(opts.tag ?? WELCOME_ELEMENT_TAG, WelcomeView, opts.nonce)
153
+ }
154
+
155
+ /** Register all eight (once). Browser only. */
105
156
  export function defineCommunityElements(opts: DefineCommunityElementsOptions = {}): void {
106
157
  defineDiscussionElement({ tag: opts.tags?.discussion, nonce: opts.nonce })
107
158
  defineNoticeFormElement({ tag: opts.tags?.noticeForm, nonce: opts.nonce })
108
159
  defineMyCommunityElement({ tag: opts.tags?.myCommunity, nonce: opts.nonce })
160
+ defineNotificationCentreElement({ tag: opts.tags?.notificationCentre, nonce: opts.nonce })
161
+ defineBadgeElement({ tag: opts.tags?.badge, nonce: opts.nonce })
162
+ defineMemberSettingsElement({ tag: opts.tags?.memberSettings, nonce: opts.nonce })
163
+ defineProfileElement({ tag: opts.tags?.profile, nonce: opts.nonce })
164
+ defineWelcomeElement({ tag: opts.tags?.welcome, nonce: opts.nonce })
109
165
  }
package/src/global.ts CHANGED
@@ -1,12 +1,26 @@
1
1
  /**
2
- * The `<script>` build: registers `<blogmcp-discussion>`, `<blogmcp-notice-form>` and `<blogmcp-my-community>`
3
- * with the script tag's own CSP nonce, and exposes `window.BlogmcpCommunity`.
2
+ * The `<script>` build: registers every element — `<blogmcp-discussion>`, `<blogmcp-notice-form>`,
3
+ * `<blogmcp-my-community>` and (M2) `<blogmcp-notification-centre>`, `<blogmcp-badge>`, `<blogmcp-member-settings>`,
4
+ * `<blogmcp-profile>`, `<blogmcp-welcome>` — with the script tag's own CSP nonce, and exposes
5
+ * `window.BlogmcpCommunity`. Live updates are not in it: a site that wants them bundles `@blogmcp/sdk/community/realtime`
6
+ * and sets the discussion's `realtime` property.
4
7
  */
5
8
  import { defineCommunityElements } from './element'
6
- import { mountDiscussion, mountMyCommunity, mountNoticeForm } from './mount'
9
+ import { mountBadge, mountDiscussion, mountMemberSettings, mountMyCommunity, mountNoticeForm, mountNotificationCentre, mountProfile, mountWelcome } from './mount'
7
10
  import { VERSION } from './version'
8
11
 
9
12
  const script = typeof document !== 'undefined' ? (document.currentScript as HTMLScriptElement | null) : null
10
13
  defineCommunityElements({ nonce: script?.nonce || undefined })
11
14
 
12
- export { defineCommunityElements, mountDiscussion, mountMyCommunity, mountNoticeForm, VERSION }
15
+ export {
16
+ defineCommunityElements,
17
+ mountDiscussion,
18
+ mountMyCommunity,
19
+ mountNoticeForm,
20
+ mountNotificationCentre,
21
+ mountBadge,
22
+ mountMemberSettings,
23
+ mountProfile,
24
+ mountWelcome,
25
+ VERSION,
26
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  * - `<blogmcp-discussion>` the discussion under an article;
8
8
  * - `<blogmcp-notice-form>` the DSA notice form, for anyone;
9
9
  * - `<blogmcp-my-community>` the reader's „Moje komunita", a statement page, a notifier's status page;
10
+ * - M2: `<blogmcp-notification-centre>` the notifications, `<blogmcp-badge>` the header's unread count,
11
+ * `<blogmcp-member-settings>` the switches, push and the digest's unsubscribe, `<blogmcp-profile>` a member's
12
+ * public profile, `<blogmcp-welcome>` the welcome page; the discussion's `realtime` property (live updates);
10
13
  * - `defineCommunityElements()` and one `define…Element()` each — the custom elements;
11
14
  * - `mountDiscussion(…)`, `mountNoticeForm(…)`, `mountMyCommunity(…)` — mount one from code;
12
15
  * - `@blogmcp/community/vue`, `@blogmcp/community/react` — `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>`;
@@ -17,13 +20,45 @@ export {
17
20
  defineDiscussionElement,
18
21
  defineNoticeFormElement,
19
22
  defineMyCommunityElement,
23
+ defineNotificationCentreElement,
24
+ defineBadgeElement,
25
+ defineMemberSettingsElement,
26
+ defineProfileElement,
27
+ defineWelcomeElement,
20
28
  DISCUSSION_ELEMENT_TAG,
21
29
  NOTICE_FORM_ELEMENT_TAG,
22
30
  MY_COMMUNITY_ELEMENT_TAG,
31
+ NOTIFICATION_CENTRE_ELEMENT_TAG,
32
+ BADGE_ELEMENT_TAG,
33
+ MEMBER_SETTINGS_ELEMENT_TAG,
34
+ PROFILE_ELEMENT_TAG,
35
+ WELCOME_ELEMENT_TAG,
23
36
  type DefineCommunityElementOptions,
24
37
  type DefineCommunityElementsOptions,
25
38
  } from './element'
26
- export { mountDiscussion, mountNoticeForm, mountMyCommunity, type MountDiscussionOptions, type MountNoticeFormOptions, type MountMyCommunityOptions } from './mount'
39
+ export {
40
+ mountDiscussion,
41
+ mountNoticeForm,
42
+ mountMyCommunity,
43
+ mountNotificationCentre,
44
+ mountBadge,
45
+ mountMemberSettings,
46
+ mountProfile,
47
+ mountWelcome,
48
+ type MountDiscussionOptions,
49
+ type MountNoticeFormOptions,
50
+ type MountMyCommunityOptions,
51
+ type MountNotificationCentreOptions,
52
+ type MountBadgeOptions,
53
+ type MountMemberSettingsOptions,
54
+ type MountProfileOptions,
55
+ type MountWelcomeOptions,
56
+ } from './mount'
57
+ export { createNotificationCentre, type NotificationCentreController, type CentreUi } from './core/centre'
58
+ export { createBadge, badgeText, BADGE_INTERVAL_MS, type BadgeController, type BadgeOptions, type BadgeUi } from './core/badge'
59
+ export { createMemberSettings, SETTING_KEYS, type MemberSettingsController, type MemberSettingsOptions, type SettingsUi, type SettingKey } from './core/settings'
60
+ export { createProfile, createWelcome, type ProfileController, type WelcomeController } from './core/profile'
61
+ export type { DiscussionRealtime, DiscussionRealtimeOptions, DiscussionRealtimeSession, DiscussionRealtimeMode } from './core/realtime'
27
62
  export { createDiscussionController, type DiscussionController, type DiscussionControllerOptions, type DiscussionUi, type ComposerMode, type Draft } from './core/discussion'
28
63
  export { createNoticeForm, type NoticeFormController, type NoticeFormOptions, type NoticeFormUi } from './core/notice'
29
64
  export { createPanel, type PanelController, type PanelOptions, type PanelUi } from './core/panel'
package/src/mount.ts CHANGED
@@ -7,13 +7,24 @@
7
7
  */
8
8
  import type { CommunityTransport } from '@blogmcp/sdk/community'
9
9
  import type { CommunityRoutes } from './core/attrs'
10
+ import type { DiscussionRealtime } from './core/realtime'
10
11
  import {
12
+ BADGE_ELEMENT_TAG,
11
13
  DISCUSSION_ELEMENT_TAG,
14
+ MEMBER_SETTINGS_ELEMENT_TAG,
12
15
  MY_COMMUNITY_ELEMENT_TAG,
13
16
  NOTICE_FORM_ELEMENT_TAG,
17
+ NOTIFICATION_CENTRE_ELEMENT_TAG,
18
+ PROFILE_ELEMENT_TAG,
19
+ WELCOME_ELEMENT_TAG,
20
+ defineBadgeElement,
14
21
  defineDiscussionElement,
22
+ defineMemberSettingsElement,
15
23
  defineMyCommunityElement,
16
24
  defineNoticeFormElement,
25
+ defineNotificationCentreElement,
26
+ defineProfileElement,
27
+ defineWelcomeElement,
17
28
  } from './element'
18
29
 
19
30
  interface CommonMountOptions {
@@ -42,6 +53,11 @@ export interface MountDiscussionOptions extends CommonMountOptions {
42
53
  signInHref?: string
43
54
  /** Head poll interval in ms (default 20 000; 0 = off). */
44
55
  pollMs?: number
56
+ /**
57
+ * M2: live updates — `createRealtimeSession` of `@blogmcp/sdk/community/realtime` (the host imports it; the element
58
+ * never does). Absent: the head poll.
59
+ */
60
+ realtime?: DiscussionRealtime
45
61
  }
46
62
 
47
63
  export interface MountNoticeFormOptions extends CommonMountOptions {
@@ -60,7 +76,36 @@ export interface MountMyCommunityOptions extends CommonMountOptions {
60
76
  signInHref?: string
61
77
  }
62
78
 
63
- type Settable = HTMLElement & { transport?: CommunityTransport }
79
+ export interface MountNotificationCentreOptions extends CommonMountOptions {
80
+ /** The site's sign-in page. */
81
+ signInHref?: string
82
+ }
83
+
84
+ export interface MountBadgeOptions extends CommonMountOptions {
85
+ /** The unread count's poll in ms (default 60 000; 0 = once, and when the page is shown again). */
86
+ pollMs?: number
87
+ }
88
+
89
+ export interface MountMemberSettingsOptions extends CommonMountOptions {
90
+ /** The host's service worker route (default `<site-base>/sw.js`, served from `@blogmcp/sdk/community/sw`). */
91
+ swPath?: string
92
+ /** The site's sign-in page. */
93
+ signInHref?: string
94
+ }
95
+
96
+ export interface MountProfileOptions extends CommonMountOptions {
97
+ /** The member's handle. */
98
+ handle: string
99
+ /** The site's sign-in page. */
100
+ signInHref?: string
101
+ }
102
+
103
+ export interface MountWelcomeOptions extends CommonMountOptions {
104
+ /** The site's sign-in page (a visitor's link in the profile's place). */
105
+ signInHref?: string
106
+ }
107
+
108
+ type Settable = HTMLElement & { transport?: CommunityTransport; realtime?: DiscussionRealtime }
64
109
 
65
110
  function attrs(el: HTMLElement, values: Record<string, string | number | undefined | null>): void {
66
111
  for (const [name, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') el.setAttribute(name, String(value))
@@ -88,6 +133,7 @@ export function mountDiscussion(o: MountDiscussionOptions): () => void {
88
133
  'poll-ms': o.pollMs,
89
134
  nonce: o.nonce,
90
135
  })
136
+ if (o.realtime) el.realtime = o.realtime
91
137
  return place(el, o)
92
138
  }
93
139
 
@@ -125,3 +171,64 @@ export function mountMyCommunity(o: MountMyCommunityOptions = {}): () => void {
125
171
  })
126
172
  return place(el, o)
127
173
  }
174
+
175
+ /** Mount `<blogmcp-notification-centre>`; returns the function that removes it. */
176
+ export function mountNotificationCentre(o: MountNotificationCentreOptions = {}): () => void {
177
+ if (typeof document === 'undefined') return () => {}
178
+ defineNotificationCentreElement({ nonce: o.nonce })
179
+ const el = document.createElement(NOTIFICATION_CENTRE_ELEMENT_TAG) as Settable
180
+ attrs(el, { 'api-base': o.apiBase, 'site-base': o.siteBase, lang: o.lang, routes: o.routes ? JSON.stringify(o.routes) : undefined, 'sign-in-href': o.signInHref, nonce: o.nonce })
181
+ return place(el, o)
182
+ }
183
+
184
+ /** Mount `<blogmcp-badge>`; returns the function that removes it. */
185
+ export function mountBadge(o: MountBadgeOptions = {}): () => void {
186
+ if (typeof document === 'undefined') return () => {}
187
+ defineBadgeElement({ nonce: o.nonce })
188
+ const el = document.createElement(BADGE_ELEMENT_TAG) as Settable
189
+ attrs(el, { 'api-base': o.apiBase, 'site-base': o.siteBase, lang: o.lang, routes: o.routes ? JSON.stringify(o.routes) : undefined, 'poll-ms': o.pollMs, nonce: o.nonce })
190
+ return place(el, o)
191
+ }
192
+
193
+ /** Mount `<blogmcp-member-settings>`; returns the function that removes it. */
194
+ export function mountMemberSettings(o: MountMemberSettingsOptions = {}): () => void {
195
+ if (typeof document === 'undefined') return () => {}
196
+ defineMemberSettingsElement({ nonce: o.nonce })
197
+ const el = document.createElement(MEMBER_SETTINGS_ELEMENT_TAG) as Settable
198
+ attrs(el, {
199
+ 'api-base': o.apiBase,
200
+ 'site-base': o.siteBase,
201
+ lang: o.lang,
202
+ 'sw-path': o.swPath,
203
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
204
+ 'sign-in-href': o.signInHref,
205
+ nonce: o.nonce,
206
+ })
207
+ return place(el, o)
208
+ }
209
+
210
+ /** Mount `<blogmcp-profile>`; returns the function that removes it. */
211
+ export function mountProfile(o: MountProfileOptions): () => void {
212
+ if (typeof document === 'undefined') return () => {}
213
+ defineProfileElement({ nonce: o.nonce })
214
+ const el = document.createElement(PROFILE_ELEMENT_TAG) as Settable
215
+ attrs(el, {
216
+ handle: o.handle,
217
+ 'api-base': o.apiBase,
218
+ 'site-base': o.siteBase,
219
+ lang: o.lang,
220
+ routes: o.routes ? JSON.stringify(o.routes) : undefined,
221
+ 'sign-in-href': o.signInHref,
222
+ nonce: o.nonce,
223
+ })
224
+ return place(el, o)
225
+ }
226
+
227
+ /** Mount `<blogmcp-welcome>`; returns the function that removes it. */
228
+ export function mountWelcome(o: MountWelcomeOptions = {}): () => void {
229
+ if (typeof document === 'undefined') return () => {}
230
+ defineWelcomeElement({ nonce: o.nonce })
231
+ const el = document.createElement(WELCOME_ELEMENT_TAG) as Settable
232
+ attrs(el, { 'api-base': o.apiBase, 'site-base': o.siteBase, lang: o.lang, routes: o.routes ? JSON.stringify(o.routes) : undefined, 'sign-in-href': o.signInHref, nonce: o.nonce })
233
+ return place(el, o)
234
+ }
package/src/react.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>` for React (18 and 19, Next.js App Router included — this
2
+ * `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>` and (M2) `<KbNotificationCentre>`, `<KbBadge>`,
3
+ * `<KbMemberSettings>`, `<KbProfile>`, `<KbWelcome>` for React (18 and 19, Next.js App Router included — this
3
4
  * module is a client component): each mounts its element after hydration and removes it on unmount. Each
4
5
  * renders one empty `<div>` as the mount point — nothing else, on the server or the client — so the element
5
6
  * sits where the component does and the page's markup is otherwise the site's alone.
@@ -7,11 +8,25 @@
7
8
  * <KbDiscussion articleId={article.id} apiBase="/api/community" signInHref={`/sign-in#${pathname}`} />
8
9
  */
9
10
  import { createElement, useEffect, useRef, type ReactElement } from 'react'
10
- import type { MountDiscussionOptions, MountMyCommunityOptions, MountNoticeFormOptions } from './mount'
11
+ import type {
12
+ MountBadgeOptions,
13
+ MountDiscussionOptions,
14
+ MountMemberSettingsOptions,
15
+ MountMyCommunityOptions,
16
+ MountNoticeFormOptions,
17
+ MountNotificationCentreOptions,
18
+ MountProfileOptions,
19
+ MountWelcomeOptions,
20
+ } from './mount'
11
21
 
12
22
  export type KbDiscussionProps = Omit<MountDiscussionOptions, 'container'>
13
23
  export type KbNoticeFormProps = Omit<MountNoticeFormOptions, 'container'>
14
24
  export type KbMyCommunityProps = Omit<MountMyCommunityOptions, 'container'>
25
+ export type KbNotificationCentreProps = Omit<MountNotificationCentreOptions, 'container'>
26
+ export type KbBadgeProps = Omit<MountBadgeOptions, 'container'>
27
+ export type KbMemberSettingsProps = Omit<MountMemberSettingsOptions, 'container'>
28
+ export type KbProfileProps = Omit<MountProfileOptions, 'container'>
29
+ export type KbWelcomeProps = Omit<MountWelcomeOptions, 'container'>
15
30
 
16
31
  /** The options that, changed, mount the element anew (functions by identity are left out). */
17
32
  const keyOf = (props: object): string => JSON.stringify(props, (_k, v: unknown) => (typeof v === 'function' ? '[fn]' : v))
@@ -50,4 +65,33 @@ export function KbMyCommunity(props: KbMyCommunityProps): ReactElement {
50
65
  return useMounted('my-community', props, async () => (await import('./mount')).mountMyCommunity)
51
66
  }
52
67
 
53
- export type { MountDiscussionOptions, MountNoticeFormOptions, MountMyCommunityOptions }
68
+ export function KbNotificationCentre(props: KbNotificationCentreProps): ReactElement {
69
+ return useMounted('notification-centre', props, async () => (await import('./mount')).mountNotificationCentre)
70
+ }
71
+
72
+ export function KbBadge(props: KbBadgeProps): ReactElement {
73
+ return useMounted('badge', props, async () => (await import('./mount')).mountBadge)
74
+ }
75
+
76
+ export function KbMemberSettings(props: KbMemberSettingsProps): ReactElement {
77
+ return useMounted('member-settings', props, async () => (await import('./mount')).mountMemberSettings)
78
+ }
79
+
80
+ export function KbProfile(props: KbProfileProps): ReactElement {
81
+ return useMounted('profile', props, async () => (await import('./mount')).mountProfile)
82
+ }
83
+
84
+ export function KbWelcome(props: KbWelcomeProps): ReactElement {
85
+ return useMounted('welcome', props, async () => (await import('./mount')).mountWelcome)
86
+ }
87
+
88
+ export type {
89
+ MountDiscussionOptions,
90
+ MountNoticeFormOptions,
91
+ MountMyCommunityOptions,
92
+ MountNotificationCentreOptions,
93
+ MountBadgeOptions,
94
+ MountMemberSettingsOptions,
95
+ MountProfileOptions,
96
+ MountWelcomeOptions,
97
+ }
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `<blogmcp-badge>`: the unread count for a site's header (m2-contracts.md §3.3, §3.10) — a link to the notification
4
+ * centre showing the count (`99+` above 99), its accessible name the catalogue's „{count} nových upozornění". Nothing
5
+ * at all for a visitor, a reader who never joined, a dark module, or while there is nothing unread.
6
+ */
7
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
8
+ import type { CommunityTransport } from '@blogmcp/sdk/community'
9
+ import { parseRoutes, routesWithDefaults } from '../core/attrs'
10
+ import { badgeText, createBadge, type BadgeController } from '../core/badge'
11
+ import { communityClientFor, siteBaseFor } from '../core/client'
12
+ import { documentLang, pickLang, type Lang } from '../core/i18n'
13
+ import { provideText } from './context'
14
+
15
+ defineOptions({ inheritAttrs: false })
16
+
17
+ const props = defineProps<{
18
+ apiBase?: string
19
+ siteBase?: string
20
+ lang?: string
21
+ /** JSON (or an object): `notificationsPath` (where the badge links). */
22
+ routes?: string | Record<string, unknown>
23
+ /** The poll in ms (default 60 000, never less; 0 = read once and when the page is shown again). */
24
+ pollMs?: number
25
+ transport?: CommunityTransport
26
+ }>()
27
+
28
+ const lang = ref<Lang>(pickLang(props.lang, documentLang()))
29
+ const { t } = provideText(lang)
30
+
31
+ const siteBase = siteBaseFor(props.siteBase)
32
+ const client = siteBase === null ? null : communityClientFor(props.apiBase, props.transport, siteBase)
33
+ const ctl: BadgeController | null = client ? createBadge({ client, ...(typeof props.pollMs === 'number' ? { intervalMs: props.pollMs } : {}) }) : null
34
+ const state = ctl?.state ?? null
35
+ const routes = computed(() => routesWithDefaults(parseRoutes(props.routes), null, siteBase ?? ''))
36
+
37
+ onMounted(() => void ctl?.start())
38
+ onBeforeUnmount(() => ctl?.dispose())
39
+ </script>
40
+
41
+ <template>
42
+ <a
43
+ v-if="state && state.phase === 'shown' && state.count > 0"
44
+ class="bcm-badge-link"
45
+ :lang="lang"
46
+ :href="routes.notificationsPath"
47
+ :aria-label="t('badge.unread', { count: state.count })"
48
+ :title="t('badge.unread', { count: state.count })"
49
+ ><span aria-hidden="true">{{ badgeText(state.count) }}</span></a
50
+ >
51
+ </template>
@@ -13,10 +13,11 @@ const refusedAges = new WeakMap<object, number>()
13
13
  * and one sentence while the discussion takes no writes yet.
14
14
  */
15
15
  import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, useHost, watch } from 'vue'
16
- import { signInLink, statementPath, type CommunityTransport, type DiscussionRestriction } from '@blogmcp/sdk/community'
16
+ import { communityMessageAnchor, communityMessageSeqFromHash, signInLink, statementPath, type CommunityTransport, type DiscussionRestriction } from '@blogmcp/sdk/community'
17
17
  import { parseRoutes, routesWithDefaults } from '../core/attrs'
18
18
  import { communityClientFor, siteBaseFor, warnOnce } from '../core/client'
19
19
  import { createDiscussionController, type DiscussionController } from '../core/discussion'
20
+ import type { DiscussionRealtime } from '../core/realtime'
20
21
  import { formatDate } from '../core/format'
21
22
  import { documentLang, pickLang, type Lang } from '../core/i18n'
22
23
  import { failureText } from '../core/outcomes'
@@ -46,6 +47,11 @@ const props = defineProps<{
46
47
  pollMs?: number
47
48
  /** A replacement transport (a property, never an attribute): tests, a signing transport later. */
48
49
  transport?: CommunityTransport
50
+ /**
51
+ * M2: live updates — the host's session factory (`createRealtimeSession` of `@blogmcp/sdk/community/realtime`), a
52
+ * property, never an attribute. Absent: the head poll.
53
+ */
54
+ realtime?: DiscussionRealtime
49
55
  }>()
50
56
 
51
57
  const host = useHost()
@@ -78,6 +84,7 @@ function create(): DiscussionController | null {
78
84
  articleId,
79
85
  lang: lang.value,
80
86
  pollMs: props.pollMs,
87
+ realtime: typeof props.realtime === 'function' ? props.realtime : null,
81
88
  refusedAge: (host && refusedAges.get(host)) ?? null,
82
89
  onAgeRefused: (minAge) => {
83
90
  if (host) refusedAges.set(host, minAge)
@@ -109,8 +116,30 @@ provideDiscussion({
109
116
  })
110
117
 
111
118
  let observer: IntersectionObserver | null = null
119
+ const section = ref<HTMLElement | null>(null)
120
+ /** The page's `#bcm-msg-<seq>` was looked for once this discussion's first page was in. */
121
+ let anchored = false
122
+
123
+ /**
124
+ * M2 T10: a link to one message (the notification centre's: `<article>#bcm-msg-<seq>`). The rows live in this shadow
125
+ * root, so the browser cannot scroll to the fragment: once the first page is in, the row is scrolled into view — a
126
+ * reply hidden behind „more replies" is shown first. A message beyond the first page is not looked for.
127
+ */
128
+ async function scrollToAnchor(): Promise<void> {
129
+ if (anchored || typeof location === 'undefined') return
130
+ anchored = true
131
+ const seq = communityMessageSeqFromHash(location.hash)
132
+ const id = seq === null ? null : communityMessageAnchor(seq)
133
+ if (seq === null || id === null) return
134
+ const parent = state.value?.thread.messages.find((m) => m.replies.some((r) => r.seq === seq))
135
+ if (parent) allReplies.add(parent.seq)
136
+ await nextTick()
137
+ const row = section.value?.querySelector<HTMLElement>(`#${id}`)
138
+ if (typeof row?.scrollIntoView === 'function') row.scrollIntoView({ block: 'start' })
139
+ }
112
140
 
113
141
  function start(): void {
142
+ anchored = false
114
143
  current.value?.dispose()
115
144
  opened.clear()
116
145
  originals.clear()
@@ -132,7 +161,7 @@ onMounted(() => {
132
161
  }
133
162
  })
134
163
  // Another article, base or language is another discussion (the language picks the translations and the posts' languageCode).
135
- watch(() => [props.articleId, props.apiBase, siteBase.value, props.transport, props.pollMs, lang.value] as const, () => start())
164
+ watch(() => [props.articleId, props.apiBase, siteBase.value, props.transport, props.realtime, props.pollMs, lang.value] as const, () => start())
136
165
  onBeforeUnmount(() => {
137
166
  observer?.disconnect()
138
167
  current.value?.dispose()
@@ -141,6 +170,12 @@ onBeforeUnmount(() => {
141
170
 
142
171
  const ctl = computed(() => current.value)
143
172
  const state = computed(() => current.value?.state ?? null)
173
+ watch(
174
+ () => state.value?.phase,
175
+ (phase) => {
176
+ if (phase === 'ready') void scrollToAnchor()
177
+ },
178
+ )
144
179
  const mode = computed(() => current.value?.mode() ?? 'pending')
145
180
  const composer = ref<InstanceType<typeof MessageComposer> | null>(null)
146
181
  // Joined: the reader goes on where the onboarding was, in the composer. A refusal closed: to the discussion's heading
@@ -194,7 +229,7 @@ async function confirmDelete(): Promise<void> {
194
229
  </script>
195
230
 
196
231
  <template>
197
- <section v-if="ctl && state && state.phase !== 'hidden'" :key="generation" class="bcm bcm-discussion" :lang="lang" :aria-labelledby="headingId">
232
+ <section v-if="ctl && state && state.phase !== 'hidden'" ref="section" :key="generation" class="bcm bcm-discussion" :lang="lang" :aria-labelledby="headingId">
198
233
  <header class="bcm-head">
199
234
  <h2 :id="headingId" ref="heading" tabindex="-1">{{ t('discussion.title') }}</h2>
200
235
  <span v-if="state.phase === 'ready'" class="bcm-count">{{ t('discussion.count', { count: state.thread.count }) }}</span>
@@ -221,6 +256,8 @@ async function confirmDelete(): Promise<void> {
221
256
  <button type="button" class="bcm-btn" :disabled="state.refreshing" @click="ctl.refresh()">{{ t('discussion.refresh') }}</button>
222
257
  </div>
223
258
 
259
+ <p v-if="state.realtimeOff" class="bcm-hint" role="status">{{ t('realtime.off') }}</p>
260
+
224
261
  <p v-if="state.thread.messages.length === 0" class="bcm-muted">{{ t('discussion.empty') }}</p>
225
262
  <ol v-else class="bcm-thread">
226
263
  <MessageItem v-for="m in state.thread.messages" :key="m.id" :message="m" />
@@ -231,6 +268,7 @@ async function confirmDelete(): Promise<void> {
231
268
  </div>
232
269
 
233
270
  <div class="bcm-section">
271
+ <p v-if="mode === 'write' && state.art9RefusedNote" class="bcm-hint">{{ t('onboarding.art9RefusedDone') }}</p>
234
272
  <MessageComposer
235
273
  v-if="mode === 'write'"
236
274
  ref="composer"
@@ -238,6 +276,7 @@ async function confirmDelete(): Promise<void> {
238
276
  :draft="state.composer"
239
277
  :wait="ctl.waitLeft(state.composer)"
240
278
  :rules-href="routes.rulesPath"
279
+ :mentions-max="ctl.mentionCap()"
241
280
  @submit="ctl.submit()"
242
281
  @confirm="(code: string, checked: boolean) => ctl && ctl.setConfirmed(state!.composer, code, checked)"
243
282
  @confirmed="ctl.sendConfirmed(state.composer)"
@@ -261,6 +300,7 @@ async function confirmDelete(): Promise<void> {
261
300
  </p>
262
301
  </div>
263
302
  <p v-else-if="mode === 'notMoved'" class="bcm-note">{{ t('error.notMoved') }}</p>
303
+ <p v-else-if="mode === 'braked'" class="bcm-note" role="status">{{ t('state.braked') }}</p>
264
304
  <p v-else-if="mode === 'restricted'" class="bcm-note">
265
305
  {{ t('error.restricted') }}
266
306
  <a :href="routes.myCommunityPath">{{ t('panel.title') }}</a>
@@ -1,18 +1,24 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * A member's text, as text: runs of text nodes, and the web addresses in it as links that went through
4
- * sanitizeUrl (core/text.ts) — `rel="nofollow ugc noopener noreferrer"`, a new tab. Never HTML.
3
+ * A member's text, as text: runs of text nodes, the web addresses in it as links that went through sanitizeUrl
4
+ * (core/text.ts) — `rel="nofollow ugc noopener noreferrer"`, a new tab — and, with `profilePath` (M2), each `@handle`
5
+ * as a link to that member's profile page on this site. Never HTML.
5
6
  */
6
7
  import { computed } from 'vue'
7
- import { linkify, UGC_LINK_REL } from '../core/text'
8
+ import { profilePath as profilePathOf } from '@blogmcp/sdk/community'
9
+ import { linkifyWithMentions, UGC_LINK_REL } from '../core/text'
8
10
 
9
- const props = defineProps<{ text: string | null }>()
10
- const segments = computed(() => linkify(props.text ?? ''))
11
+ const props = defineProps<{ text: string | null; profilePath?: string | null }>()
12
+ const segments = computed(() => {
13
+ const template = props.profilePath
14
+ return linkifyWithMentions(props.text ?? '', template ? (handle: string) => profilePathOf(template, handle) : null)
15
+ })
11
16
  </script>
12
17
 
13
18
  <template>
14
19
  <template v-for="(s, i) in segments" :key="i">
15
20
  <a v-if="s.kind === 'link'" :href="s.href" :rel="UGC_LINK_REL" target="_blank">{{ s.text }}</a>
21
+ <a v-else-if="s.kind === 'mention'" class="bcm-mention" :href="s.href">{{ s.text }}</a>
16
22
  <template v-else>{{ s.text }}</template>
17
23
  </template>
18
24
  </template>