@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,21 @@
1
+ /** The member's export as a file the browser saves (§8.3: the download is the SDK's). Nothing leaves the page. */
2
+ export function saveJson(data: unknown, fileName: string): void {
3
+ if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function') return
4
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
5
+ const url = URL.createObjectURL(blob)
6
+ const a = document.createElement('a')
7
+ a.href = url
8
+ a.download = fileName
9
+ a.style.display = 'none'
10
+ document.body.appendChild(a)
11
+ a.click()
12
+ a.remove()
13
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
14
+ }
15
+
16
+ /** `<name>-YYYY-MM-DD.json`, the name being the catalogue's (`panel.exportFileName`). */
17
+ export function exportFileName(name: string, now: Date = new Date()): string {
18
+ const day = now.toISOString().slice(0, 10)
19
+ const safe = name.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, '') || 'export'
20
+ return `${safe}-${day}.json`
21
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Dates and a statement's territorial scope as the reader's language writes them (Intl does the words; the
3
+ * catalogue holds the sentence around them).
4
+ */
5
+ import { translator, type Lang } from './i18n'
6
+
7
+ /** The Intl locale a language reads. */
8
+ const localeOf = (lang: Lang): string => (lang === 'cs' ? 'cs-CZ' : 'en-GB')
9
+
10
+ const formats = new Map<string, Intl.DateTimeFormat | null>()
11
+
12
+ function format(lang: Lang, withTime: boolean): Intl.DateTimeFormat | null {
13
+ const key = `${lang}:${withTime ? 't' : 'd'}`
14
+ if (!formats.has(key)) {
15
+ let f: Intl.DateTimeFormat | null = null
16
+ try {
17
+ f = new Intl.DateTimeFormat(localeOf(lang), withTime ? { dateStyle: 'medium', timeStyle: 'short' } : { dateStyle: 'long' })
18
+ } catch {
19
+ // A runtime without Intl: the ISO text stays.
20
+ }
21
+ formats.set(key, f)
22
+ }
23
+ return formats.get(key) ?? null
24
+ }
25
+
26
+ /** An ISO timestamp as a date (and time); '' for anything that is no date. */
27
+ export function formatDate(lang: Lang, iso: string | null | undefined, withTime = true): string {
28
+ if (typeof iso !== 'string' || iso === '') return ''
29
+ const at = Date.parse(iso)
30
+ if (!Number.isFinite(at)) return ''
31
+ return format(lang, withTime)?.format(new Date(at)) ?? iso
32
+ }
33
+
34
+ /** Whether an ISO timestamp lies in the past (an appeal window, a sanction's end). */
35
+ export function isPast(iso: string | null | undefined, now: number = Date.now()): boolean {
36
+ if (typeof iso !== 'string') return false
37
+ const at = Date.parse(iso)
38
+ return Number.isFinite(at) && at <= now
39
+ }
40
+
41
+ /** `trust.decisions.territorial_scope`'s other shape: ISO 3166-1 alpha-2 codes, comma-separated (its CHECK). */
42
+ const REGION_LIST = /^[A-Z]{2}(?:,[A-Z]{2})*$/
43
+
44
+ const regionNames = new Map<Lang, Intl.DisplayNames | null>()
45
+ const regionLists = new Map<Lang, Intl.ListFormat | null>()
46
+
47
+ function cached<T>(cache: Map<Lang, T | null>, lang: Lang, make: (locale: string) => T): T | null {
48
+ if (!cache.has(lang)) {
49
+ let value: T | null = null
50
+ try {
51
+ value = make(localeOf(lang))
52
+ } catch {
53
+ // A runtime without this part of Intl: the codes stay.
54
+ }
55
+ cache.set(lang, value)
56
+ }
57
+ return cache.get(lang) ?? null
58
+ }
59
+
60
+ /** A region's name in the language (`CZ` → „Česko"), or the code when Intl has none. */
61
+ function regionName(lang: Lang, code: string): string {
62
+ try {
63
+ return cached(regionNames, lang, (locale) => new Intl.DisplayNames(locale, { type: 'region' }))?.of(code) || code
64
+ } catch {
65
+ return code
66
+ }
67
+ }
68
+
69
+ /**
70
+ * A statement's territorial scope (DSA art. 17(3)(a)) as the reader reads it. kb-api stores `all` — the measure
71
+ * applies without a territorial limit, the catalogue's `statement.scopeAll` — or the regions it is limited to as
72
+ * ISO 3166-1 alpha-2 codes (`CZ,SK`), which Intl names and joins („Česko a Slovensko"; a runtime without Intl reads
73
+ * `CZ, SK`). Any other value — one a newer kb-api sends — is shown as it came, never dropped and never an
74
+ * exception; '' when there is none.
75
+ */
76
+ export function formatTerritorialScope(lang: Lang, scope: string | null | undefined): string {
77
+ if (typeof scope !== 'string') return ''
78
+ const value = scope.trim()
79
+ if (value === 'all') {
80
+ const t = translator(lang)
81
+ return t('statement.scopeAll')
82
+ }
83
+ if (!REGION_LIST.test(value)) return value
84
+ const names = value.split(',').map((code) => regionName(lang, code))
85
+ try {
86
+ return cached(regionLists, lang, (locale) => new Intl.ListFormat(locale, { type: 'conjunction' }))?.format(names) ?? names.join(', ')
87
+ } catch {
88
+ return names.join(', ')
89
+ }
90
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The elements' language and their texts (m1-contracts.md §10.4): every string a reader sees comes from
3
+ * the SDK's catalogue, `communityMessage` — one catalogue, one tone test. The elements add no words of
4
+ * their own; test/text-only.spec.ts scans src/ for any.
5
+ */
6
+ import { communityMessage, hasCommunityMessage, type CommunityMessageKey } from '@blogmcp/sdk/community'
7
+
8
+ export type Lang = 'cs' | 'en'
9
+
10
+ /**
11
+ * The review layer's rule: the first candidate that is Czech or Slovak reads Czech, the first that is
12
+ * English reads English; anything else is passed over; English when none decides. The candidates are
13
+ * the `lang` attribute, then `<html lang>`.
14
+ */
15
+ export function pickLang(...candidates: Array<string | null | undefined>): Lang {
16
+ for (const c of candidates) {
17
+ if (!c) continue
18
+ const l = c.toLowerCase()
19
+ if (l.startsWith('cs') || l.startsWith('sk')) return 'cs'
20
+ if (l.startsWith('en')) return 'en'
21
+ }
22
+ return 'en'
23
+ }
24
+
25
+ /** The page's own language attribute, when there is a page (the elements mount in a browser only). */
26
+ export function documentLang(): string | null {
27
+ return typeof document === 'undefined' ? null : document.documentElement.lang || null
28
+ }
29
+
30
+ export type TextParams = Record<string, string | number>
31
+ export type Translate = (key: CommunityMessageKey, params?: TextParams) => string
32
+
33
+ export function translator(lang: Lang): Translate {
34
+ return (key, params) => communityMessage(lang, key, params)
35
+ }
36
+
37
+ /**
38
+ * A key built from one of kb-api's open codes (`restriction.<code>`, `caseStatus.<status>`, …) when the
39
+ * catalogue has a text for it, else null: a code a newer kb-api sends is skipped, never shown raw.
40
+ */
41
+ export function knownKey(key: string): CommunityMessageKey | null {
42
+ return hasCommunityMessage(key) ? key : null
43
+ }
44
+
45
+ /** A message key and its params: what a controller hands the view, which turns it into text. */
46
+ export interface TextRef {
47
+ readonly key: CommunityMessageKey
48
+ readonly params?: TextParams
49
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Names in the onboarding and the profile form, checked the way kb-api checks them before a request goes
3
+ * (m1-contracts.md §7.4.3): the display name NFKC-folded, 2–80 characters; a handle `COMMUNITY_HANDLE_RE`.
4
+ * kb-api decides the rest (reserved words, look-alikes, clashes) and the element shows its answer.
5
+ */
6
+ import { COMMUNITY_DISPLAY_NAME_MAX, COMMUNITY_DISPLAY_NAME_MIN, COMMUNITY_HANDLE_RE } from '@blogmcp/sdk/community'
7
+ import { codePointLength } from './text'
8
+
9
+ /** The display name as kb-api will fold it: NFKC, white space collapsed, trimmed. */
10
+ export function normalizeName(name: string): string {
11
+ return typeof name === 'string' ? name.normalize('NFKC').replace(/\s+/gu, ' ').trim() : ''
12
+ }
13
+
14
+ export function isDisplayNameOk(name: string): boolean {
15
+ const n = codePointLength(normalizeName(name))
16
+ return n >= COMMUNITY_DISPLAY_NAME_MIN && n <= COMMUNITY_DISPLAY_NAME_MAX
17
+ }
18
+
19
+ /** A handle's length as COMMUNITY_HANDLE_RE takes it (3–30), for the hint and the field's maxlength. */
20
+ export const HANDLE_MIN = 3
21
+ export const HANDLE_MAX = 30
22
+
23
+ /** A handle as typed: lower case, trimmed; '' when none was typed. */
24
+ export function normalizeHandle(handle: string): string {
25
+ return typeof handle === 'string' ? handle.trim().toLowerCase() : ''
26
+ }
27
+
28
+ export function isHandleOk(handle: string): boolean {
29
+ const h = normalizeHandle(handle)
30
+ return h === '' || COMMUNITY_HANDLE_RE.test(h)
31
+ }
32
+
33
+ /** Letters NFD does not take apart, as kb-api's stem maps them. */
34
+ const ASCII: Readonly<Record<string, string>> = Object.freeze({ ß: 'ss', ł: 'l', đ: 'd', ð: 'd', ø: 'o', ħ: 'h', ŧ: 't', ı: 'i', æ: 'ae', œ: 'oe', þ: 'th', ŀ: 'l' })
35
+
36
+ /**
37
+ * The handle kb-api would make of a display name when the member gives none (its stem: fold → ASCII →
38
+ * words joined by `_`, at most 24 characters) — shown as a suggestion; null when the name gives too little.
39
+ * kb-api may still add a number on a clash, or leave out a reserved word.
40
+ */
41
+ export function suggestedHandle(displayName: string): string | null {
42
+ const folded = normalizeName(displayName).toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '').normalize('NFC')
43
+ const ascii = folded.replace(/[ßłđðøħŧıæœþŀ]/g, (ch) => ASCII[ch] ?? ch)
44
+ const stem = ascii
45
+ .split(/[^a-z0-9]+/)
46
+ .filter((p) => p !== '')
47
+ .join('_')
48
+ .slice(0, 24)
49
+ .replace(/_+$/, '')
50
+ return COMMUNITY_HANDLE_RE.test(stem) ? stem : null
51
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `<blogmcp-notice-form>`'s brain (m1-contracts.md §7.7, §10.4; community.md §5; DSA art. 16): the notice
3
+ * anyone may send about a message, a profile or an article — with or without a session. The category, the
4
+ * description, the optional name and e-mail (hidden and never sent for `child_at_risk`: kb-api refuses a
5
+ * contact there, NOTIFIER_NOT_ALLOWED), the Turnstile token, and the good-faith statement of art. 16(2)(d)
6
+ * (`confirmations: ['good_faith']`, §7.14 I-2). The answer is the notifier's receipt: the status link carries
7
+ * it in its fragment only.
8
+ */
9
+ import { reactive } from 'vue'
10
+ import {
11
+ CATEGORY_WITHOUT_NOTIFIER,
12
+ COMMUNITY_GOOD_FAITH,
13
+ COMMUNITY_NOTICE_DESCRIPTION_MAX,
14
+ COMMUNITY_NOTIFIER_NAME_MAX,
15
+ communityRulesOf,
16
+ noticeStatusLink,
17
+ type CaseCategory,
18
+ type CommunityCallOptions,
19
+ type CommunityClient,
20
+ type CommunityFailure,
21
+ type CommunityRule,
22
+ type NoticeReceipt,
23
+ type NoticeSubmitInput,
24
+ type NoticeTarget,
25
+ } from '@blogmcp/sdk/community'
26
+ import type { Lang } from './i18n'
27
+ import { codePointLength, normalizeBody } from './text'
28
+
29
+ /** `off`: no site key (development, a host without the widget); kb-api then decides alone. */
30
+ export type CaptchaState = 'off' | 'loading' | 'ready' | 'unavailable'
31
+
32
+ export interface NoticeFormUi {
33
+ target: NoticeTarget | null
34
+ category: CaseCategory | ''
35
+ ruleId: string
36
+ rules: CommunityRule[] | null
37
+ description: string
38
+ name: string
39
+ email: string
40
+ goodFaith: boolean
41
+ captcha: CaptchaState
42
+ /** The widget's one-shot token, or null. */
43
+ token: string | null
44
+ /** Bumped after every failed send: the widget issues a new token. */
45
+ resetSignal: number
46
+ sending: boolean
47
+ failure: CommunityFailure | null
48
+ receipt: NoticeReceipt | null
49
+ /** The notifier's status page with the receipt in the fragment. */
50
+ statusLink: string | null
51
+ }
52
+
53
+ export interface NoticeFormController {
54
+ readonly state: NoticeFormUi
55
+ /** The name and e-mail fields apply (not for an endangered child). */
56
+ contactAllowed(): boolean
57
+ canSubmit(): boolean
58
+ descriptionLength(): number
59
+ setCategory(category: CaseCategory | ''): void
60
+ setToken(token: string | null): void
61
+ setCaptcha(state: CaptchaState): void
62
+ submit(): Promise<void>
63
+ dispose(): void
64
+ }
65
+
66
+ export interface NoticeFormOptions {
67
+ client: Pick<CommunityClient, 'notices' | 'legal'>
68
+ lang: Lang
69
+ target: NoticeTarget | null
70
+ /** A Turnstile site key was given: a token is needed before the form can be sent. */
71
+ captcha: boolean
72
+ /** The host's status page (the receipt goes in its fragment). */
73
+ noticesPath: string
74
+ /** The form was sent (the receipt is the page's: it is not passed on). */
75
+ onSubmitted?(answer: { contactKept: boolean }): void
76
+ }
77
+
78
+ export function createNoticeForm(o: NoticeFormOptions): NoticeFormController {
79
+ const abort = new AbortController()
80
+ const call: CommunityCallOptions = { signal: abort.signal }
81
+ const state = reactive<NoticeFormUi>({
82
+ target: o.target,
83
+ category: '',
84
+ ruleId: '',
85
+ rules: null,
86
+ description: '',
87
+ name: '',
88
+ email: '',
89
+ goodFaith: false,
90
+ captcha: o.captcha ? 'loading' : 'off',
91
+ token: null,
92
+ resetSignal: 0,
93
+ sending: false,
94
+ failure: null,
95
+ receipt: null,
96
+ statusLink: null,
97
+ }) as NoticeFormUi
98
+ let rulesAsked = false
99
+
100
+ const contactAllowed = (): boolean => state.category !== CATEGORY_WITHOUT_NOTIFIER
101
+ const descriptionLength = (): number => codePointLength(normalizeBody(state.description))
102
+
103
+ function canSubmit(): boolean {
104
+ const length = descriptionLength()
105
+ return (
106
+ !state.sending &&
107
+ state.receipt === null &&
108
+ state.target !== null &&
109
+ state.category !== '' &&
110
+ length > 0 &&
111
+ length <= COMMUNITY_NOTICE_DESCRIPTION_MAX &&
112
+ codePointLength(state.name.trim()) <= COMMUNITY_NOTIFIER_NAME_MAX &&
113
+ state.goodFaith &&
114
+ (state.captcha === 'off' || (state.captcha === 'ready' && state.token !== null))
115
+ )
116
+ }
117
+
118
+ function loadRules(): void {
119
+ if (rulesAsked) return
120
+ rulesAsked = true
121
+ void o.client.legal.current({ kind: 'rules', locale: o.lang }, call).then((r) => {
122
+ state.rules = r.ok ? communityRulesOf(r.value.bodyJson) : []
123
+ })
124
+ }
125
+
126
+ async function submit(): Promise<void> {
127
+ if (!canSubmit() || !state.target || state.category === '') return
128
+ state.sending = true
129
+ state.failure = null
130
+ const input: NoticeSubmitInput = {
131
+ category: state.category,
132
+ target: state.target,
133
+ description: normalizeBody(state.description),
134
+ confirmations: [COMMUNITY_GOOD_FAITH],
135
+ }
136
+ if (state.category === 'rules' && state.ruleId !== '') input.ruleId = state.ruleId
137
+ // An endangered child is reported without the notifier's name and e-mail: not even sent.
138
+ const name = state.name.trim()
139
+ const email = state.email.trim()
140
+ if (contactAllowed() && (name !== '' || email !== '')) input.notifier = { name: name || null, email: email || null, locale: o.lang }
141
+ if (state.token !== null) input.turnstileToken = state.token
142
+ // The token is spent by this request whatever its answer.
143
+ state.token = null
144
+ const r = await o.client.notices.submit(input, call)
145
+ state.sending = false
146
+ if (r.ok) {
147
+ state.receipt = r.value
148
+ state.statusLink = noticeStatusLink(o.noticesPath, r.value.receipt)
149
+ o.onSubmitted?.({ contactKept: r.value.contactKept })
150
+ return
151
+ }
152
+ state.failure = r
153
+ if (state.captcha !== 'off') state.resetSignal += 1
154
+ }
155
+
156
+ return {
157
+ state,
158
+ contactAllowed,
159
+ canSubmit,
160
+ descriptionLength,
161
+ setCategory(category) {
162
+ state.category = category
163
+ if (category !== 'rules') state.ruleId = ''
164
+ else loadRules()
165
+ },
166
+ setToken(token) {
167
+ state.token = token
168
+ if (token !== null && state.captcha === 'loading') state.captcha = 'ready'
169
+ },
170
+ setCaptcha(captcha) {
171
+ if (state.captcha !== 'off') state.captcha = captcha
172
+ },
173
+ submit,
174
+ dispose() {
175
+ abort.abort()
176
+ },
177
+ }
178
+ }