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