@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
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Text, and only text (m1-contracts.md §10.4, community.md §7.7): what a member wrote is rendered as text
3
+ * nodes — never HTML, never markdown. A web address in it becomes a link only through @blogmcp/sanitize's
4
+ * `sanitizeUrl`, and the view gives every such link `rel="nofollow ugc noopener noreferrer"` and
5
+ * `target="_blank"`. test/text-only.spec.ts bans `v-html`, `innerHTML` and any HTML parser in src/.
6
+ */
7
+ import { sanitizeUrl } from '@blogmcp/sanitize/url'
8
+ import { communityMentionSpans } from '@blogmcp/sdk/community'
9
+
10
+ export type TextSegment =
11
+ | { readonly kind: 'text'; readonly text: string }
12
+ | { readonly kind: 'link'; readonly text: string; readonly href: string }
13
+ /** M2: an `@handle` with the link to the member's profile page on the host's own site (never a member's URL). */
14
+ | { readonly kind: 'mention'; readonly text: string; readonly href: string }
15
+
16
+ /** The rel every link to what a member wrote carries (user-generated, no referrer, no opener). */
17
+ export const UGC_LINK_REL = 'nofollow ugc noopener noreferrer'
18
+
19
+ /** A web address as people write it: `http(s)://` or `www.`, up to a blank or a character no address carries. */
20
+ const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`{}|\\^]+/gi
21
+ /** Characters that end a sentence more often than an address. */
22
+ const TRAILING = '.,;:!?…\'"»”’]'
23
+
24
+ /** The address without the punctuation after it; a `)` stays when it closes a `(` inside the address. */
25
+ function trimUrl(raw: string): string {
26
+ let url = raw
27
+ while (url.length > 0) {
28
+ const last = url[url.length - 1]!
29
+ if (last === ')') {
30
+ const opens = url.split('(').length - 1
31
+ const closes = url.split(')').length - 1
32
+ if (closes <= opens) break
33
+ } else if (!TRAILING.includes(last)) {
34
+ break
35
+ }
36
+ url = url.slice(0, -1)
37
+ }
38
+ return url
39
+ }
40
+
41
+ /** The link target of an address, or null: only http and https survive sanitizeUrl as a link. */
42
+ function safeHref(url: string): string | null {
43
+ const candidate = /^www\./i.test(url) ? `https://${url}` : url
44
+ // A bare scheme is no address.
45
+ if (/^https?:\/\/$/i.test(candidate)) return null
46
+ const safe = sanitizeUrl(candidate)
47
+ return safe !== 'about:blank' && /^https?:\/\//i.test(safe) ? safe : null
48
+ }
49
+
50
+ /** A member's text as text runs and links. */
51
+ export function linkify(text: string): TextSegment[] {
52
+ const out: TextSegment[] = []
53
+ if (typeof text !== 'string' || text === '') return out
54
+ let at = 0
55
+ for (const m of text.matchAll(URL_RE)) {
56
+ const start = m.index ?? 0
57
+ if (start < at) continue
58
+ const url = trimUrl(m[0])
59
+ const href = url ? safeHref(url) : null
60
+ if (!href) continue
61
+ if (start > at) out.push({ kind: 'text', text: text.slice(at, start) })
62
+ out.push({ kind: 'link', text: url, href })
63
+ at = start + url.length
64
+ }
65
+ if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
66
+ return out
67
+ }
68
+
69
+ /**
70
+ * A member's text as text runs, web-address links and `@handle` mentions (m2-contracts.md §3.3: „the element renders
71
+ * `@handle` as text with a link to `/u/{handle}`"). The mentions are the SDK's (kb-api's token rule), found only in the
72
+ * text between addresses — an address's `/@x` stays part of its link; `profileHref` builds the host's profile page
73
+ * (profilePath) and a handle it gives no page for stays text. Joining every segment's `text` gives the body back.
74
+ */
75
+ export function linkifyWithMentions(text: string, profileHref: ((handle: string) => string | null) | null): TextSegment[] {
76
+ const segments = linkify(text)
77
+ if (!profileHref) return segments
78
+ const out: TextSegment[] = []
79
+ for (const s of segments) {
80
+ if (s.kind !== 'text' || !s.text.includes('@')) {
81
+ out.push(s)
82
+ continue
83
+ }
84
+ for (const span of communityMentionSpans(s.text)) {
85
+ const href = span.kind === 'mention' ? profileHref(span.handle) : null
86
+ if (span.kind === 'mention' && href) out.push({ kind: 'mention', text: span.text, href })
87
+ else {
88
+ const last = out[out.length - 1]
89
+ // Text next to text stays one run (a handle without a page, between two runs).
90
+ if (last && last.kind === 'text') out[out.length - 1] = { kind: 'text', text: last.text + span.text }
91
+ else out.push({ kind: 'text', text: span.text })
92
+ }
93
+ }
94
+ }
95
+ return out
96
+ }
97
+
98
+ /** Characters in code points, as kb-api counts a body (after NFC and trimming). */
99
+ export function codePointLength(text: string): number {
100
+ let n = 0
101
+ for (const _ of text) n++
102
+ return n
103
+ }
104
+
105
+ /** A body as kb-api will measure it: NFC, trimmed. */
106
+ export function normalizeBody(text: string): string {
107
+ return typeof text === 'string' ? text.normalize('NFC').trim() : ''
108
+ }
109
+
110
+ const ENTITIES: Readonly<Record<string, string>> = Object.freeze({
111
+ amp: '&',
112
+ lt: '<',
113
+ gt: '>',
114
+ quot: '"',
115
+ apos: "'",
116
+ nbsp: ' ',
117
+ ndash: '–',
118
+ mdash: '—',
119
+ hellip: '…',
120
+ laquo: '«',
121
+ raquo: '»',
122
+ bdquo: '„',
123
+ ldquo: '“',
124
+ rdquo: '”',
125
+ lsquo: '‘',
126
+ rsquo: '’',
127
+ sbquo: '‚',
128
+ copy: '©',
129
+ reg: '®',
130
+ euro: '€',
131
+ shy: '',
132
+ })
133
+
134
+ function decodeEntities(text: string): string {
135
+ return text.replace(/&(#\d{1,7}|#x[0-9a-f]{1,6}|[a-z]{2,8});/gi, (match, entity: string) => {
136
+ if (entity[0] === '#') {
137
+ const hex = entity[1] === 'x' || entity[1] === 'X'
138
+ const cp = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10)
139
+ return cp > 0 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) ? String.fromCodePoint(cp) : match
140
+ }
141
+ return ENTITIES[entity.toLowerCase()] ?? match
142
+ })
143
+ }
144
+
145
+ /** Elements whose end closes a paragraph of the text. */
146
+ const BLOCK_END = /<\/(?:p|div|li|h[1-6]|section|article|blockquote|tr|ul|ol|dl|dt|dd|header|footer|table|pre|figure)\s*>/gi
147
+ /** Elements whose start opens one. */
148
+ const BLOCK_START = /<(?:li|tr|dt|dd|h[1-6]|blockquote|pre|figure)\b[^>]*>/gi
149
+
150
+ /**
151
+ * The paragraphs of the tenant's own legal text (`legal/current`'s `body` is HTML with anchors) as plain
152
+ * text, for the art. 9 consent step: tags are dropped and entities decoded AFTER that, so nothing in the
153
+ * text is ever parsed as markup again. String work only — no HTML parser, no live DOM.
154
+ */
155
+ export function htmlToParagraphs(html: string): string[] {
156
+ if (typeof html !== 'string' || html === '') return []
157
+ const text = html
158
+ .replace(/<!--[\s\S]*?-->/g, '')
159
+ .replace(/<(script|style|template|noscript|svg|math)\b[\s\S]*?<\/\1\s*>/gi, '')
160
+ .replace(/[\t\n\r\f ]+/g, ' ')
161
+ .replace(/<br\b[^>]*>/gi, '\n')
162
+ .replace(BLOCK_END, '\n\n')
163
+ .replace(BLOCK_START, '\n\n')
164
+ .replace(/<[^>]*>/g, '')
165
+ return decodeEntities(text)
166
+ .split(/\n{2,}/)
167
+ .map((p) =>
168
+ p
169
+ .replace(/ *\n */g, '\n')
170
+ .replace(/[  ]{2,}/g, ' ')
171
+ .trim(),
172
+ )
173
+ .filter((p) => p !== '')
174
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Cloudflare Turnstile for the notice form, by the storefront's TurnstileWidget rules: rendered explicitly,
3
+ * the script loaded only when a notice form mounts (no other page sends Cloudflare a byte), the token
4
+ * one-shot — kb-api spends it on siteverify whatever the answer, so the form asks for a new one after every
5
+ * failure — and a form whose script did not load cannot be sent.
6
+ *
7
+ * The widget is rendered into an element in the custom element's LIGHT DOM (projected through a slot):
8
+ * Turnstile's iframe then lives in the page's own document, as on any other site.
9
+ */
10
+ import { COMMUNITY_NOTICE_TURNSTILE_ACTION } from '@blogmcp/sdk/community'
11
+
12
+ export const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
13
+
14
+ interface TurnstileRenderOptions {
15
+ sitekey: string
16
+ action?: string
17
+ language?: string
18
+ appearance?: 'always' | 'execute' | 'interaction-only'
19
+ callback?: (token: string) => void
20
+ 'expired-callback'?: () => void
21
+ 'error-callback'?: () => void
22
+ 'timeout-callback'?: () => void
23
+ }
24
+
25
+ export interface TurnstileApi {
26
+ render(el: HTMLElement, options: TurnstileRenderOptions): string
27
+ reset(widgetId: string): void
28
+ remove(widgetId: string): void
29
+ }
30
+
31
+ type TurnstileWindow = Window & { turnstile?: TurnstileApi }
32
+
33
+ let scriptPromise: Promise<TurnstileApi> | null = null
34
+
35
+ /** One `<script>` per page however many forms mount; a failed load may be tried again later. */
36
+ export function loadTurnstile(nonce?: string): Promise<TurnstileApi> {
37
+ const w = window as TurnstileWindow
38
+ if (w.turnstile) return Promise.resolve(w.turnstile)
39
+ if (scriptPromise) return scriptPromise
40
+ scriptPromise = new Promise<TurnstileApi>((resolve, reject) => {
41
+ const script = document.createElement('script')
42
+ script.src = TURNSTILE_SCRIPT_SRC
43
+ script.async = true
44
+ script.defer = true
45
+ if (nonce) script.nonce = nonce
46
+ script.onload = () => (w.turnstile ? resolve(w.turnstile) : reject(new Error('[blogmcp-community] the Turnstile script loaded without its API')))
47
+ script.onerror = () => reject(new Error('[blogmcp-community] the Turnstile script failed to load'))
48
+ document.head.appendChild(script)
49
+ }).catch((err: unknown) => {
50
+ scriptPromise = null
51
+ throw err
52
+ })
53
+ return scriptPromise
54
+ }
55
+
56
+ export interface TurnstileWidget {
57
+ /** A fresh token (the last one is spent). */
58
+ reset(): void
59
+ remove(): void
60
+ }
61
+
62
+ export interface RenderTurnstileOptions {
63
+ siteKey: string
64
+ language: string
65
+ /** A new token, or null when it expired or failed (the form cannot be sent until a new one comes). */
66
+ onToken(token: string | null): void
67
+ nonce?: string
68
+ }
69
+
70
+ /** The widget in `container`; rejects when the script cannot load (an ad blocker, the network). */
71
+ export async function renderTurnstile(container: HTMLElement, o: RenderTurnstileOptions): Promise<TurnstileWidget> {
72
+ const api = await loadTurnstile(o.nonce)
73
+ const id = api.render(container, {
74
+ sitekey: o.siteKey,
75
+ // kb-api refuses a token issued for any other action.
76
+ action: COMMUNITY_NOTICE_TURNSTILE_ACTION,
77
+ language: o.language,
78
+ // Visible only when Cloudflare really wants an interaction.
79
+ appearance: 'interaction-only',
80
+ callback: (token) => o.onToken(token),
81
+ 'expired-callback': () => o.onToken(null),
82
+ 'error-callback': () => o.onToken(null),
83
+ 'timeout-callback': () => o.onToken(null),
84
+ })
85
+ let removed = false
86
+ return {
87
+ reset() {
88
+ if (removed) return
89
+ o.onToken(null)
90
+ api.reset(id)
91
+ },
92
+ remove() {
93
+ if (removed) return
94
+ removed = true
95
+ api.remove(id)
96
+ },
97
+ }
98
+ }
package/src/element.ts ADDED
@@ -0,0 +1,165 @@
1
+ /// <reference path="./shims.d.ts" />
2
+ /**
3
+ * The custom elements (Vue inside a shadow root each), m1-contracts.md §10.4 and m2-contracts.md §3.10:
4
+ *
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)
14
+ *
15
+ * Each carries ONE inline stylesheet in its shadow root, with the CSP nonce honoured: the one given when the
16
+ * elements are defined (the script build passes `document.currentScript.nonce`), else the element's own
17
+ * `nonce` — read from the property, because a page with a CSP hides the attribute once it is connected.
18
+ */
19
+ import { defineCustomElement, type Component } from 'vue'
20
+ import BadgeView from './ui/BadgeView.vue'
21
+ import DiscussionView from './ui/DiscussionView.vue'
22
+ import MemberSettingsView from './ui/MemberSettingsView.vue'
23
+ import MyCommunityView from './ui/MyCommunityView.vue'
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'
28
+ import css from './ui/styles.css?inline'
29
+
30
+ /**
31
+ * Vue's ESM build reads three compile-time flags as globals, and only a bundler running Vue's own plugin
32
+ * defines them. A React site (or any bundler without `@vitejs/plugin-vue`) leaves them undefined, and mounting
33
+ * the element then throws `__VUE_PROD_DEVTOOLS__ is not defined` (the review layer met it on the drhoubicka
34
+ * storefront, 2026-10-02). Give them Vue's documented defaults when nothing did; a site that set them keeps
35
+ * its values.
36
+ */
37
+ function ensureVueFeatureFlags(): void {
38
+ const g = globalThis as Record<string, unknown>
39
+ const defaults: Record<string, boolean> = {
40
+ __VUE_OPTIONS_API__: true,
41
+ __VUE_PROD_DEVTOOLS__: false,
42
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
43
+ }
44
+ for (const [flag, value] of Object.entries(defaults)) if (typeof g[flag] === 'undefined') g[flag] = value
45
+ }
46
+
47
+ export const DISCUSSION_ELEMENT_TAG = 'blogmcp-discussion'
48
+ export const NOTICE_FORM_ELEMENT_TAG = 'blogmcp-notice-form'
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'
56
+
57
+ export interface DefineCommunityElementOptions {
58
+ /** Another tag name for the element. */
59
+ tag?: string
60
+ /** CSP nonce for the element's `<style>` (else each element's own `nonce`). */
61
+ nonce?: string
62
+ }
63
+
64
+ export interface DefineCommunityElementsOptions {
65
+ /** CSP nonce for the elements' `<style>`. */
66
+ nonce?: 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
+ }
77
+ }
78
+
79
+ /** The element's own nonce: the property (a CSP hides the attribute once connected), else the attribute. */
80
+ function ownNonce(el: HTMLElement): string | undefined {
81
+ const fromProperty = (el as HTMLElement & { nonce?: unknown }).nonce
82
+ if (typeof fromProperty === 'string' && fromProperty !== '') return fromProperty
83
+ return el.getAttribute('nonce') || undefined
84
+ }
85
+
86
+ function communityElement(component: Component, defineNonce: string | undefined): CustomElementConstructor {
87
+ // No `styles` option: the stylesheet is put in below, so a per-element nonce can be honoured.
88
+ const Base = defineCustomElement(component as Parameters<typeof defineCustomElement>[0])
89
+ return class CommunityElement extends Base {
90
+ override connectedCallback(): void {
91
+ const own = ownNonce(this)
92
+ const nonce = defineNonce || own
93
+ // The element carries the nonce it uses (the IDL property, which a CSP never hides), so what it loads
94
+ // later — the notice form's Turnstile script — gets the same one, the script build's included.
95
+ if (nonce && !own) (this as HTMLElement & { nonce: string }).nonce = nonce
96
+ const root = this.shadowRoot
97
+ if (root && !root.querySelector('style[data-bcm]')) {
98
+ const style = document.createElement('style')
99
+ style.setAttribute('data-bcm', '')
100
+ if (nonce) style.setAttribute('nonce', nonce)
101
+ style.textContent = css
102
+ root.prepend(style)
103
+ }
104
+ super.connectedCallback()
105
+ }
106
+ }
107
+ }
108
+
109
+ function define(tag: string, component: Component, nonce: string | undefined): void {
110
+ if (typeof customElements === 'undefined' || customElements.get(tag)) return
111
+ ensureVueFeatureFlags()
112
+ customElements.define(tag, communityElement(component, nonce))
113
+ }
114
+
115
+ /** Register `<blogmcp-discussion>` (once; later calls are no-ops). Browser only. */
116
+ export function defineDiscussionElement(opts: DefineCommunityElementOptions = {}): void {
117
+ define(opts.tag ?? DISCUSSION_ELEMENT_TAG, DiscussionView, opts.nonce)
118
+ }
119
+
120
+ /** Register `<blogmcp-notice-form>` (once). Browser only. */
121
+ export function defineNoticeFormElement(opts: DefineCommunityElementOptions = {}): void {
122
+ define(opts.tag ?? NOTICE_FORM_ELEMENT_TAG, NoticeFormView, opts.nonce)
123
+ }
124
+
125
+ /** Register `<blogmcp-my-community>` (once). Browser only. */
126
+ export function defineMyCommunityElement(opts: DefineCommunityElementOptions = {}): void {
127
+ define(opts.tag ?? MY_COMMUNITY_ELEMENT_TAG, MyCommunityView, opts.nonce)
128
+ }
129
+
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. */
156
+ export function defineCommunityElements(opts: DefineCommunityElementsOptions = {}): void {
157
+ defineDiscussionElement({ tag: opts.tags?.discussion, nonce: opts.nonce })
158
+ defineNoticeFormElement({ tag: opts.tags?.noticeForm, nonce: opts.nonce })
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 })
165
+ }
package/src/global.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
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.
7
+ */
8
+ import { defineCommunityElements } from './element'
9
+ import { mountBadge, mountDiscussion, mountMemberSettings, mountMyCommunity, mountNoticeForm, mountNotificationCentre, mountProfile, mountWelcome } from './mount'
10
+ import { VERSION } from './version'
11
+
12
+ const script = typeof document !== 'undefined' ? (document.currentScript as HTMLScriptElement | null) : null
13
+ defineCommunityElements({ nonce: script?.nonce || undefined })
14
+
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 ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @blogmcp/community — the community's elements for a site's own pages (m1-contracts.md §10.4; docs: "Add
3
+ * discussions to your frontend"), over `@blogmcp/sdk/community`. Every element talks to the HOST's relative
4
+ * base (`api-base`; default `/api/community` under the site's `site-base`: a headless site's BFF from
5
+ * `@blogmcp/sdk/community/server`, or with `site-base="/blog"` a managed blog's own proxy), never to kb-api directly.
6
+ *
7
+ * - `<blogmcp-discussion>` the discussion under an article;
8
+ * - `<blogmcp-notice-form>` the DSA notice form, for anyone;
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);
13
+ * - `defineCommunityElements()` and one `define…Element()` each — the custom elements;
14
+ * - `mountDiscussion(…)`, `mountNoticeForm(…)`, `mountMyCommunity(…)` — mount one from code;
15
+ * - `@blogmcp/community/vue`, `@blogmcp/community/react` — `<KbDiscussion>`, `<KbNoticeForm>`, `<KbMyCommunity>`;
16
+ * - `@blogmcp/community/global` — one `<script>` for sites without a bundler.
17
+ */
18
+ export {
19
+ defineCommunityElements,
20
+ defineDiscussionElement,
21
+ defineNoticeFormElement,
22
+ defineMyCommunityElement,
23
+ defineNotificationCentreElement,
24
+ defineBadgeElement,
25
+ defineMemberSettingsElement,
26
+ defineProfileElement,
27
+ defineWelcomeElement,
28
+ DISCUSSION_ELEMENT_TAG,
29
+ NOTICE_FORM_ELEMENT_TAG,
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,
36
+ type DefineCommunityElementOptions,
37
+ type DefineCommunityElementsOptions,
38
+ } from './element'
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'
62
+ export { createDiscussionController, type DiscussionController, type DiscussionControllerOptions, type DiscussionUi, type ComposerMode, type Draft } from './core/discussion'
63
+ export { createNoticeForm, type NoticeFormController, type NoticeFormOptions, type NoticeFormUi } from './core/notice'
64
+ export { createPanel, type PanelController, type PanelOptions, type PanelUi } from './core/panel'
65
+ export { createOnboarding, type OnboardingModel, type OnboardingOptions, type OnboardingStep } from './core/onboarding'
66
+ export { pickLang, type Lang } from './core/i18n'
67
+ export { AVATAR_PRESETS, type AvatarPreset } from './core/avatars'
68
+ export { DEFAULT_API_BASE } from './core/client'
69
+ export type { CommunityRoutes } from './core/attrs'
70
+ export { VERSION } from './version'