@blogmcp/community 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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +243 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-WadUnaTa.js +6436 -0
  5. package/dist/core/attrs.d.ts +99 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/badge.d.ts +30 -0
  8. package/dist/core/centre.d.ts +28 -0
  9. package/dist/core/client.d.ts +27 -0
  10. package/dist/core/digest-token.d.ts +2 -0
  11. package/dist/core/discussion.d.ts +175 -0
  12. package/dist/core/download.d.ts +4 -0
  13. package/dist/core/format.d.ts +24 -0
  14. package/dist/core/i18n.d.ts +28 -0
  15. package/dist/core/names.d.ts +15 -0
  16. package/dist/core/notice.d.ts +50 -0
  17. package/dist/core/onboarding.d.ts +92 -0
  18. package/dist/core/outcomes.d.ts +29 -0
  19. package/dist/core/panel.d.ts +96 -0
  20. package/dist/core/profile.d.ts +40 -0
  21. package/dist/core/push.d.ts +63 -0
  22. package/dist/core/realtime.d.ts +35 -0
  23. package/dist/core/receipt.d.ts +5 -0
  24. package/dist/core/settings.d.ts +53 -0
  25. package/dist/core/text.d.ts +35 -0
  26. package/dist/core/turnstile.d.ts +33 -0
  27. package/dist/element.d.ts +47 -0
  28. package/dist/global.d.ts +11 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +45 -0
  31. package/dist/mount.d.ts +105 -0
  32. package/dist/react.d.ts +28 -0
  33. package/dist/react.js +55 -0
  34. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  35. package/dist/ui/BadgeView.vue.d.ts +13 -0
  36. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  37. package/dist/ui/DiscussionView.vue.d.ts +44 -0
  38. package/dist/ui/LinkedText.vue.d.ts +6 -0
  39. package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
  40. package/dist/ui/MessageComposer.vue.d.ts +26 -0
  41. package/dist/ui/MessageItem.vue.d.ts +6 -0
  42. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  43. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  44. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  45. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  46. package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
  47. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  48. package/dist/ui/ProfileView.vue.d.ts +13 -0
  49. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  50. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  51. package/dist/ui/StatementView.vue.d.ts +15 -0
  52. package/dist/ui/WelcomeView.vue.d.ts +12 -0
  53. package/dist/ui/context.d.ts +21 -0
  54. package/dist/ui/discussion-context.d.ts +19 -0
  55. package/dist/ui/types.d.ts +7 -0
  56. package/dist/version.d.ts +1 -0
  57. package/dist/vue.d.ts +200 -0
  58. package/dist/vue.js +201 -0
  59. package/package.json +80 -4
  60. package/src/core/attrs.ts +186 -0
  61. package/src/core/avatars.ts +44 -0
  62. package/src/core/badge.ts +120 -0
  63. package/src/core/centre.ts +136 -0
  64. package/src/core/client.ts +54 -0
  65. package/src/core/digest-token.ts +26 -0
  66. package/src/core/discussion.ts +876 -0
  67. package/src/core/download.ts +21 -0
  68. package/src/core/format.ts +112 -0
  69. package/src/core/i18n.ts +49 -0
  70. package/src/core/names.ts +51 -0
  71. package/src/core/notice.ts +178 -0
  72. package/src/core/onboarding.ts +492 -0
  73. package/src/core/outcomes.ts +86 -0
  74. package/src/core/panel.ts +446 -0
  75. package/src/core/profile.ts +144 -0
  76. package/src/core/push.ts +174 -0
  77. package/src/core/realtime.ts +39 -0
  78. package/src/core/receipt.ts +28 -0
  79. package/src/core/settings.ts +363 -0
  80. package/src/core/text.ts +174 -0
  81. package/src/core/turnstile.ts +98 -0
  82. package/src/element.ts +165 -0
  83. package/src/global.ts +26 -0
  84. package/src/index.ts +70 -0
  85. package/src/mount.ts +234 -0
  86. package/src/react.ts +97 -0
  87. package/src/shims.d.ts +11 -0
  88. package/src/ui/AuthorAvatar.vue +26 -0
  89. package/src/ui/BadgeView.vue +51 -0
  90. package/src/ui/ConfirmDialog.vue +38 -0
  91. package/src/ui/DiscussionView.vue +334 -0
  92. package/src/ui/LinkedText.vue +24 -0
  93. package/src/ui/MemberSettingsView.vue +175 -0
  94. package/src/ui/MessageComposer.vue +141 -0
  95. package/src/ui/MessageItem.vue +223 -0
  96. package/src/ui/MessageMenu.vue +67 -0
  97. package/src/ui/MyCommunityView.vue +378 -0
  98. package/src/ui/NoticeFormView.vue +244 -0
  99. package/src/ui/NoticeStatusView.vue +82 -0
  100. package/src/ui/NotificationCentreView.vue +159 -0
  101. package/src/ui/OnboardingSteps.vue +174 -0
  102. package/src/ui/ProfileView.vue +91 -0
  103. package/src/ui/ReactionBar.vue +41 -0
  104. package/src/ui/ReportDialog.vue +88 -0
  105. package/src/ui/StatementView.vue +141 -0
  106. package/src/ui/WelcomeView.vue +72 -0
  107. package/src/ui/context.ts +75 -0
  108. package/src/ui/discussion-context.ts +30 -0
  109. package/src/ui/styles.css +259 -0
  110. package/src/ui/types.ts +7 -0
  111. package/src/version.ts +3 -0
  112. package/src/vue.ts +242 -0
package/dist/vue.js ADDED
@@ -0,0 +1,201 @@
1
+ import { defineComponent, ref, onMounted, watch, onBeforeUnmount, h } from "vue";
2
+ function useMount(kind, options, load) {
3
+ const host = ref(null);
4
+ let unmount = null;
5
+ let gone = false;
6
+ let ticket = 0;
7
+ async function mount() {
8
+ const mine = ++ticket;
9
+ const mountFn = await load();
10
+ if (gone || mine !== ticket || !host.value) return;
11
+ unmount?.();
12
+ unmount = mountFn({ ...options(), container: host.value });
13
+ }
14
+ onMounted(() => void mount());
15
+ watch(
16
+ () => JSON.stringify(options(), (_k, v) => typeof v === "function" ? "[fn]" : v),
17
+ () => void mount()
18
+ );
19
+ onBeforeUnmount(() => {
20
+ gone = true;
21
+ unmount?.();
22
+ unmount = null;
23
+ });
24
+ return () => h("div", { ref: host, "data-blogmcp-community": kind });
25
+ }
26
+ function defined(o) {
27
+ const out = {};
28
+ for (const [k, v] of Object.entries(o)) if (v !== void 0 && v !== null) out[k] = v;
29
+ return out;
30
+ }
31
+ const KbDiscussion = defineComponent({
32
+ name: "KbDiscussion",
33
+ props: {
34
+ articleId: { type: String, required: true },
35
+ apiBase: String,
36
+ siteBase: String,
37
+ lang: String,
38
+ signInHref: String,
39
+ routes: Object,
40
+ pollMs: Number,
41
+ nonce: String,
42
+ transport: Function,
43
+ /** M2: `createRealtimeSession` of `@blogmcp/sdk/community/realtime`. */
44
+ realtime: Function
45
+ },
46
+ setup(props) {
47
+ return useMount(
48
+ "discussion",
49
+ () => defined({ ...props }),
50
+ async () => (await import("./chunks/mount-WadUnaTa.js").then((n) => n.J)).mountDiscussion
51
+ );
52
+ }
53
+ });
54
+ const KbNoticeForm = defineComponent({
55
+ name: "KbNoticeForm",
56
+ props: {
57
+ target: Object,
58
+ articleId: String,
59
+ apiBase: String,
60
+ siteBase: String,
61
+ lang: String,
62
+ turnstileSiteKey: String,
63
+ routes: Object,
64
+ nonce: String,
65
+ transport: Function
66
+ },
67
+ setup(props) {
68
+ return useMount(
69
+ "notice-form",
70
+ () => defined({ ...props }),
71
+ async () => (await import("./chunks/mount-WadUnaTa.js").then((n) => n.J)).mountNoticeForm
72
+ );
73
+ }
74
+ });
75
+ const KbMyCommunity = defineComponent({
76
+ name: "KbMyCommunity",
77
+ props: {
78
+ decisionId: String,
79
+ apiBase: String,
80
+ siteBase: String,
81
+ lang: String,
82
+ signInHref: String,
83
+ routes: Object,
84
+ nonce: String,
85
+ transport: Function
86
+ },
87
+ setup(props) {
88
+ return useMount(
89
+ "my-community",
90
+ () => defined({ ...props }),
91
+ async () => (await import("./chunks/mount-WadUnaTa.js").then((n) => n.J)).mountMyCommunity
92
+ );
93
+ }
94
+ });
95
+ const KbNotificationCentre = defineComponent({
96
+ name: "KbNotificationCentre",
97
+ props: {
98
+ apiBase: String,
99
+ siteBase: String,
100
+ lang: String,
101
+ signInHref: String,
102
+ routes: Object,
103
+ nonce: String,
104
+ transport: Function
105
+ },
106
+ setup(props) {
107
+ return useMount(
108
+ "notification-centre",
109
+ () => defined({ ...props }),
110
+ async () => (await import("./chunks/mount-WadUnaTa.js").then((n) => n.J)).mountNotificationCentre
111
+ );
112
+ }
113
+ });
114
+ const KbBadge = defineComponent({
115
+ name: "KbBadge",
116
+ props: {
117
+ apiBase: String,
118
+ siteBase: String,
119
+ lang: String,
120
+ routes: Object,
121
+ pollMs: Number,
122
+ nonce: String,
123
+ transport: Function
124
+ },
125
+ setup(props) {
126
+ return useMount(
127
+ "badge",
128
+ () => defined({ ...props }),
129
+ async () => (await import("./chunks/mount-WadUnaTa.js").then((n) => n.J)).mountBadge
130
+ );
131
+ }
132
+ });
133
+ const KbMemberSettings = defineComponent({
134
+ name: "KbMemberSettings",
135
+ props: {
136
+ apiBase: String,
137
+ siteBase: String,
138
+ lang: String,
139
+ swPath: String,
140
+ signInHref: String,
141
+ routes: Object,
142
+ nonce: String,
143
+ transport: Function
144
+ },
145
+ setup(props) {
146
+ return useMount(
147
+ "member-settings",
148
+ () => defined({ ...props }),
149
+ async () => (await import("./chunks/mount-WadUnaTa.js").then((n) => n.J)).mountMemberSettings
150
+ );
151
+ }
152
+ });
153
+ const KbProfile = defineComponent({
154
+ name: "KbProfile",
155
+ props: {
156
+ handle: { type: String, required: true },
157
+ apiBase: String,
158
+ siteBase: String,
159
+ lang: String,
160
+ signInHref: String,
161
+ routes: Object,
162
+ nonce: String,
163
+ transport: Function
164
+ },
165
+ setup(props) {
166
+ return useMount(
167
+ "profile",
168
+ () => defined({ ...props }),
169
+ async () => (await import("./chunks/mount-WadUnaTa.js").then((n) => n.J)).mountProfile
170
+ );
171
+ }
172
+ });
173
+ const KbWelcome = defineComponent({
174
+ name: "KbWelcome",
175
+ props: {
176
+ apiBase: String,
177
+ siteBase: String,
178
+ lang: String,
179
+ signInHref: String,
180
+ routes: Object,
181
+ nonce: String,
182
+ transport: Function
183
+ },
184
+ setup(props) {
185
+ return useMount(
186
+ "welcome",
187
+ () => defined({ ...props }),
188
+ async () => (await import("./chunks/mount-WadUnaTa.js").then((n) => n.J)).mountWelcome
189
+ );
190
+ }
191
+ });
192
+ export {
193
+ KbBadge,
194
+ KbDiscussion,
195
+ KbMemberSettings,
196
+ KbMyCommunity,
197
+ KbNoticeForm,
198
+ KbNotificationCentre,
199
+ KbProfile,
200
+ KbWelcome
201
+ };
package/package.json CHANGED
@@ -1,6 +1,82 @@
1
1
  {
2
2
  "name": "@blogmcp/community",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Community elements for your own site: the discussion under an article, the DSA notice form and the reader's โ€žMy communityโ€ panel. Custom elements + Vue/React wrappers + a script build, over @blogmcp/sdk/community.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "sideEffects": [
13
+ "./dist/global.js",
14
+ "./dist/blogmcp-community.global.js",
15
+ "./src/global.ts"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./vue": {
23
+ "types": "./dist/vue.d.ts",
24
+ "import": "./dist/vue.js"
25
+ },
26
+ "./react": {
27
+ "types": "./dist/react.d.ts",
28
+ "import": "./dist/react.js"
29
+ },
30
+ "./global": "./dist/blogmcp-community.global.js"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src"
35
+ ],
36
+ "scripts": {
37
+ "build": "vite build && vite build --config vite.global.config.ts && vue-tsc -p tsconfig.build.json && node scripts/check-size.mjs",
38
+ "test": "vitest run",
39
+ "test:e2e": "playwright test --config test/e2e/playwright.config.ts",
40
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json",
41
+ "prepack": "node ../../scripts/pack-dist-types.mjs prepack",
42
+ "postpack": "node ../../scripts/pack-dist-types.mjs postpack"
43
+ },
44
+ "dependencies": {
45
+ "@blogmcp/sanitize": "^0.10.0",
46
+ "@blogmcp/sdk": "^0.10.0",
47
+ "vue": "^3.5.0"
48
+ },
49
+ "peerDependencies": {
50
+ "react": ">=18"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "react": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "@playwright/test": "^1.49.0",
59
+ "@types/node": "^20.10.0",
60
+ "@types/react": "^18.3.0",
61
+ "@types/react-dom": "^18.3.0",
62
+ "@vitejs/plugin-vue": "^6.0.0",
63
+ "happy-dom": "^17.4.0",
64
+ "react": "^18.3.0",
65
+ "react-dom": "^18.3.0",
66
+ "typescript": "^5.3.3",
67
+ "vite": "^7.3.0",
68
+ "vitest": "^3.0.0",
69
+ "vue-tsc": "^2.0.0"
70
+ },
71
+ "keywords": [
72
+ "blogmcp",
73
+ "community",
74
+ "discussion",
75
+ "comments",
76
+ "dsa",
77
+ "custom-element",
78
+ "vue",
79
+ "react"
80
+ ],
81
+ "license": "MIT"
82
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * What the elements read from their attributes: the host's page routes (`routes`, JSON), the notice form's
3
+ * target (`target`, JSON, or `article-id`), and the link the discussion gives a reader without a session
4
+ * to report a message on the host's notice page.
5
+ *
6
+ * Every path is the HOST's (m1-contracts.md ยง10.2: the elements take the host's routes as attributes) and
7
+ * must be a path on its own site โ€” one leading `/`, never `//` or `/\`, no blank, control character or
8
+ * backslash; anything else is dropped and the managed blog's default (COMMUNITY_ROUTES) applies.
9
+ *
10
+ * The defaults are router-relative (the SDK's COMMUNITY_ROUTES: "the blog prepends its own base"). A site served
11
+ * under a path โ€” a managed blog under `/blog`, whose host's own `/api/` goes straight to kb-api โ€” names it in
12
+ * `site-base`, and the defaults (the pages and the api-base) are taken under it; what the host names itself
13
+ * (`routes`, `api-base`, the tenant's MyState.links) is a full site path already and is used as it is.
14
+ */
15
+ import { COMMUNITY_ROUTES, type NoticeTarget } from '@blogmcp/sdk/community'
16
+
17
+ /** The host's community pages, as the `routes` attribute names them (all optional). */
18
+ export interface CommunityRoutes {
19
+ /** A statement of reasons; `{id}` is the decision's id (`/komunita/rozhodnuti/{id}`). */
20
+ statementPath?: string
21
+ /** The โ€žMoje komunita" panel. */
22
+ myCommunityPath?: string
23
+ /** The rules page (rendered by the host without JavaScript). */
24
+ rulesPath?: string
25
+ /** The notice form. */
26
+ reportPath?: string
27
+ /** A notifier's status page (the receipt goes in its fragment). */
28
+ noticesPath?: string
29
+ /**
30
+ * The site's consultation page (drhoubicka's โ€žporadna"): the regulated preset's `report_to_manufacturer` note
31
+ * becomes a link to it (m1-contracts.md ยง7.6). No default โ€” without it the note stays text.
32
+ */
33
+ consultationPath?: string
34
+ /**
35
+ * M2 (m2-contracts.md ยง3.10): a member's public profile; `{handle}` is the handle (`/komunita/u/{handle}`) โ€” a
36
+ * template, as statementPath is. A mention in a message links to it.
37
+ */
38
+ profilePath?: string
39
+ /** M2: the member's notification settings (where the digest's unsubscribe link lands with `#u=`). */
40
+ settingsPath?: string
41
+ /** M2: the notification centre (the badge links to it). */
42
+ notificationsPath?: string
43
+ /** M2: the welcome page after joining. */
44
+ welcomePath?: string
45
+ /** M2: the discussions overview. */
46
+ discussionsPath?: string
47
+ /**
48
+ * M2 T10: an article's page; `{slug}` is the article's slug (`/blog/articles/{slug}`, the storefront `/blog/{slug}`)
49
+ * โ€” a template, as profilePath is. The notification centre links a reply's or a mention's message on it (the
50
+ * message's anchor in the fragment). No default: without it those rows stay unlinked.
51
+ */
52
+ articlePath?: string
53
+ }
54
+
55
+ const ROUTE_KEYS = [
56
+ 'statementPath',
57
+ 'myCommunityPath',
58
+ 'rulesPath',
59
+ 'reportPath',
60
+ 'noticesPath',
61
+ 'consultationPath',
62
+ 'profilePath',
63
+ 'settingsPath',
64
+ 'notificationsPath',
65
+ 'welcomePath',
66
+ 'discussionsPath',
67
+ 'articlePath',
68
+ ] as const
69
+
70
+ /** A path on the host's own site (the SDK's links.ts rule). */
71
+ export function isSitePath(path: unknown): path is string {
72
+ return typeof path === 'string' && /^\/(?![/\\])/.test(path) && !/[\s\\\u0000-\u001f\u007f]/.test(path)
73
+ }
74
+
75
+ type Rec = Record<string, unknown>
76
+ const isRecord = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v)
77
+
78
+ /** A JSON attribute's value (a string), or the object a framework set as the property. */
79
+ function jsonOf(value: unknown): unknown {
80
+ if (typeof value !== 'string') return value
81
+ if (value.trim() === '') return null
82
+ try {
83
+ return JSON.parse(value) as unknown
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ /** The `routes` attribute: the site paths it names; anything malformed is left out. */
90
+ export function parseRoutes(value: unknown): CommunityRoutes {
91
+ const raw = jsonOf(value)
92
+ const out: CommunityRoutes = {}
93
+ if (!isRecord(raw)) return out
94
+ for (const key of ROUTE_KEYS) if (isSitePath(raw[key])) out[key] = raw[key] as string
95
+ return out
96
+ }
97
+
98
+ /**
99
+ * The `site-base` attribute: the path the site is served under, without a trailing `/` (`/blog`; '' for the root,
100
+ * also when absent) โ€” or null when it is no plain path (a URL, a query, a dot segment): the element then renders
101
+ * nothing, as for an absolute api-base.
102
+ */
103
+ export function siteBaseOf(value: unknown): string | null {
104
+ if (value === undefined || value === null || value === '') return ''
105
+ if (typeof value !== 'string') return null
106
+ const base = value.replace(/\/+$/, '')
107
+ if (base === '') return value.startsWith('/') ? '' : null
108
+ return /^(?:\/(?!\.{1,2}(?:\/|$))[A-Za-z0-9._~%-]+)+$/.test(base) ? base : null
109
+ }
110
+
111
+ /**
112
+ * The pages with the managed blog's defaults filled in under the site's base (statementPath and profilePath stay
113
+ * templates; articlePath, a template too, has no default).
114
+ * A page the host or the tenant names is its own full path.
115
+ */
116
+ export function routesWithDefaults(
117
+ routes: CommunityRoutes,
118
+ tenant?: { statementPath?: string | null; myCommunityPath?: string | null } | null,
119
+ siteBase = '',
120
+ ) {
121
+ const site = (path: string | null | undefined): string | undefined => (isSitePath(path) ? path : undefined)
122
+ return {
123
+ statementPath: routes.statementPath ?? site(tenant?.statementPath) ?? `${siteBase}${COMMUNITY_ROUTES.statement}`,
124
+ myCommunityPath: routes.myCommunityPath ?? site(tenant?.myCommunityPath) ?? `${siteBase}${COMMUNITY_ROUTES.myCommunity}`,
125
+ rulesPath: routes.rulesPath ?? `${siteBase}${COMMUNITY_ROUTES.rules}`,
126
+ reportPath: routes.reportPath ?? `${siteBase}${COMMUNITY_ROUTES.report}`,
127
+ noticesPath: routes.noticesPath ?? `${siteBase}${COMMUNITY_ROUTES.notices}`,
128
+ consultationPath: routes.consultationPath ?? null,
129
+ profilePath: routes.profilePath ?? `${siteBase}${COMMUNITY_ROUTES.profile}`,
130
+ settingsPath: routes.settingsPath ?? `${siteBase}${COMMUNITY_ROUTES.settings}`,
131
+ notificationsPath: routes.notificationsPath ?? `${siteBase}${COMMUNITY_ROUTES.notifications}`,
132
+ welcomePath: routes.welcomePath ?? `${siteBase}${COMMUNITY_ROUTES.welcome}`,
133
+ discussionsPath: routes.discussionsPath ?? `${siteBase}${COMMUNITY_ROUTES.discussions}`,
134
+ articlePath: routes.articlePath ?? null,
135
+ }
136
+ }
137
+
138
+ const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
139
+ const HANDLE_RE = /^[a-z0-9][a-z0-9_]{2,29}$/
140
+ const isId = (v: unknown): v is string => typeof v === 'string' && ID_RE.test(v)
141
+ const isSeq = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v > 0
142
+
143
+ /** A notice target of one of kb-api's four shapes, or null (the form then has nothing to report). */
144
+ export function parseTarget(value: unknown): NoticeTarget | null {
145
+ const raw = jsonOf(value)
146
+ if (!isRecord(raw)) return null
147
+ if ('conversationId' in raw) return isId(raw.conversationId) && isSeq(raw.seq) ? { conversationId: raw.conversationId.toLowerCase(), seq: raw.seq } : null
148
+ if ('memberId' in raw) return isId(raw.memberId) ? { memberId: raw.memberId.toLowerCase() } : null
149
+ if ('handle' in raw) return typeof raw.handle === 'string' && HANDLE_RE.test(raw.handle) ? { handle: raw.handle } : null
150
+ if ('articleId' in raw) return isId(raw.articleId) ? { articleId: raw.articleId.toLowerCase() } : null
151
+ return null
152
+ }
153
+
154
+ export type NoticeTargetKind = 'message' | 'profile' | 'article'
155
+
156
+ export function targetKind(target: NoticeTarget): NoticeTargetKind {
157
+ if ('conversationId' in target) return 'message'
158
+ if ('articleId' in target) return 'article'
159
+ return 'profile'
160
+ }
161
+
162
+ /**
163
+ * The notice page's link for a message, for a reader without a session (who cannot use the member's
164
+ * report): `${reportPath}#c=<conversationId>&s=<seq>`. The ids are public; they travel in the fragment so
165
+ * the notice page stays one address (no crawlable variants โ€” the storefront's rule for the sign-in link).
166
+ */
167
+ export function reportLink(reportPath: string, target: { conversationId: string; seq: number }): string | null {
168
+ if (!isSitePath(reportPath) || reportPath.includes('#') || !isId(target.conversationId) || !isSeq(target.seq)) return null
169
+ return `${reportPath}#c=${target.conversationId.toLowerCase()}&s=${target.seq}`
170
+ }
171
+
172
+ /** The target the discussion's report link carries (`#c=โ€ฆ&s=โ€ฆ`), or `#a=<articleId>`; null for anything else. */
173
+ export function targetFromHash(hash: string): NoticeTarget | null {
174
+ if (typeof hash !== 'string' || !hash.startsWith('#') || hash.startsWith('#t=')) return null
175
+ const params = new Map<string, string>()
176
+ for (const part of hash.slice(1).split('&')) {
177
+ const eq = part.indexOf('=')
178
+ if (eq > 0) params.set(part.slice(0, eq), part.slice(eq + 1))
179
+ }
180
+ const c = params.get('c')
181
+ const s = params.get('s')
182
+ if (c !== undefined && s !== undefined && /^\d{1,15}$/.test(s)) return parseTarget({ conversationId: c, seq: Number(s) })
183
+ const a = params.get('a')
184
+ if (a !== undefined) return parseTarget({ articleId: a })
185
+ return null
186
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The avatar presets a member picks from (kb-api stores the id, `^[a-z0-9_]{1,64}$`, and knows no list).
3
+ * Each is one emoji: a screen reader names it in the reader's own language, so the picker needs no words of
4
+ * ours. An id this list does not know (a newer element's) shows the name's initial instead.
5
+ */
6
+ export interface AvatarPreset {
7
+ readonly id: string
8
+ readonly emoji: string
9
+ }
10
+
11
+ export const AVATAR_PRESETS: readonly AvatarPreset[] = Object.freeze([
12
+ Object.freeze({ id: 'fox', emoji: '๐ŸฆŠ' }),
13
+ Object.freeze({ id: 'owl', emoji: '๐Ÿฆ‰' }),
14
+ Object.freeze({ id: 'bear', emoji: '๐Ÿป' }),
15
+ Object.freeze({ id: 'hedgehog', emoji: '๐Ÿฆ”' }),
16
+ Object.freeze({ id: 'frog', emoji: '๐Ÿธ' }),
17
+ Object.freeze({ id: 'bee', emoji: '๐Ÿ' }),
18
+ Object.freeze({ id: 'leaf', emoji: '๐Ÿƒ' }),
19
+ Object.freeze({ id: 'flower', emoji: '๐ŸŒผ' }),
20
+ Object.freeze({ id: 'tree', emoji: '๐ŸŒฒ' }),
21
+ Object.freeze({ id: 'star', emoji: 'โญ' }),
22
+ ])
23
+
24
+ const BY_ID = new Map(AVATAR_PRESETS.map((p) => [p.id, p.emoji]))
25
+
26
+ /** The preset's emoji, or null for none or an unknown id. */
27
+ export function avatarEmoji(preset: string | null | undefined): string | null {
28
+ return typeof preset === 'string' ? (BY_ID.get(preset) ?? null) : null
29
+ }
30
+
31
+ /** The first letter of a name (the avatar of a member without a known preset). */
32
+ export function initialOf(name: string | null | undefined): string {
33
+ if (typeof name !== 'string') return ''
34
+ for (const ch of name.trim()) return ch.toLocaleUpperCase()
35
+ return ''
36
+ }
37
+
38
+ /** A stable hue for a member id, so the same member keeps the same initial's colour (decoration only). */
39
+ export function hueOf(id: string | null | undefined): number {
40
+ if (typeof id !== 'string' || id === '') return 210
41
+ let h = 0
42
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360
43
+ return h
44
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `<blogmcp-badge>`'s brain (m2-contracts.md ยง3.3, ยง3.10): the unread count of the member's notifications for a
3
+ * site's header. `me/get` once (who is viewing, the module's state); then `me/notifications/unread-count` at once
4
+ * when the page is visible, every `intervalMs` (60 s, never less) while it is visible, and again when it is shown again (`visibilitychange`). A
5
+ * visitor, a reader who never joined and a module that is `dark` get nothing: no count, no further request.
6
+ */
7
+ import { reactive } from 'vue'
8
+ import { isKnownModuleState, type CommunityCallOptions, type CommunityClient } from '@blogmcp/sdk/community'
9
+
10
+ /** The badge's poll (the contract's 60 s). */
11
+ export const BADGE_INTERVAL_MS = 60_000
12
+ /** A count above it reads `99+`. */
13
+ export const BADGE_MAX = 99
14
+
15
+ export interface BadgeUi {
16
+ /** `hidden`: nothing to show (a visitor, no member, a dark module, or not decided yet). */
17
+ phase: 'pending' | 'hidden' | 'shown'
18
+ count: number
19
+ }
20
+
21
+ export interface BadgeController {
22
+ readonly state: BadgeUi
23
+ start(): Promise<void>
24
+ /** Read the count now (coalesced with one on its way). */
25
+ refresh(): Promise<void>
26
+ dispose(): void
27
+ }
28
+
29
+ export interface BadgeOptions {
30
+ client: CommunityClient
31
+ /**
32
+ * Default 60 s, and never more often (a smaller value above 0 reads as 60 s: the contract's rate per open tab);
33
+ * 0 or less = no poll (the count is read once and on visibility).
34
+ */
35
+ intervalMs?: number
36
+ /** Whether the page is visible now (default: document.visibilityState). */
37
+ visible?: () => boolean
38
+ }
39
+
40
+ /** The count as the badge shows it. */
41
+ export const badgeText = (count: number): string => (count > BADGE_MAX ? `${BADGE_MAX}+` : String(count))
42
+
43
+ export function createBadge(o: BadgeOptions): BadgeController {
44
+ const { client } = o
45
+ const asked = typeof o.intervalMs === 'number' && Number.isFinite(o.intervalMs) ? o.intervalMs : BADGE_INTERVAL_MS
46
+ const interval = asked > 0 ? Math.max(asked, BADGE_INTERVAL_MS) : 0
47
+ // `!== 'hidden'` as the discussion's head poll reads it: a browser without the API (or a prerendered page) counts.
48
+ const visible = o.visible ?? ((): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden')
49
+ const abort = new AbortController()
50
+ const call: CommunityCallOptions = { signal: abort.signal }
51
+ const state = reactive<BadgeUi>({ phase: 'pending', count: 0 }) as BadgeUi
52
+ let disposed = false
53
+ let active = false
54
+ let timer: ReturnType<typeof setInterval> | null = null
55
+ let reading: Promise<void> | null = null
56
+
57
+ function stop(): void {
58
+ active = false
59
+ if (timer) clearInterval(timer)
60
+ timer = null
61
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
62
+ }
63
+
64
+ function hide(): void {
65
+ state.phase = 'hidden'
66
+ state.count = 0
67
+ stop()
68
+ }
69
+
70
+ function refresh(): Promise<void> {
71
+ if (disposed || !active) return Promise.resolve()
72
+ reading ??= (async () => {
73
+ const r = await client.me.notifications.unreadCount(call)
74
+ if (disposed || !active) return
75
+ if (r.ok) {
76
+ state.count = Math.max(0, Math.floor(r.value.count))
77
+ state.phase = 'shown'
78
+ return
79
+ }
80
+ // The session ended (signed out elsewhere): nothing to count any more.
81
+ if (r.code === 'signIn') hide()
82
+ // Any other failure keeps what the badge shows; the next tick tries again.
83
+ })().finally(() => {
84
+ reading = null
85
+ })
86
+ return reading
87
+ }
88
+
89
+ function onVisibility(): void {
90
+ if (visible()) void refresh()
91
+ }
92
+
93
+ return {
94
+ state,
95
+ async start() {
96
+ const me = await client.me.get(call)
97
+ if (disposed) return
98
+ const moduleState = me.ok ? me.value.module?.state : undefined
99
+ // An unknown state reads as dark (the SDK's open enum); a kb-api older than M2 sends none: the badge counts.
100
+ const dark = moduleState !== undefined && (!isKnownModuleState(moduleState) || moduleState === 'dark')
101
+ if (!me.ok || me.value.needs.includes('sign_in') || !me.value.member || dark) return hide()
102
+ active = true
103
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
104
+ if (interval > 0) {
105
+ timer = setInterval(() => {
106
+ if (visible()) void refresh()
107
+ }, interval)
108
+ }
109
+ // A tab opened in the background asks nothing until it is shown (the visibilitychange listener reads it then).
110
+ if (visible()) await refresh()
111
+ },
112
+ refresh,
113
+ dispose() {
114
+ if (disposed) return
115
+ disposed = true
116
+ stop()
117
+ abort.abort()
118
+ },
119
+ }
120
+ }