@blogmcp/community 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/dist/blogmcp-community.global.js +10 -0
  4. package/dist/chunks/mount-DzjwnHyx.js +4597 -0
  5. package/dist/core/attrs.d.ts +73 -0
  6. package/dist/core/avatars.d.ts +16 -0
  7. package/dist/core/client.d.ts +27 -0
  8. package/dist/core/discussion.d.ts +144 -0
  9. package/dist/core/download.d.ts +4 -0
  10. package/dist/core/format.d.ts +17 -0
  11. package/dist/core/i18n.d.ts +28 -0
  12. package/dist/core/names.d.ts +15 -0
  13. package/dist/core/notice.d.ts +50 -0
  14. package/dist/core/onboarding.d.ts +52 -0
  15. package/dist/core/outcomes.d.ts +28 -0
  16. package/dist/core/panel.d.ts +80 -0
  17. package/dist/core/receipt.d.ts +5 -0
  18. package/dist/core/text.d.ts +22 -0
  19. package/dist/core/turnstile.d.ts +33 -0
  20. package/dist/element.d.ts +26 -0
  21. package/dist/global.d.ts +8 -0
  22. package/dist/index.d.ts +25 -0
  23. package/dist/index.js +22 -0
  24. package/dist/mount.d.ts +65 -0
  25. package/dist/react.d.ts +17 -0
  26. package/dist/react.js +35 -0
  27. package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
  28. package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
  29. package/dist/ui/DiscussionView.vue.d.ts +33 -0
  30. package/dist/ui/LinkedText.vue.d.ts +5 -0
  31. package/dist/ui/MessageComposer.vue.d.ts +24 -0
  32. package/dist/ui/MessageItem.vue.d.ts +6 -0
  33. package/dist/ui/MessageMenu.vue.d.ts +10 -0
  34. package/dist/ui/MyCommunityView.vue.d.ts +16 -0
  35. package/dist/ui/NoticeFormView.vue.d.ts +26 -0
  36. package/dist/ui/NoticeStatusView.vue.d.ts +15 -0
  37. package/dist/ui/OnboardingSteps.vue.d.ts +11 -0
  38. package/dist/ui/ReactionBar.vue.d.ts +13 -0
  39. package/dist/ui/ReportDialog.vue.d.ts +15 -0
  40. package/dist/ui/StatementView.vue.d.ts +15 -0
  41. package/dist/ui/context.d.ts +21 -0
  42. package/dist/ui/discussion-context.d.ts +19 -0
  43. package/dist/ui/types.d.ts +7 -0
  44. package/dist/version.d.ts +1 -0
  45. package/dist/vue.d.ts +89 -0
  46. package/dist/vue.js +97 -0
  47. package/package.json +80 -4
  48. package/src/core/attrs.ts +147 -0
  49. package/src/core/avatars.ts +44 -0
  50. package/src/core/client.ts +54 -0
  51. package/src/core/discussion.ts +691 -0
  52. package/src/core/download.ts +21 -0
  53. package/src/core/format.ts +90 -0
  54. package/src/core/i18n.ts +49 -0
  55. package/src/core/names.ts +51 -0
  56. package/src/core/notice.ts +178 -0
  57. package/src/core/onboarding.ts +345 -0
  58. package/src/core/outcomes.ts +74 -0
  59. package/src/core/panel.ts +411 -0
  60. package/src/core/receipt.ts +28 -0
  61. package/src/core/text.ts +142 -0
  62. package/src/core/turnstile.ts +98 -0
  63. package/src/element.ts +109 -0
  64. package/src/global.ts +12 -0
  65. package/src/index.ts +35 -0
  66. package/src/mount.ts +127 -0
  67. package/src/react.ts +53 -0
  68. package/src/shims.d.ts +11 -0
  69. package/src/ui/AuthorAvatar.vue +26 -0
  70. package/src/ui/ConfirmDialog.vue +38 -0
  71. package/src/ui/DiscussionView.vue +294 -0
  72. package/src/ui/LinkedText.vue +18 -0
  73. package/src/ui/MessageComposer.vue +130 -0
  74. package/src/ui/MessageItem.vue +205 -0
  75. package/src/ui/MessageMenu.vue +67 -0
  76. package/src/ui/MyCommunityView.vue +369 -0
  77. package/src/ui/NoticeFormView.vue +244 -0
  78. package/src/ui/NoticeStatusView.vue +82 -0
  79. package/src/ui/OnboardingSteps.vue +163 -0
  80. package/src/ui/ReactionBar.vue +41 -0
  81. package/src/ui/ReportDialog.vue +88 -0
  82. package/src/ui/StatementView.vue +141 -0
  83. package/src/ui/context.ts +75 -0
  84. package/src/ui/discussion-context.ts +30 -0
  85. package/src/ui/styles.css +243 -0
  86. package/src/ui/types.ts +7 -0
  87. package/src/version.ts +3 -0
  88. package/src/vue.ts +116 -0
@@ -0,0 +1,411 @@
1
+ /**
2
+ * `<blogmcp-my-community>`'s brain (m1-contracts.md §7.4, §8.3, §10.4; community.md §5): the reader's
3
+ * „Moje komunita" — the statements of reasons about their content (the art. 17(3) fields, the appeal within
4
+ * its window, an open appeal's state), their reports, their notifications, the art. 9 consent's withdrawal,
5
+ * their blocks, the export (a JSON download) and the profile's deletion with its grace and cancel.
6
+ *
7
+ * With a decision id it shows that one statement (the host's statement page). With a notifier's receipt it
8
+ * shows that notice's status and appeal — `notices/status`, `notices/appeal`: no session needed for that.
9
+ */
10
+ import { reactive } from 'vue'
11
+ import {
12
+ COMMUNITY_APPEAL_MAX,
13
+ isKnownNotificationCode,
14
+ type AppealItem,
15
+ type BlockEntry,
16
+ type CommunityCallOptions,
17
+ type CommunityClient,
18
+ type CommunityFailure,
19
+ type DecisionSummary,
20
+ type MemberExport,
21
+ type MyState,
22
+ type NoticeStatus,
23
+ type NotificationItem,
24
+ type OwnProfile,
25
+ type ReportItem,
26
+ type Statement,
27
+ } from '@blogmcp/sdk/community'
28
+ import type { TextRef } from './i18n'
29
+ import { isDisplayNameOk, isHandleOk, normalizeHandle, normalizeName } from './names'
30
+ import { codePointLength, normalizeBody } from './text'
31
+
32
+ export type LoadPhase = 'idle' | 'loading' | 'ready' | 'failed'
33
+
34
+ export interface AppealDraft {
35
+ body: string
36
+ sending: boolean
37
+ failure: CommunityFailure | null
38
+ }
39
+
40
+ export interface ProfileDraft {
41
+ displayName: string
42
+ handle: string
43
+ avatarPreset: string | null
44
+ sending: boolean
45
+ failure: CommunityFailure | null
46
+ fields: string[]
47
+ }
48
+
49
+ export interface PanelUi {
50
+ /** The member's part: `signIn` when the session cannot act as a member (or there is none). */
51
+ phase: LoadPhase | 'signIn'
52
+ failure: CommunityFailure | null
53
+ me: MyState | null
54
+ notifications: NotificationItem[]
55
+ decisions: DecisionSummary[]
56
+ appeals: AppealItem[]
57
+ reports: ReportItem[]
58
+ blocks: BlockEntry[]
59
+ /** The statement the page opens (`decision-id`). */
60
+ statement: { phase: LoadPhase | 'signIn'; value: Statement | null; failure: CommunityFailure | null; appeal: AppealDraft }
61
+ /** The notifier's part (`#t=` in the address). `missing`: a `#t=` that holds no receipt. */
62
+ notice: { phase: LoadPhase | 'missing'; value: NoticeStatus | null; failure: CommunityFailure | null; appeal: AppealDraft }
63
+ profile: ProfileDraft | null
64
+ /** The profile's deletion: its date once requested here (kb-api's MyState does not carry it). */
65
+ purgeAfter: string | null
66
+ exporting: boolean
67
+ busy: Record<string, boolean>
68
+ announcement: TextRef | null
69
+ announceId: number
70
+ }
71
+
72
+ export interface PanelController {
73
+ readonly state: PanelUi
74
+ /** The member's sections (me/get and the lists). */
75
+ loadMember(): Promise<void>
76
+ loadStatement(decisionId: string): Promise<void>
77
+ loadNotice(receipt: string | null): Promise<void>
78
+ unreadCount(): number
79
+ markAllRead(): Promise<void>
80
+ fileAppeal(): Promise<void>
81
+ withdrawAppeal(): Promise<void>
82
+ fileNoticeAppeal(): Promise<void>
83
+ withdrawNoticeAppeal(): Promise<void>
84
+ withdrawConsent(): Promise<boolean>
85
+ unblock(memberId: string): Promise<void>
86
+ editProfile(): void
87
+ cancelProfile(): void
88
+ saveProfile(): Promise<void>
89
+ /** `me/export` as a JSON file the browser saves (`save` does the download; tests pass their own). */
90
+ exportData(save: (data: MemberExport) => void): Promise<void>
91
+ erase(): Promise<boolean>
92
+ cancelErase(): Promise<void>
93
+ /** The member asked for the deletion (here or before). */
94
+ erasing(): boolean
95
+ appealLength(draft: AppealDraft): number
96
+ canAppealWith(draft: AppealDraft): boolean
97
+ dispose(): void
98
+ }
99
+
100
+ export interface PanelOptions {
101
+ client: CommunityClient
102
+ lang: string
103
+ }
104
+
105
+ const appealDraft = (): AppealDraft => ({ body: '', sending: false, failure: null })
106
+
107
+ export function createPanel(o: PanelOptions): PanelController {
108
+ const { client } = o
109
+ const abort = new AbortController()
110
+ const call: CommunityCallOptions = { signal: abort.signal }
111
+ const state = reactive<PanelUi>({
112
+ phase: 'idle',
113
+ failure: null,
114
+ me: null,
115
+ notifications: [],
116
+ decisions: [],
117
+ appeals: [],
118
+ reports: [],
119
+ blocks: [],
120
+ statement: { phase: 'idle', value: null, failure: null, appeal: appealDraft() },
121
+ notice: { phase: 'idle', value: null, failure: null, appeal: appealDraft() },
122
+ profile: null,
123
+ purgeAfter: null,
124
+ exporting: false,
125
+ busy: {},
126
+ announcement: null,
127
+ announceId: 0,
128
+ }) as PanelUi
129
+ let disposed = false
130
+
131
+ const announce = (text: TextRef): void => {
132
+ state.announcement = text
133
+ state.announceId += 1
134
+ }
135
+ const failureRef = (f: CommunityFailure): TextRef => ({ key: `error.${f.code}` as TextRef['key'] })
136
+
137
+ async function loadMember(): Promise<void> {
138
+ state.phase = 'loading'
139
+ state.failure = null
140
+ const me = await client.me.get(call)
141
+ if (disposed) return
142
+ if (!me.ok) {
143
+ if (me.code === 'signIn') {
144
+ state.phase = 'signIn'
145
+ return
146
+ }
147
+ state.phase = 'failed'
148
+ state.failure = me
149
+ return
150
+ }
151
+ state.me = me.value
152
+ if (me.value.needs.includes('sign_in')) {
153
+ state.phase = 'signIn'
154
+ return
155
+ }
156
+ if (!me.value.member) {
157
+ // Signed in, never joined: nothing of theirs to list yet.
158
+ state.phase = 'ready'
159
+ return
160
+ }
161
+ const [notifications, decisions, appeals, reports, blocks] = await Promise.all([
162
+ client.me.notifications.list({}, call),
163
+ client.me.decisions.list({}, call),
164
+ client.me.appeals.list(call),
165
+ client.me.reports.list(call),
166
+ client.blocks.list(call),
167
+ ])
168
+ if (disposed) return
169
+ if (notifications.ok) state.notifications = notifications.value.items.filter((n) => isKnownNotificationCode(n.code))
170
+ if (decisions.ok) state.decisions = [...decisions.value.items]
171
+ if (appeals.ok) state.appeals = [...appeals.value.items]
172
+ if (reports.ok) state.reports = [...reports.value.items]
173
+ if (blocks.ok) state.blocks = [...blocks.value.blocks]
174
+ const first = [notifications, decisions, appeals, reports, blocks].find((r) => !r.ok)
175
+ if (first && !first.ok) announce(failureRef(first))
176
+ state.phase = 'ready'
177
+ }
178
+
179
+ // The statement the page shows now, and its load: a host that reuses the element for another decision-id
180
+ // (client-side navigation) must never see the late answer for the one before (nor appeal that one there).
181
+ let statementId: string | null = null
182
+ let statementLoad = 0
183
+
184
+ async function loadStatement(decisionId: string): Promise<void> {
185
+ const ticket = ++statementLoad
186
+ if (decisionId !== statementId) {
187
+ // Another decision: nothing of the one before stays on its page — its text, the appeal typed under it.
188
+ statementId = decisionId
189
+ state.statement.value = null
190
+ state.statement.appeal = appealDraft()
191
+ }
192
+ state.statement.phase = 'loading'
193
+ state.statement.failure = null
194
+ const r = await client.me.decisions.get({ decisionId }, call)
195
+ if (disposed || ticket !== statementLoad) return
196
+ if (r.ok) {
197
+ state.statement.value = r.value
198
+ state.statement.phase = 'ready'
199
+ return
200
+ }
201
+ state.statement.failure = r
202
+ state.statement.phase = r.code === 'signIn' ? 'signIn' : 'failed'
203
+ }
204
+
205
+ async function loadNotice(receipt: string | null): Promise<void> {
206
+ if (receipt === null) {
207
+ state.notice.phase = 'missing'
208
+ return
209
+ }
210
+ state.notice.phase = 'loading'
211
+ state.notice.failure = null
212
+ const r = await client.notices.status({ receipt }, call)
213
+ if (disposed) return
214
+ if (r.ok) {
215
+ state.notice.value = r.value
216
+ state.notice.phase = 'ready'
217
+ return
218
+ }
219
+ state.notice.failure = r
220
+ state.notice.phase = 'failed'
221
+ }
222
+ // The receipt is the notifier's capability: kept in this closure only, never in the state a view renders.
223
+ let noticeReceipt: string | null = null
224
+
225
+ const appealLength = (d: AppealDraft): number => codePointLength(normalizeBody(d.body))
226
+ const canAppealWith = (d: AppealDraft): boolean => !d.sending && appealLength(d) > 0 && appealLength(d) <= COMMUNITY_APPEAL_MAX
227
+
228
+ return {
229
+ state,
230
+ loadMember,
231
+ loadStatement,
232
+ async loadNotice(receipt) {
233
+ noticeReceipt = receipt
234
+ await loadNotice(receipt)
235
+ },
236
+ unreadCount: () => state.notifications.filter((n) => n.readAt === null).length,
237
+ async markAllRead() {
238
+ const ids = state.notifications.filter((n) => n.readAt === null).map((n) => n.id)
239
+ if (ids.length === 0 || state.busy.markRead) return
240
+ state.busy.markRead = true
241
+ const r = await client.me.notifications.markRead({ ids }, call)
242
+ delete state.busy.markRead
243
+ if (disposed) return
244
+ if (!r.ok) return announce(failureRef(r))
245
+ const at = new Date().toISOString()
246
+ state.notifications = state.notifications.map((n) => (ids.includes(n.id) ? { ...n, readAt: at } : n))
247
+ },
248
+ async fileAppeal() {
249
+ const s = state.statement
250
+ const d = s.appeal
251
+ if (!s.value || !s.value.canAppeal || !canAppealWith(d)) return
252
+ const decisionId = s.value.decisionId
253
+ d.sending = true
254
+ d.failure = null
255
+ const r = await client.me.appeals.file({ decisionId, body: normalizeBody(d.body) }, call)
256
+ d.sending = false
257
+ // The page moved on to another statement meanwhile: the answer is that one's no more.
258
+ if (disposed || decisionId !== statementId) return
259
+ if (!r.ok) {
260
+ d.failure = r
261
+ return
262
+ }
263
+ d.body = ''
264
+ announce({ key: 'statement.appealSent' })
265
+ await loadStatement(decisionId)
266
+ },
267
+ async withdrawAppeal() {
268
+ const s = state.statement
269
+ const open = s.value?.appeal
270
+ if (!s.value || !open || open.status !== 'open' || state.busy.withdraw) return
271
+ const decisionId = s.value.decisionId
272
+ state.busy.withdraw = true
273
+ const r = await client.me.appeals.withdraw({ appealId: open.appealId }, call)
274
+ delete state.busy.withdraw
275
+ if (disposed || decisionId !== statementId) return
276
+ if (!r.ok) return announce(failureRef(r))
277
+ announce({ key: 'statement.appealWithdrawn' })
278
+ await loadStatement(decisionId)
279
+ },
280
+ async fileNoticeAppeal() {
281
+ const n = state.notice
282
+ const d = n.appeal
283
+ if (!n.value || !n.value.appeal.canAppeal || noticeReceipt === null || !canAppealWith(d)) return
284
+ d.sending = true
285
+ d.failure = null
286
+ const r = await client.notices.appeal({ receipt: noticeReceipt, body: normalizeBody(d.body) }, call)
287
+ d.sending = false
288
+ if (disposed) return
289
+ if (!r.ok) {
290
+ d.failure = r
291
+ return
292
+ }
293
+ d.body = ''
294
+ announce({ key: 'notice.appealSent' })
295
+ await loadNotice(noticeReceipt)
296
+ },
297
+ async withdrawNoticeAppeal() {
298
+ if (!state.notice.value?.appeal.open || noticeReceipt === null || state.busy.noticeWithdraw) return
299
+ state.busy.noticeWithdraw = true
300
+ const r = await client.notices.withdrawAppeal({ receipt: noticeReceipt }, call)
301
+ delete state.busy.noticeWithdraw
302
+ if (disposed) return
303
+ if (!r.ok) return announce(failureRef(r))
304
+ announce({ key: 'statement.appealWithdrawn' })
305
+ await loadNotice(noticeReceipt)
306
+ },
307
+ async withdrawConsent() {
308
+ if (state.busy.consent) return false
309
+ state.busy.consent = true
310
+ const r = await client.me.consents.withdraw({ kind: 'art9_consent' }, call)
311
+ delete state.busy.consent
312
+ if (disposed) return false
313
+ if (!r.ok) {
314
+ announce(failureRef(r))
315
+ return false
316
+ }
317
+ if (state.me) state.me = { ...state.me, acceptances: { ...state.me.acceptances, art9Consent: r.value.state as MyState['acceptances']['rules'] } }
318
+ announce({ key: 'panel.art9Withdrawn' })
319
+ return true
320
+ },
321
+ async unblock(memberId) {
322
+ const key = `block:${memberId}`
323
+ if (state.busy[key]) return
324
+ state.busy[key] = true
325
+ const r = await client.blocks.set({ memberId, blocked: false }, call)
326
+ delete state.busy[key]
327
+ if (disposed) return
328
+ if (!r.ok) return announce(failureRef(r))
329
+ state.blocks = state.blocks.filter((b) => b.memberId !== memberId)
330
+ announce({ key: 'discussion.unblocked' })
331
+ },
332
+ editProfile() {
333
+ const p: OwnProfile | null | undefined = state.me?.profile
334
+ if (!p) return
335
+ state.profile = { displayName: p.displayName, handle: p.handle, avatarPreset: p.avatarPreset, sending: false, failure: null, fields: [] }
336
+ },
337
+ cancelProfile() {
338
+ state.profile = null
339
+ },
340
+ async saveProfile() {
341
+ const d = state.profile
342
+ const current = state.me?.profile
343
+ if (!d || !current || d.sending || !isDisplayNameOk(d.displayName) || !isHandleOk(d.handle) || normalizeHandle(d.handle) === '') return
344
+ const input: { displayName?: string; handle?: string; avatarPreset?: string | null } = {}
345
+ const name = normalizeName(d.displayName)
346
+ const handle = normalizeHandle(d.handle)
347
+ if (name !== current.displayName) input.displayName = name
348
+ if (handle !== current.handle) input.handle = handle
349
+ if (d.avatarPreset !== current.avatarPreset) input.avatarPreset = d.avatarPreset
350
+ if (Object.keys(input).length === 0) {
351
+ state.profile = null
352
+ return
353
+ }
354
+ d.sending = true
355
+ d.failure = null
356
+ d.fields = []
357
+ const r = await client.me.updateProfile(input, call)
358
+ d.sending = false
359
+ if (disposed) return
360
+ if (!r.ok) {
361
+ d.failure = r
362
+ d.fields = r.code === 'handle' ? ['handle'] : r.code === 'name' ? ['displayName'] : (r.fields ?? [])
363
+ return
364
+ }
365
+ // The form closes on the new values (the catalogue has no sentence for a saved profile).
366
+ if (state.me) state.me = { ...state.me, profile: r.value.profile }
367
+ state.profile = null
368
+ },
369
+ async exportData(save) {
370
+ if (state.exporting) return
371
+ state.exporting = true
372
+ const r = await client.me.export(call)
373
+ state.exporting = false
374
+ if (disposed) return
375
+ if (!r.ok) return announce(failureRef(r))
376
+ save(r.value)
377
+ },
378
+ async erase() {
379
+ if (state.busy.erase) return false
380
+ state.busy.erase = true
381
+ const r = await client.me.erase(call)
382
+ delete state.busy.erase
383
+ if (disposed) return false
384
+ if (!r.ok) {
385
+ announce(failureRef(r))
386
+ return false
387
+ }
388
+ state.purgeAfter = r.value.purgeAfter
389
+ if (state.me?.member) state.me = { ...state.me, member: { ...state.me.member, status: r.value.status } }
390
+ return true
391
+ },
392
+ async cancelErase() {
393
+ if (state.busy.erase) return
394
+ state.busy.erase = true
395
+ const r = await client.me.cancelErase(call)
396
+ delete state.busy.erase
397
+ if (disposed) return
398
+ if (!r.ok) return announce(failureRef(r))
399
+ state.purgeAfter = null
400
+ if (state.me?.member) state.me = { ...state.me, member: { ...state.me.member, status: r.value.status } }
401
+ announce({ key: 'panel.eraseCancelled' })
402
+ },
403
+ erasing: () => state.me?.member?.status === 'deletion_requested',
404
+ appealLength,
405
+ canAppealWith,
406
+ dispose() {
407
+ disposed = true
408
+ abort.abort()
409
+ },
410
+ }
411
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A notifier's receipt as `<blogmcp-my-community>` takes it (m1-contracts.md §7.9, §10.2; community.md invariant 8):
3
+ * from its OWN page's address only — `#t=` in `location.hash` when it mounts —, and the address bar cleared at once
4
+ * (the SDK's readReceiptFromHash, which also clears the router's copy in `history.state`).
5
+ *
6
+ * That reader remembers the receipt for the whole window too (so a framework that mounts a page twice still gets
7
+ * it), and hands it back with no fragment in the address. An element never takes that for its page's: a later page
8
+ * of the same tab — a client-side navigation, the next person at a shared device — would ask for the notifier's
9
+ * case again and offer its appeal. What an element took it keeps for itself (the same element put back by its host
10
+ * shows it again); no other element sees it.
11
+ */
12
+ import { readReceiptFromHash, receiptFromHash } from '@blogmcp/sdk/community'
13
+
14
+ const taken = new WeakMap<object, string | null>()
15
+
16
+ /**
17
+ * The receipt this element's page carries: a string; `null` for a `#t=` that holds none (a cut link — "incomplete",
18
+ * never an older receipt of the tab); `undefined` when the page carries none for it.
19
+ */
20
+ export function receiptForElement(owner: object | null): string | null | undefined {
21
+ if (typeof location !== 'undefined' && location.hash.startsWith('#t=')) {
22
+ const receipt = receiptFromHash(location.hash)
23
+ readReceiptFromHash()
24
+ if (owner) taken.set(owner, receipt)
25
+ return receipt
26
+ }
27
+ return owner && taken.has(owner) ? (taken.get(owner) ?? null) : undefined
28
+ }
@@ -0,0 +1,142 @@
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
+
9
+ export type TextSegment =
10
+ | { readonly kind: 'text'; readonly text: string }
11
+ | { readonly kind: 'link'; readonly text: string; readonly href: string }
12
+
13
+ /** The rel every link to what a member wrote carries (user-generated, no referrer, no opener). */
14
+ export const UGC_LINK_REL = 'nofollow ugc noopener noreferrer'
15
+
16
+ /** A web address as people write it: `http(s)://` or `www.`, up to a blank or a character no address carries. */
17
+ const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`{}|\\^]+/gi
18
+ /** Characters that end a sentence more often than an address. */
19
+ const TRAILING = '.,;:!?…\'"»”’]'
20
+
21
+ /** The address without the punctuation after it; a `)` stays when it closes a `(` inside the address. */
22
+ function trimUrl(raw: string): string {
23
+ let url = raw
24
+ while (url.length > 0) {
25
+ const last = url[url.length - 1]!
26
+ if (last === ')') {
27
+ const opens = url.split('(').length - 1
28
+ const closes = url.split(')').length - 1
29
+ if (closes <= opens) break
30
+ } else if (!TRAILING.includes(last)) {
31
+ break
32
+ }
33
+ url = url.slice(0, -1)
34
+ }
35
+ return url
36
+ }
37
+
38
+ /** The link target of an address, or null: only http and https survive sanitizeUrl as a link. */
39
+ function safeHref(url: string): string | null {
40
+ const candidate = /^www\./i.test(url) ? `https://${url}` : url
41
+ // A bare scheme is no address.
42
+ if (/^https?:\/\/$/i.test(candidate)) return null
43
+ const safe = sanitizeUrl(candidate)
44
+ return safe !== 'about:blank' && /^https?:\/\//i.test(safe) ? safe : null
45
+ }
46
+
47
+ /** A member's text as text runs and links. */
48
+ export function linkify(text: string): TextSegment[] {
49
+ const out: TextSegment[] = []
50
+ if (typeof text !== 'string' || text === '') return out
51
+ let at = 0
52
+ for (const m of text.matchAll(URL_RE)) {
53
+ const start = m.index ?? 0
54
+ if (start < at) continue
55
+ const url = trimUrl(m[0])
56
+ const href = url ? safeHref(url) : null
57
+ if (!href) continue
58
+ if (start > at) out.push({ kind: 'text', text: text.slice(at, start) })
59
+ out.push({ kind: 'link', text: url, href })
60
+ at = start + url.length
61
+ }
62
+ if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
63
+ return out
64
+ }
65
+
66
+ /** Characters in code points, as kb-api counts a body (after NFC and trimming). */
67
+ export function codePointLength(text: string): number {
68
+ let n = 0
69
+ for (const _ of text) n++
70
+ return n
71
+ }
72
+
73
+ /** A body as kb-api will measure it: NFC, trimmed. */
74
+ export function normalizeBody(text: string): string {
75
+ return typeof text === 'string' ? text.normalize('NFC').trim() : ''
76
+ }
77
+
78
+ const ENTITIES: Readonly<Record<string, string>> = Object.freeze({
79
+ amp: '&',
80
+ lt: '<',
81
+ gt: '>',
82
+ quot: '"',
83
+ apos: "'",
84
+ nbsp: ' ',
85
+ ndash: '–',
86
+ mdash: '—',
87
+ hellip: '…',
88
+ laquo: '«',
89
+ raquo: '»',
90
+ bdquo: '„',
91
+ ldquo: '“',
92
+ rdquo: '”',
93
+ lsquo: '‘',
94
+ rsquo: '’',
95
+ sbquo: '‚',
96
+ copy: '©',
97
+ reg: '®',
98
+ euro: '€',
99
+ shy: '',
100
+ })
101
+
102
+ function decodeEntities(text: string): string {
103
+ return text.replace(/&(#\d{1,7}|#x[0-9a-f]{1,6}|[a-z]{2,8});/gi, (match, entity: string) => {
104
+ if (entity[0] === '#') {
105
+ const hex = entity[1] === 'x' || entity[1] === 'X'
106
+ const cp = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10)
107
+ return cp > 0 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) ? String.fromCodePoint(cp) : match
108
+ }
109
+ return ENTITIES[entity.toLowerCase()] ?? match
110
+ })
111
+ }
112
+
113
+ /** Elements whose end closes a paragraph of the text. */
114
+ 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
115
+ /** Elements whose start opens one. */
116
+ const BLOCK_START = /<(?:li|tr|dt|dd|h[1-6]|blockquote|pre|figure)\b[^>]*>/gi
117
+
118
+ /**
119
+ * The paragraphs of the tenant's own legal text (`legal/current`'s `body` is HTML with anchors) as plain
120
+ * text, for the art. 9 consent step: tags are dropped and entities decoded AFTER that, so nothing in the
121
+ * text is ever parsed as markup again. String work only — no HTML parser, no live DOM.
122
+ */
123
+ export function htmlToParagraphs(html: string): string[] {
124
+ if (typeof html !== 'string' || html === '') return []
125
+ const text = html
126
+ .replace(/<!--[\s\S]*?-->/g, '')
127
+ .replace(/<(script|style|template|noscript|svg|math)\b[\s\S]*?<\/\1\s*>/gi, '')
128
+ .replace(/[\t\n\r\f ]+/g, ' ')
129
+ .replace(/<br\b[^>]*>/gi, '\n')
130
+ .replace(BLOCK_END, '\n\n')
131
+ .replace(BLOCK_START, '\n\n')
132
+ .replace(/<[^>]*>/g, '')
133
+ return decodeEntities(text)
134
+ .split(/\n{2,}/)
135
+ .map((p) =>
136
+ p
137
+ .replace(/ *\n */g, '\n')
138
+ .replace(/[  ]{2,}/g, ' ')
139
+ .trim(),
140
+ )
141
+ .filter((p) => p !== '')
142
+ }
@@ -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
+ }