@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.
- package/LICENSE +21 -0
- package/README.md +243 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-WadUnaTa.js +6436 -0
- package/dist/core/attrs.d.ts +99 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/badge.d.ts +30 -0
- package/dist/core/centre.d.ts +28 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/digest-token.d.ts +2 -0
- package/dist/core/discussion.d.ts +175 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +24 -0
- package/dist/core/i18n.d.ts +28 -0
- package/dist/core/names.d.ts +15 -0
- package/dist/core/notice.d.ts +50 -0
- package/dist/core/onboarding.d.ts +92 -0
- package/dist/core/outcomes.d.ts +29 -0
- package/dist/core/panel.d.ts +96 -0
- package/dist/core/profile.d.ts +40 -0
- package/dist/core/push.d.ts +63 -0
- package/dist/core/realtime.d.ts +35 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/settings.d.ts +53 -0
- package/dist/core/text.d.ts +35 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +47 -0
- package/dist/global.d.ts +11 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +45 -0
- package/dist/mount.d.ts +105 -0
- package/dist/react.d.ts +28 -0
- package/dist/react.js +55 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/BadgeView.vue.d.ts +13 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +44 -0
- package/dist/ui/LinkedText.vue.d.ts +6 -0
- package/dist/ui/MemberSettingsView.vue.d.ts +13 -0
- package/dist/ui/MessageComposer.vue.d.ts +26 -0
- package/dist/ui/MessageItem.vue.d.ts +6 -0
- package/dist/ui/MessageMenu.vue.d.ts +10 -0
- package/dist/ui/MyCommunityView.vue.d.ts +16 -0
- package/dist/ui/NoticeFormView.vue.d.ts +26 -0
- package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
- package/dist/ui/NotificationCentreView.vue.d.ts +12 -0
- package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
- package/dist/ui/ProfileView.vue.d.ts +13 -0
- package/dist/ui/ReactionBar.vue.d.ts +13 -0
- package/dist/ui/ReportDialog.vue.d.ts +15 -0
- package/dist/ui/StatementView.vue.d.ts +15 -0
- package/dist/ui/WelcomeView.vue.d.ts +12 -0
- package/dist/ui/context.d.ts +21 -0
- package/dist/ui/discussion-context.d.ts +19 -0
- package/dist/ui/types.d.ts +7 -0
- package/dist/version.d.ts +1 -0
- package/dist/vue.d.ts +200 -0
- package/dist/vue.js +201 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +186 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/badge.ts +120 -0
- package/src/core/centre.ts +136 -0
- package/src/core/client.ts +54 -0
- package/src/core/digest-token.ts +26 -0
- package/src/core/discussion.ts +876 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +112 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/names.ts +51 -0
- package/src/core/notice.ts +178 -0
- package/src/core/onboarding.ts +492 -0
- package/src/core/outcomes.ts +86 -0
- package/src/core/panel.ts +446 -0
- package/src/core/profile.ts +144 -0
- package/src/core/push.ts +174 -0
- package/src/core/realtime.ts +39 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/settings.ts +363 -0
- package/src/core/text.ts +174 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +165 -0
- package/src/global.ts +26 -0
- package/src/index.ts +70 -0
- package/src/mount.ts +234 -0
- package/src/react.ts +97 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/BadgeView.vue +51 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +334 -0
- package/src/ui/LinkedText.vue +24 -0
- package/src/ui/MemberSettingsView.vue +175 -0
- package/src/ui/MessageComposer.vue +141 -0
- package/src/ui/MessageItem.vue +223 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +378 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/NotificationCentreView.vue +159 -0
- package/src/ui/OnboardingSteps.vue +174 -0
- package/src/ui/ProfileView.vue +91 -0
- package/src/ui/ReactionBar.vue +41 -0
- package/src/ui/ReportDialog.vue +88 -0
- package/src/ui/StatementView.vue +141 -0
- package/src/ui/WelcomeView.vue +72 -0
- package/src/ui/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +259 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +242 -0
package/src/core/text.ts
ADDED
|
@@ -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'
|