@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
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }