@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.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/dist/blogmcp-community.global.js +10 -0
- package/dist/chunks/mount-DzjwnHyx.js +4597 -0
- package/dist/core/attrs.d.ts +73 -0
- package/dist/core/avatars.d.ts +16 -0
- package/dist/core/client.d.ts +27 -0
- package/dist/core/discussion.d.ts +144 -0
- package/dist/core/download.d.ts +4 -0
- package/dist/core/format.d.ts +17 -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 +52 -0
- package/dist/core/outcomes.d.ts +28 -0
- package/dist/core/panel.d.ts +80 -0
- package/dist/core/receipt.d.ts +5 -0
- package/dist/core/text.d.ts +22 -0
- package/dist/core/turnstile.d.ts +33 -0
- package/dist/element.d.ts +26 -0
- package/dist/global.d.ts +8 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +22 -0
- package/dist/mount.d.ts +65 -0
- package/dist/react.d.ts +17 -0
- package/dist/react.js +35 -0
- package/dist/ui/AuthorAvatar.vue.d.ts +11 -0
- package/dist/ui/ConfirmDialog.vue.d.ts +20 -0
- package/dist/ui/DiscussionView.vue.d.ts +33 -0
- package/dist/ui/LinkedText.vue.d.ts +5 -0
- package/dist/ui/MessageComposer.vue.d.ts +24 -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/OnboardingSteps.vue.d.ts +11 -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/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 +89 -0
- package/dist/vue.js +97 -0
- package/package.json +80 -4
- package/src/core/attrs.ts +147 -0
- package/src/core/avatars.ts +44 -0
- package/src/core/client.ts +54 -0
- package/src/core/discussion.ts +691 -0
- package/src/core/download.ts +21 -0
- package/src/core/format.ts +90 -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 +345 -0
- package/src/core/outcomes.ts +74 -0
- package/src/core/panel.ts +411 -0
- package/src/core/receipt.ts +28 -0
- package/src/core/text.ts +142 -0
- package/src/core/turnstile.ts +98 -0
- package/src/element.ts +109 -0
- package/src/global.ts +12 -0
- package/src/index.ts +35 -0
- package/src/mount.ts +127 -0
- package/src/react.ts +53 -0
- package/src/shims.d.ts +11 -0
- package/src/ui/AuthorAvatar.vue +26 -0
- package/src/ui/ConfirmDialog.vue +38 -0
- package/src/ui/DiscussionView.vue +294 -0
- package/src/ui/LinkedText.vue +18 -0
- package/src/ui/MessageComposer.vue +130 -0
- package/src/ui/MessageItem.vue +205 -0
- package/src/ui/MessageMenu.vue +67 -0
- package/src/ui/MyCommunityView.vue +369 -0
- package/src/ui/NoticeFormView.vue +244 -0
- package/src/ui/NoticeStatusView.vue +82 -0
- package/src/ui/OnboardingSteps.vue +163 -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/context.ts +75 -0
- package/src/ui/discussion-context.ts +30 -0
- package/src/ui/styles.css +243 -0
- package/src/ui/types.ts +7 -0
- package/src/version.ts +3 -0
- package/src/vue.ts +116 -0
package/dist/vue.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
},
|
|
44
|
+
setup(props) {
|
|
45
|
+
return useMount(
|
|
46
|
+
"discussion",
|
|
47
|
+
() => defined({ ...props }),
|
|
48
|
+
async () => (await import("./chunks/mount-DzjwnHyx.js").then((n) => n.l)).mountDiscussion
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const KbNoticeForm = defineComponent({
|
|
53
|
+
name: "KbNoticeForm",
|
|
54
|
+
props: {
|
|
55
|
+
target: Object,
|
|
56
|
+
articleId: String,
|
|
57
|
+
apiBase: String,
|
|
58
|
+
siteBase: String,
|
|
59
|
+
lang: String,
|
|
60
|
+
turnstileSiteKey: String,
|
|
61
|
+
routes: Object,
|
|
62
|
+
nonce: String,
|
|
63
|
+
transport: Function
|
|
64
|
+
},
|
|
65
|
+
setup(props) {
|
|
66
|
+
return useMount(
|
|
67
|
+
"notice-form",
|
|
68
|
+
() => defined({ ...props }),
|
|
69
|
+
async () => (await import("./chunks/mount-DzjwnHyx.js").then((n) => n.l)).mountNoticeForm
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
const KbMyCommunity = defineComponent({
|
|
74
|
+
name: "KbMyCommunity",
|
|
75
|
+
props: {
|
|
76
|
+
decisionId: String,
|
|
77
|
+
apiBase: String,
|
|
78
|
+
siteBase: String,
|
|
79
|
+
lang: String,
|
|
80
|
+
signInHref: String,
|
|
81
|
+
routes: Object,
|
|
82
|
+
nonce: String,
|
|
83
|
+
transport: Function
|
|
84
|
+
},
|
|
85
|
+
setup(props) {
|
|
86
|
+
return useMount(
|
|
87
|
+
"my-community",
|
|
88
|
+
() => defined({ ...props }),
|
|
89
|
+
async () => (await import("./chunks/mount-DzjwnHyx.js").then((n) => n.l)).mountMyCommunity
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
export {
|
|
94
|
+
KbDiscussion,
|
|
95
|
+
KbMyCommunity,
|
|
96
|
+
KbNoticeForm
|
|
97
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,82 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blogmcp/community",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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.9.0",
|
|
46
|
+
"@blogmcp/sdk": "^0.9.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,147 @@
|
|
|
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
|
+
|
|
36
|
+
const ROUTE_KEYS = ['statementPath', 'myCommunityPath', 'rulesPath', 'reportPath', 'noticesPath', 'consultationPath'] as const
|
|
37
|
+
|
|
38
|
+
/** A path on the host's own site (the SDK's links.ts rule). */
|
|
39
|
+
export function isSitePath(path: unknown): path is string {
|
|
40
|
+
return typeof path === 'string' && /^\/(?![/\\])/.test(path) && !/[\s\\\u0000-\u001f\u007f]/.test(path)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Rec = Record<string, unknown>
|
|
44
|
+
const isRecord = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
45
|
+
|
|
46
|
+
/** A JSON attribute's value (a string), or the object a framework set as the property. */
|
|
47
|
+
function jsonOf(value: unknown): unknown {
|
|
48
|
+
if (typeof value !== 'string') return value
|
|
49
|
+
if (value.trim() === '') return null
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(value) as unknown
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The `routes` attribute: the site paths it names; anything malformed is left out. */
|
|
58
|
+
export function parseRoutes(value: unknown): CommunityRoutes {
|
|
59
|
+
const raw = jsonOf(value)
|
|
60
|
+
const out: CommunityRoutes = {}
|
|
61
|
+
if (!isRecord(raw)) return out
|
|
62
|
+
for (const key of ROUTE_KEYS) if (isSitePath(raw[key])) out[key] = raw[key] as string
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The `site-base` attribute: the path the site is served under, without a trailing `/` (`/blog`; '' for the root,
|
|
68
|
+
* also when absent) — or null when it is no plain path (a URL, a query, a dot segment): the element then renders
|
|
69
|
+
* nothing, as for an absolute api-base.
|
|
70
|
+
*/
|
|
71
|
+
export function siteBaseOf(value: unknown): string | null {
|
|
72
|
+
if (value === undefined || value === null || value === '') return ''
|
|
73
|
+
if (typeof value !== 'string') return null
|
|
74
|
+
const base = value.replace(/\/+$/, '')
|
|
75
|
+
if (base === '') return value.startsWith('/') ? '' : null
|
|
76
|
+
return /^(?:\/(?!\.{1,2}(?:\/|$))[A-Za-z0-9._~%-]+)+$/.test(base) ? base : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The pages with the managed blog's defaults filled in under the site's base (statementPath stays a template).
|
|
81
|
+
* A page the host or the tenant names is its own full path.
|
|
82
|
+
*/
|
|
83
|
+
export function routesWithDefaults(
|
|
84
|
+
routes: CommunityRoutes,
|
|
85
|
+
tenant?: { statementPath?: string | null; myCommunityPath?: string | null } | null,
|
|
86
|
+
siteBase = '',
|
|
87
|
+
) {
|
|
88
|
+
const site = (path: string | null | undefined): string | undefined => (isSitePath(path) ? path : undefined)
|
|
89
|
+
return {
|
|
90
|
+
statementPath: routes.statementPath ?? site(tenant?.statementPath) ?? `${siteBase}${COMMUNITY_ROUTES.statement}`,
|
|
91
|
+
myCommunityPath: routes.myCommunityPath ?? site(tenant?.myCommunityPath) ?? `${siteBase}${COMMUNITY_ROUTES.myCommunity}`,
|
|
92
|
+
rulesPath: routes.rulesPath ?? `${siteBase}${COMMUNITY_ROUTES.rules}`,
|
|
93
|
+
reportPath: routes.reportPath ?? `${siteBase}${COMMUNITY_ROUTES.report}`,
|
|
94
|
+
noticesPath: routes.noticesPath ?? `${siteBase}${COMMUNITY_ROUTES.notices}`,
|
|
95
|
+
consultationPath: routes.consultationPath ?? null,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
100
|
+
const HANDLE_RE = /^[a-z0-9][a-z0-9_]{2,29}$/
|
|
101
|
+
const isId = (v: unknown): v is string => typeof v === 'string' && ID_RE.test(v)
|
|
102
|
+
const isSeq = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v > 0
|
|
103
|
+
|
|
104
|
+
/** A notice target of one of kb-api's four shapes, or null (the form then has nothing to report). */
|
|
105
|
+
export function parseTarget(value: unknown): NoticeTarget | null {
|
|
106
|
+
const raw = jsonOf(value)
|
|
107
|
+
if (!isRecord(raw)) return null
|
|
108
|
+
if ('conversationId' in raw) return isId(raw.conversationId) && isSeq(raw.seq) ? { conversationId: raw.conversationId.toLowerCase(), seq: raw.seq } : null
|
|
109
|
+
if ('memberId' in raw) return isId(raw.memberId) ? { memberId: raw.memberId.toLowerCase() } : null
|
|
110
|
+
if ('handle' in raw) return typeof raw.handle === 'string' && HANDLE_RE.test(raw.handle) ? { handle: raw.handle } : null
|
|
111
|
+
if ('articleId' in raw) return isId(raw.articleId) ? { articleId: raw.articleId.toLowerCase() } : null
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type NoticeTargetKind = 'message' | 'profile' | 'article'
|
|
116
|
+
|
|
117
|
+
export function targetKind(target: NoticeTarget): NoticeTargetKind {
|
|
118
|
+
if ('conversationId' in target) return 'message'
|
|
119
|
+
if ('articleId' in target) return 'article'
|
|
120
|
+
return 'profile'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The notice page's link for a message, for a reader without a session (who cannot use the member's
|
|
125
|
+
* report): `${reportPath}#c=<conversationId>&s=<seq>`. The ids are public; they travel in the fragment so
|
|
126
|
+
* the notice page stays one address (no crawlable variants — the storefront's rule for the sign-in link).
|
|
127
|
+
*/
|
|
128
|
+
export function reportLink(reportPath: string, target: { conversationId: string; seq: number }): string | null {
|
|
129
|
+
if (!isSitePath(reportPath) || reportPath.includes('#') || !isId(target.conversationId) || !isSeq(target.seq)) return null
|
|
130
|
+
return `${reportPath}#c=${target.conversationId.toLowerCase()}&s=${target.seq}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The target the discussion's report link carries (`#c=…&s=…`), or `#a=<articleId>`; null for anything else. */
|
|
134
|
+
export function targetFromHash(hash: string): NoticeTarget | null {
|
|
135
|
+
if (typeof hash !== 'string' || !hash.startsWith('#') || hash.startsWith('#t=')) return null
|
|
136
|
+
const params = new Map<string, string>()
|
|
137
|
+
for (const part of hash.slice(1).split('&')) {
|
|
138
|
+
const eq = part.indexOf('=')
|
|
139
|
+
if (eq > 0) params.set(part.slice(0, eq), part.slice(eq + 1))
|
|
140
|
+
}
|
|
141
|
+
const c = params.get('c')
|
|
142
|
+
const s = params.get('s')
|
|
143
|
+
if (c !== undefined && s !== undefined && /^\d{1,15}$/.test(s)) return parseTarget({ conversationId: c, seq: Number(s) })
|
|
144
|
+
const a = params.get('a')
|
|
145
|
+
if (a !== undefined) return parseTarget({ articleId: a })
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
@@ -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,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client an element talks through: the SDK's `createCommunityClient` over the HOST's relative base
|
|
3
|
+
* (`api-base`; default `/api/community` under the site's `site-base` — a headless site's BFF, the storefront's,
|
|
4
|
+
* or with `site-base="/blog"` a managed blog's own proxy). Never kb-api directly: its community namespaces answer
|
|
5
|
+
* no CORS (m1-contracts.md §7.1), and the client refuses an absolute base. On a managed blog the tenant host's
|
|
6
|
+
* `/api/` IS kb-api, so `/api/community` there would skip the blog's proxy and its session. An element with an
|
|
7
|
+
* unusable base warns once and renders nothing.
|
|
8
|
+
*/
|
|
9
|
+
import { createCommunityClient, isRelativeCommunityBase, type CommunityClient, type CommunityTransport } from '@blogmcp/sdk/community'
|
|
10
|
+
import { siteBaseOf } from './attrs'
|
|
11
|
+
|
|
12
|
+
/** The base the hosts serve the community under, below their site base (§12; the BFF's default basePath). */
|
|
13
|
+
export const DEFAULT_API_BASE = '/api/community'
|
|
14
|
+
|
|
15
|
+
const warned = new Set<string>()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One console line per problem and page, for the site's developer — never a value from the page. The
|
|
19
|
+
* message starts with `[blogmcp-community]` (test/text-only.spec.ts tells developer lines from reader text
|
|
20
|
+
* by it).
|
|
21
|
+
*/
|
|
22
|
+
export function warnOnce(key: string, message: `[blogmcp-community] ${string}`): void {
|
|
23
|
+
if (warned.has(key)) return
|
|
24
|
+
warned.add(key)
|
|
25
|
+
console.warn(message)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The `site-base` attribute as a path prefix ('' at the site's root), or null — the developer told once — when it
|
|
30
|
+
* is no path on this site.
|
|
31
|
+
*/
|
|
32
|
+
export function siteBaseFor(value: string | null | undefined): string | null {
|
|
33
|
+
const base = siteBaseOf(value)
|
|
34
|
+
if (base === null) warnOnce('site-base', '[blogmcp-community] site-base must be a path on this site (e.g. "/blog"), never a URL: the site’s pages and its proxy are found under it')
|
|
35
|
+
return base
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The client for an element's `api-base` (and an injected transport), or null when the base is not a relative
|
|
40
|
+
* path. Without an `api-base`: `/api/community` under the site's base.
|
|
41
|
+
*/
|
|
42
|
+
export function communityClientFor(apiBase: string | null | undefined, transport?: CommunityTransport | null, siteBase = ''): CommunityClient | null {
|
|
43
|
+
const base = apiBase === undefined || apiBase === null || apiBase === '' ? `${siteBase}${DEFAULT_API_BASE}` : apiBase
|
|
44
|
+
if (!isRelativeCommunityBase(base)) {
|
|
45
|
+
warnOnce('api-base', '[blogmcp-community] api-base must be a path on this site (e.g. "/api/community"), never a URL: the community API is reached through the site’s own proxy or BFF')
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return createCommunityClient(transport ? { base, transport } : { base })
|
|
50
|
+
} catch {
|
|
51
|
+
warnOnce('api-base', '[blogmcp-community] api-base was refused by the community client')
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|