@meith/plugin-kit 0.7.0 → 0.8.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/package.json +2 -2
- package/src/host.ts +19 -6
- package/src/index.ts +44 -51
- package/src/payloads.ts +44 -12
- package/src/plugin.ts +64 -13
- package/src/runtime.ts +61 -6
- package/src/settings.ts +2 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/plugin-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "The SDK for writing a Meith plugin: typed manifests, hooks, routes, pages and migrations.",
|
|
5
5
|
"license": "LGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@meith/theme-kit": "^0.
|
|
23
|
+
"@meith/theme-kit": "^0.8.0"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": "^19.2.0"
|
package/src/host.ts
CHANGED
|
@@ -53,7 +53,10 @@ const DEFAULT_PRIORITY = 100
|
|
|
53
53
|
|
|
54
54
|
export class PluginHost {
|
|
55
55
|
readonly #entries = new Map<HookName, Entry[]>()
|
|
56
|
-
readonly #contributions = new Map<
|
|
56
|
+
readonly #contributions = new Map<
|
|
57
|
+
PluginRegion,
|
|
58
|
+
{ pluginKey: string; priority: number; contribution: PluginContribution }[]
|
|
59
|
+
>()
|
|
57
60
|
readonly #stats = new Map<string, Stats>()
|
|
58
61
|
readonly #logger: HostLogger
|
|
59
62
|
readonly #failureThreshold: number
|
|
@@ -81,7 +84,9 @@ export class PluginHost {
|
|
|
81
84
|
for (const [name, registration] of Object.entries(plugin.hooks ?? {})) {
|
|
82
85
|
const hook = name as HookName
|
|
83
86
|
const handler = (
|
|
84
|
-
typeof registration === 'function'
|
|
87
|
+
typeof registration === 'function'
|
|
88
|
+
? registration
|
|
89
|
+
: (registration as HookRegistration<HookName>).handler
|
|
85
90
|
) as StoredHandler
|
|
86
91
|
const priority =
|
|
87
92
|
typeof registration === 'function'
|
|
@@ -104,8 +109,12 @@ export class PluginHost {
|
|
|
104
109
|
}
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
const byPriorityThenKey = <T extends { priority: number; pluginKey: string }>(
|
|
108
|
-
a
|
|
112
|
+
const byPriorityThenKey = <T extends { priority: number; pluginKey: string }>(
|
|
113
|
+
a: T,
|
|
114
|
+
b: T,
|
|
115
|
+
): number =>
|
|
116
|
+
a.priority - b.priority ||
|
|
117
|
+
(a.pluginKey < b.pluginKey ? -1 : a.pluginKey > b.pluginKey ? 1 : 0)
|
|
109
118
|
|
|
110
119
|
for (const list of this.#entries.values()) list.sort(byPriorityThenKey)
|
|
111
120
|
for (const list of this.#contributions.values()) list.sort(byPriorityThenKey)
|
|
@@ -130,7 +139,11 @@ export class PluginHost {
|
|
|
130
139
|
return current
|
|
131
140
|
}
|
|
132
141
|
|
|
133
|
-
async emit<K extends HookName>(
|
|
142
|
+
async emit<K extends HookName>(
|
|
143
|
+
name: K,
|
|
144
|
+
value: HookValue<K>,
|
|
145
|
+
context: HookContext<K>,
|
|
146
|
+
): Promise<void> {
|
|
134
147
|
const entries = this.#entries.get(name)
|
|
135
148
|
if (entries === undefined) return
|
|
136
149
|
|
|
@@ -219,7 +232,7 @@ export class PluginHost {
|
|
|
219
232
|
|
|
220
233
|
#isEnabled(pluginKey: string): boolean {
|
|
221
234
|
const stats = this.#stats.get(pluginKey)
|
|
222
|
-
return stats
|
|
235
|
+
return stats?.enabled === true && !stats.operatorDisabled
|
|
223
236
|
}
|
|
224
237
|
|
|
225
238
|
async #call(
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
export {
|
|
2
|
-
HOOKS,
|
|
3
2
|
HOOK_NAMES,
|
|
4
|
-
|
|
5
|
-
isHookName,
|
|
3
|
+
HOOKS,
|
|
6
4
|
type HookKind,
|
|
7
5
|
type HookName,
|
|
8
6
|
type HookSpec,
|
|
7
|
+
hookKind,
|
|
8
|
+
isHookName,
|
|
9
9
|
} from './hooks'
|
|
10
|
-
|
|
10
|
+
export {
|
|
11
|
+
emptyHost,
|
|
12
|
+
type HostLogger,
|
|
13
|
+
isFilter,
|
|
14
|
+
type PluginHealth,
|
|
15
|
+
PluginHost,
|
|
16
|
+
type PluginHostOptions,
|
|
17
|
+
} from './host'
|
|
11
18
|
export type {
|
|
12
19
|
DraftPayload,
|
|
13
20
|
ForumRef,
|
|
@@ -22,23 +29,14 @@ export type {
|
|
|
22
29
|
ValidationMessages,
|
|
23
30
|
ViewerRef,
|
|
24
31
|
} from './payloads'
|
|
25
|
-
|
|
26
32
|
export {
|
|
27
33
|
DEFAULT_ROUTE_BODY_BYTES,
|
|
28
|
-
MAX_ROUTE_BODY_BYTES,
|
|
29
34
|
definePlugin,
|
|
30
|
-
pluginAdminPath,
|
|
31
|
-
pluginAdminRoutePath,
|
|
32
|
-
pluginNotificationKindId,
|
|
33
|
-
pluginPagePath,
|
|
34
|
-
pluginRoutePath,
|
|
35
|
-
pluginSettingKey,
|
|
36
|
-
pluginTablePrefix,
|
|
37
|
-
pluginTaskId,
|
|
38
35
|
type EventHandler,
|
|
39
36
|
type FilterHandler,
|
|
40
37
|
type HookHandler,
|
|
41
38
|
type HookRegistration,
|
|
39
|
+
MAX_ROUTE_BODY_BYTES,
|
|
42
40
|
type PluginAdminPage,
|
|
43
41
|
type PluginAdminPageContext,
|
|
44
42
|
type PluginBoardPage,
|
|
@@ -59,47 +57,29 @@ export {
|
|
|
59
57
|
type PluginSettingType,
|
|
60
58
|
type PluginTask,
|
|
61
59
|
type PluginViewer,
|
|
60
|
+
pluginAdminPath,
|
|
61
|
+
pluginAdminRoutePath,
|
|
62
|
+
pluginNotificationKindId,
|
|
63
|
+
pluginPagePath,
|
|
64
|
+
pluginRoutePath,
|
|
65
|
+
pluginSettingKey,
|
|
66
|
+
pluginTablePrefix,
|
|
67
|
+
pluginTaskId,
|
|
62
68
|
} from './plugin'
|
|
63
|
-
|
|
64
69
|
export {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
resolvePluginSettingDetails,
|
|
70
|
-
resolvePluginSettings,
|
|
71
|
-
serialisePluginSetting,
|
|
72
|
-
type PluginEnvReader,
|
|
73
|
-
type PluginSettingSource,
|
|
74
|
-
type PluginSettingValue,
|
|
75
|
-
type ResolvedPluginSetting,
|
|
76
|
-
} from './settings'
|
|
77
|
-
|
|
70
|
+
createRouteRateLimiter,
|
|
71
|
+
type RateLimitVerdict,
|
|
72
|
+
type RouteRateLimiter,
|
|
73
|
+
} from './rate-limit'
|
|
78
74
|
export {
|
|
79
|
-
PLUGIN_REGIONS,
|
|
80
|
-
REGION_NAMES,
|
|
81
75
|
isPluginRegion,
|
|
76
|
+
PLUGIN_REGIONS,
|
|
82
77
|
type PluginRegion,
|
|
83
78
|
type PluginRegionContext,
|
|
79
|
+
REGION_NAMES,
|
|
84
80
|
type RegionSpec,
|
|
85
81
|
} from './regions'
|
|
86
|
-
|
|
87
|
-
export {
|
|
88
|
-
PluginHost,
|
|
89
|
-
emptyHost,
|
|
90
|
-
isFilter,
|
|
91
|
-
type HostLogger,
|
|
92
|
-
type PluginHealth,
|
|
93
|
-
type PluginHostOptions,
|
|
94
|
-
} from './host'
|
|
95
|
-
|
|
96
82
|
export {
|
|
97
|
-
pluginNotificationKindSpecs,
|
|
98
|
-
pluginNotify,
|
|
99
|
-
unavailablePluginData,
|
|
100
|
-
unavailablePluginGrants,
|
|
101
|
-
unavailablePluginNotify,
|
|
102
|
-
unavailablePluginUsers,
|
|
103
83
|
type PluginData,
|
|
104
84
|
type PluginGrantRow,
|
|
105
85
|
type PluginGrants,
|
|
@@ -109,10 +89,23 @@ export {
|
|
|
109
89
|
type PluginNotifyKindInput,
|
|
110
90
|
type PluginUserRef,
|
|
111
91
|
type PluginUsers,
|
|
92
|
+
pluginNotificationKindSpecs,
|
|
93
|
+
pluginNotify,
|
|
94
|
+
unavailablePluginData,
|
|
95
|
+
unavailablePluginGrants,
|
|
96
|
+
unavailablePluginNotify,
|
|
97
|
+
unavailablePluginUsers,
|
|
112
98
|
} from './runtime'
|
|
113
|
-
|
|
114
99
|
export {
|
|
115
|
-
|
|
116
|
-
type
|
|
117
|
-
type
|
|
118
|
-
|
|
100
|
+
operatorDisabledPlugins,
|
|
101
|
+
type PluginEnvReader,
|
|
102
|
+
type PluginSettingSource,
|
|
103
|
+
type PluginSettingValue,
|
|
104
|
+
parsePluginSetting,
|
|
105
|
+
pluginEnabledKey,
|
|
106
|
+
pluginSettingType,
|
|
107
|
+
type ResolvedPluginSetting,
|
|
108
|
+
resolvePluginSettingDetails,
|
|
109
|
+
resolvePluginSettings,
|
|
110
|
+
serialisePluginSetting,
|
|
111
|
+
} from './settings'
|
package/src/payloads.ts
CHANGED
|
@@ -40,15 +40,15 @@
|
|
|
40
40
|
|
|
41
41
|
import type {
|
|
42
42
|
AnnouncementModel,
|
|
43
|
+
AuthPageModel,
|
|
43
44
|
BoardIndexModel,
|
|
44
45
|
BoardStatsModel,
|
|
45
|
-
AuthPageModel,
|
|
46
46
|
CategoryBlockModel,
|
|
47
47
|
DiscoveryViewModel,
|
|
48
48
|
ErrorNoticeModel,
|
|
49
49
|
FooterModel,
|
|
50
|
-
ForumJumpModel,
|
|
51
50
|
ForumDisplayModel,
|
|
51
|
+
ForumJumpModel,
|
|
52
52
|
ForumRowSlotModel,
|
|
53
53
|
HeaderModel,
|
|
54
54
|
LatestPostsModel,
|
|
@@ -200,7 +200,10 @@ export interface HookSignatures {
|
|
|
200
200
|
'thread.create.validate': { value: ValidationMessages; context: { draft: DraftPayload } }
|
|
201
201
|
'thread.create.before': { value: DraftPayload; context: ViewerRef }
|
|
202
202
|
'thread.created': { value: ThreadRef & { authorId: number; subject: string }; context: ViewerRef }
|
|
203
|
-
'post.create.validate': {
|
|
203
|
+
'post.create.validate': {
|
|
204
|
+
value: ValidationMessages
|
|
205
|
+
context: { draft: DraftPayload; threadId: number }
|
|
206
|
+
}
|
|
204
207
|
'post.create.before': { value: DraftPayload; context: ViewerRef & { threadId: number } }
|
|
205
208
|
'post.created': { value: PostRef & { authorId: number }; context: ViewerRef }
|
|
206
209
|
'post.edit.before': {
|
|
@@ -216,11 +219,19 @@ export interface HookSignatures {
|
|
|
216
219
|
context: ModerationRef
|
|
217
220
|
}
|
|
218
221
|
'thread.merged': {
|
|
219
|
-
value: {
|
|
222
|
+
value: {
|
|
223
|
+
readonly keptThreadId: number
|
|
224
|
+
readonly mergedThreadId: number
|
|
225
|
+
readonly postCount: number
|
|
226
|
+
}
|
|
220
227
|
context: ModerationRef
|
|
221
228
|
}
|
|
222
229
|
'thread.split': {
|
|
223
|
-
value: {
|
|
230
|
+
value: {
|
|
231
|
+
readonly sourceThreadId: number
|
|
232
|
+
readonly newThreadId: number
|
|
233
|
+
readonly postCount: number
|
|
234
|
+
}
|
|
224
235
|
context: ModerationRef
|
|
225
236
|
}
|
|
226
237
|
'thread.locked': { value: ThreadRef & { isLocked: boolean }; context: ModerationRef }
|
|
@@ -241,7 +252,10 @@ export interface HookSignatures {
|
|
|
241
252
|
}
|
|
242
253
|
'attachment.deleted': { value: { readonly attachmentId: number }; context: ViewerRef }
|
|
243
254
|
'poll.created': { value: ThreadRef & { pollId: number; optionCount: number }; context: ViewerRef }
|
|
244
|
-
'poll.voted': {
|
|
255
|
+
'poll.voted': {
|
|
256
|
+
value: { readonly pollId: number; readonly optionId: number }
|
|
257
|
+
context: ViewerRef
|
|
258
|
+
}
|
|
245
259
|
'rating.recorded': {
|
|
246
260
|
value: { readonly threadId: number; readonly rating: number; readonly average: number }
|
|
247
261
|
context: ViewerRef
|
|
@@ -282,7 +296,10 @@ export interface HookSignatures {
|
|
|
282
296
|
}
|
|
283
297
|
context: ModerationRef
|
|
284
298
|
}
|
|
285
|
-
'warning.revoked': {
|
|
299
|
+
'warning.revoked': {
|
|
300
|
+
value: { readonly warningId: number; readonly userId: number }
|
|
301
|
+
context: ModerationRef
|
|
302
|
+
}
|
|
286
303
|
'moderation.logged': {
|
|
287
304
|
value: { readonly action: string; readonly targetId: number | null }
|
|
288
305
|
context: ModerationRef
|
|
@@ -293,7 +310,10 @@ export interface HookSignatures {
|
|
|
293
310
|
value: ValidationMessages
|
|
294
311
|
context: { readonly username: string; readonly email: string; readonly ipPrefix: string | null }
|
|
295
312
|
}
|
|
296
|
-
'user.registered': {
|
|
313
|
+
'user.registered': {
|
|
314
|
+
value: UserRef & { username: string; requiresActivation: boolean }
|
|
315
|
+
context: RequestRef
|
|
316
|
+
}
|
|
297
317
|
'user.activated': { value: UserRef; context: RequestRef }
|
|
298
318
|
'user.login.attempted': {
|
|
299
319
|
value: {
|
|
@@ -316,7 +336,10 @@ export interface HookSignatures {
|
|
|
316
336
|
context: RequestRef
|
|
317
337
|
}
|
|
318
338
|
'user.profile.updated': { value: UserRef & { fields: readonly string[] }; context: RequestRef }
|
|
319
|
-
'user.merged': {
|
|
339
|
+
'user.merged': {
|
|
340
|
+
value: { readonly keptUserId: number; readonly mergedUserId: number }
|
|
341
|
+
context: RequestRef
|
|
342
|
+
}
|
|
320
343
|
'user.deleted': { value: UserRef & { reason: 'pruned' | 'deleted' }; context: RequestRef }
|
|
321
344
|
|
|
322
345
|
/* ---- Mail, notifications, messages ---- */
|
|
@@ -329,7 +352,10 @@ export interface HookSignatures {
|
|
|
329
352
|
} | null
|
|
330
353
|
context: RequestRef
|
|
331
354
|
}
|
|
332
|
-
'notification.created': {
|
|
355
|
+
'notification.created': {
|
|
356
|
+
value: { readonly notificationId: number; readonly userId: number }
|
|
357
|
+
context: RequestRef
|
|
358
|
+
}
|
|
333
359
|
'mail.send.before': {
|
|
334
360
|
value: {
|
|
335
361
|
readonly to: string
|
|
@@ -349,7 +375,10 @@ export interface HookSignatures {
|
|
|
349
375
|
} | null
|
|
350
376
|
context: RequestRef
|
|
351
377
|
}
|
|
352
|
-
'pm.sent': {
|
|
378
|
+
'pm.sent': {
|
|
379
|
+
value: { readonly messageId: number; readonly recipientIds: readonly number[] }
|
|
380
|
+
context: RequestRef
|
|
381
|
+
}
|
|
353
382
|
'subscription.changed': {
|
|
354
383
|
value: {
|
|
355
384
|
readonly userId: number
|
|
@@ -399,7 +428,10 @@ export interface HookSignatures {
|
|
|
399
428
|
value: readonly { readonly label: string; readonly href: string }[]
|
|
400
429
|
context: ViewerRef
|
|
401
430
|
}
|
|
402
|
-
'settings.saved': {
|
|
431
|
+
'settings.saved': {
|
|
432
|
+
value: { readonly keys: readonly string[] }
|
|
433
|
+
context: { readonly adminId: number }
|
|
434
|
+
}
|
|
403
435
|
'task.run.before': { value: { readonly taskId: string }; context: Record<string, never> }
|
|
404
436
|
'task.run.after': {
|
|
405
437
|
value: { readonly taskId: string; readonly ok: boolean; readonly durationMs: number }
|
package/src/plugin.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import type { Translator } from '@meith/theme-kit'
|
|
4
|
+
|
|
5
|
+
import { type HOOKS, type HookName, isHookName } from './hooks'
|
|
4
6
|
import type { HookContext, HookValue } from './payloads'
|
|
5
7
|
import { isPluginRegion, type PluginRegion, type PluginRegionContext } from './regions'
|
|
6
8
|
import type { PluginData, PluginGrants, PluginNotify, PluginUsers } from './runtime'
|
|
@@ -33,9 +35,17 @@ export type PluginSettingType = 'string' | 'secret' | 'number' | 'boolean' | 'se
|
|
|
33
35
|
export interface PluginSetting {
|
|
34
36
|
readonly key: string
|
|
35
37
|
readonly label: string
|
|
38
|
+
readonly labelKey?: string | undefined
|
|
36
39
|
readonly description?: string | undefined
|
|
40
|
+
readonly descriptionKey?: string | undefined
|
|
37
41
|
readonly type?: PluginSettingType | undefined
|
|
38
|
-
readonly options?:
|
|
42
|
+
readonly options?:
|
|
43
|
+
| readonly {
|
|
44
|
+
readonly value: string
|
|
45
|
+
readonly label: string
|
|
46
|
+
readonly labelKey?: string | undefined
|
|
47
|
+
}[]
|
|
48
|
+
| undefined
|
|
39
49
|
readonly env?: string | undefined
|
|
40
50
|
readonly required?: boolean | undefined
|
|
41
51
|
readonly default: string | number | boolean
|
|
@@ -56,6 +66,8 @@ export interface PluginTask {
|
|
|
56
66
|
export interface PluginAdminPage {
|
|
57
67
|
readonly path: string
|
|
58
68
|
readonly title: string
|
|
69
|
+
readonly titleKey?: string | undefined
|
|
70
|
+
readonly titleArgs?: Parameters<Translator['t']>[1] | undefined
|
|
59
71
|
readonly render: (context: PluginAdminPageContext) => ReactNode | Promise<ReactNode>
|
|
60
72
|
}
|
|
61
73
|
|
|
@@ -118,15 +130,21 @@ export interface PluginPageContext extends PluginRuntimeContext {
|
|
|
118
130
|
readonly path: string
|
|
119
131
|
readonly query: Readonly<Record<string, string>>
|
|
120
132
|
readonly boardUrl: string
|
|
133
|
+
readonly locale: string
|
|
134
|
+
readonly t: Translator
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
export interface PluginAdminPageContext extends PluginRuntimeContext {
|
|
124
138
|
readonly query: Readonly<Record<string, string>>
|
|
139
|
+
readonly locale: string
|
|
140
|
+
readonly t: Translator
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
export interface PluginBoardPage {
|
|
128
144
|
readonly path: string
|
|
129
145
|
readonly title: string
|
|
146
|
+
readonly titleKey?: string | undefined
|
|
147
|
+
readonly titleArgs?: Parameters<Translator['t']>[1] | undefined
|
|
130
148
|
readonly access: PluginPageAccess
|
|
131
149
|
readonly render: (context: PluginPageContext) => ReactNode | Promise<ReactNode>
|
|
132
150
|
}
|
|
@@ -147,15 +165,20 @@ export interface PluginRuntimeContext {
|
|
|
147
165
|
export interface PluginNotificationKind {
|
|
148
166
|
readonly key: string
|
|
149
167
|
readonly title: string
|
|
168
|
+
readonly titleKey?: string | undefined
|
|
150
169
|
readonly description: string
|
|
170
|
+
readonly descriptionKey?: string | undefined
|
|
151
171
|
readonly emailByDefault?: boolean | undefined
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
export interface PluginDefinition {
|
|
155
175
|
readonly key: string
|
|
156
176
|
readonly name: string
|
|
177
|
+
readonly nameKey?: string | undefined
|
|
157
178
|
readonly version: string
|
|
158
179
|
readonly description?: string | undefined
|
|
180
|
+
readonly descriptionKey?: string | undefined
|
|
181
|
+
readonly descriptionArgs?: Parameters<Translator['t']>[1] | undefined
|
|
159
182
|
readonly apiVersion?: string | undefined
|
|
160
183
|
|
|
161
184
|
readonly dependsOn?: readonly string[] | undefined
|
|
@@ -201,8 +224,7 @@ const MIGRATION_FORMS: readonly {
|
|
|
201
224
|
{ pattern: /^alter\s+table(?:\s+if\s+exists)?(?:\s+only)?\s+(\S+)/i, describe: 'alter table' },
|
|
202
225
|
{ pattern: /^drop\s+table(?:\s+if\s+exists)?\s+(\S+)/i, describe: 'drop table' },
|
|
203
226
|
{
|
|
204
|
-
pattern:
|
|
205
|
-
/^create\s+(?:unique\s+)?index(?:\s+if\s+not\s+exists)?\s+(\S+)\s+on\s+(\S+)/i,
|
|
227
|
+
pattern: /^create\s+(?:unique\s+)?index(?:\s+if\s+not\s+exists)?\s+(\S+)\s+on\s+(\S+)/i,
|
|
206
228
|
describe: 'create index',
|
|
207
229
|
},
|
|
208
230
|
{ pattern: /^drop\s+index(?:\s+if\s+exists)?\s+(\S+)/i, describe: 'drop index' },
|
|
@@ -216,7 +238,10 @@ const MIGRATION_FORMS: readonly {
|
|
|
216
238
|
]
|
|
217
239
|
|
|
218
240
|
function bareIdentifier(raw: string): string {
|
|
219
|
-
let name = raw
|
|
241
|
+
let name = raw
|
|
242
|
+
.replace(/[(;,].*$/s, '')
|
|
243
|
+
.replace(/"/g, '')
|
|
244
|
+
.toLowerCase()
|
|
220
245
|
if (name.startsWith('public.')) name = name.slice('public.'.length)
|
|
221
246
|
const dot = name.indexOf('.')
|
|
222
247
|
return dot === -1 ? name : name.slice(0, dot)
|
|
@@ -272,7 +297,9 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
272
297
|
}
|
|
273
298
|
if (plugin.name.trim() === '') throw new Error(`${where}: name must not be empty.`)
|
|
274
299
|
if (!/^\d+\.\d+\.\d+$/.test(plugin.version)) {
|
|
275
|
-
throw new Error(
|
|
300
|
+
throw new Error(
|
|
301
|
+
`${where}: version must be semver (major.minor.patch), got "${plugin.version}".`,
|
|
302
|
+
)
|
|
276
303
|
}
|
|
277
304
|
|
|
278
305
|
for (const dependency of plugin.dependsOn ?? []) {
|
|
@@ -292,13 +319,19 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
292
319
|
)
|
|
293
320
|
}
|
|
294
321
|
const handler =
|
|
295
|
-
typeof registration === 'function'
|
|
322
|
+
typeof registration === 'function'
|
|
323
|
+
? registration
|
|
324
|
+
: (registration as HookRegistration<HookName>)?.handler
|
|
296
325
|
if (typeof handler !== 'function') {
|
|
297
326
|
throw new Error(`${where}: hook "${name}" must be a function or { handler, priority }.`)
|
|
298
327
|
}
|
|
299
328
|
}
|
|
300
329
|
|
|
301
|
-
assertUnique(
|
|
330
|
+
assertUnique(
|
|
331
|
+
where,
|
|
332
|
+
'setting',
|
|
333
|
+
(plugin.settings ?? []).map((setting) => setting.key),
|
|
334
|
+
)
|
|
302
335
|
for (const setting of plugin.settings ?? []) {
|
|
303
336
|
if (!SETTING_KEY_PATTERN.test(setting.key)) {
|
|
304
337
|
throw new Error(
|
|
@@ -352,7 +385,11 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
352
385
|
}
|
|
353
386
|
}
|
|
354
387
|
|
|
355
|
-
assertUnique(
|
|
388
|
+
assertUnique(
|
|
389
|
+
where,
|
|
390
|
+
'migration',
|
|
391
|
+
(plugin.migrations ?? []).map((migration) => migration.id),
|
|
392
|
+
)
|
|
356
393
|
const migrationIds = (plugin.migrations ?? []).map((migration) => migration.id)
|
|
357
394
|
for (const id of migrationIds) {
|
|
358
395
|
if (!MIGRATION_ID_PATTERN.test(id)) {
|
|
@@ -383,10 +420,16 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
383
420
|
}
|
|
384
421
|
}
|
|
385
422
|
|
|
386
|
-
assertUnique(
|
|
423
|
+
assertUnique(
|
|
424
|
+
where,
|
|
425
|
+
'task',
|
|
426
|
+
(plugin.tasks ?? []).map((task) => task.id),
|
|
427
|
+
)
|
|
387
428
|
for (const task of plugin.tasks ?? []) {
|
|
388
429
|
if (!TASK_ID_PATTERN.test(task.id)) {
|
|
389
|
-
throw new Error(
|
|
430
|
+
throw new Error(
|
|
431
|
+
`${where}: task id "${task.id}" must be lower-case letters, digits and hyphens.`,
|
|
432
|
+
)
|
|
390
433
|
}
|
|
391
434
|
if (!Number.isInteger(task.intervalSeconds) || task.intervalSeconds < 60) {
|
|
392
435
|
throw new Error(
|
|
@@ -397,7 +440,11 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
397
440
|
}
|
|
398
441
|
}
|
|
399
442
|
|
|
400
|
-
assertUnique(
|
|
443
|
+
assertUnique(
|
|
444
|
+
where,
|
|
445
|
+
'admin page',
|
|
446
|
+
(plugin.adminPages ?? []).map((page) => page.path),
|
|
447
|
+
)
|
|
401
448
|
for (const page of plugin.adminPages ?? []) {
|
|
402
449
|
if (!PAGE_PATH_PATTERN.test(page.path)) {
|
|
403
450
|
throw new Error(
|
|
@@ -470,7 +517,11 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
470
517
|
}
|
|
471
518
|
}
|
|
472
519
|
|
|
473
|
-
assertUnique(
|
|
520
|
+
assertUnique(
|
|
521
|
+
where,
|
|
522
|
+
'page',
|
|
523
|
+
(plugin.pages ?? []).map((page) => page.path),
|
|
524
|
+
)
|
|
474
525
|
for (const page of plugin.pages ?? []) {
|
|
475
526
|
if (page.path !== '' && !PAGE_PATH_PATTERN.test(page.path)) {
|
|
476
527
|
throw new Error(
|
package/src/runtime.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
export interface PluginGrantRow {
|
|
3
2
|
readonly groupKey: string
|
|
4
3
|
readonly expiresAt: Date
|
|
@@ -77,8 +76,12 @@ export interface PluginNotify {
|
|
|
77
76
|
send(input: {
|
|
78
77
|
readonly userId: number
|
|
79
78
|
readonly kind: string
|
|
80
|
-
readonly subject
|
|
79
|
+
readonly subject?: string | undefined
|
|
80
|
+
readonly subjectKey?: string | undefined
|
|
81
|
+
readonly subjectArgs?: Readonly<Record<string, string | number>> | undefined
|
|
81
82
|
readonly body?: string | undefined
|
|
83
|
+
readonly bodyKey?: string | undefined
|
|
84
|
+
readonly bodyArgs?: Readonly<Record<string, string | number>> | undefined
|
|
82
85
|
readonly href?: string | undefined
|
|
83
86
|
readonly dedupeKey?: string | undefined
|
|
84
87
|
}): Promise<void>
|
|
@@ -97,14 +100,18 @@ const MAX_NOTIFY_BODY = 2_000
|
|
|
97
100
|
export interface PluginNotifyKindInput {
|
|
98
101
|
readonly key: string
|
|
99
102
|
readonly title: string
|
|
103
|
+
readonly titleKey?: string | undefined
|
|
100
104
|
readonly description: string
|
|
105
|
+
readonly descriptionKey?: string | undefined
|
|
101
106
|
readonly emailByDefault?: boolean | undefined
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
export interface PluginNotificationKindSpec {
|
|
105
110
|
readonly id: string
|
|
106
111
|
readonly title: string
|
|
112
|
+
readonly titleKey?: string
|
|
107
113
|
readonly description: string
|
|
114
|
+
readonly descriptionKey?: string
|
|
108
115
|
readonly audience: 'member'
|
|
109
116
|
readonly emailByDefault: boolean
|
|
110
117
|
readonly emailConfigurable: true
|
|
@@ -127,7 +134,9 @@ export function pluginNotificationKindSpecs(
|
|
|
127
134
|
return kinds.map((kind) => ({
|
|
128
135
|
id: `plugin.${pluginKey}.${kind.key}`,
|
|
129
136
|
title: kind.title,
|
|
137
|
+
...(kind.titleKey === undefined ? {} : { titleKey: kind.titleKey }),
|
|
130
138
|
description: kind.description,
|
|
139
|
+
...(kind.descriptionKey === undefined ? {} : { descriptionKey: kind.descriptionKey }),
|
|
131
140
|
audience: 'member',
|
|
132
141
|
emailByDefault: kind.emailByDefault ?? true,
|
|
133
142
|
emailConfigurable: true,
|
|
@@ -154,14 +163,36 @@ export function pluginNotify(
|
|
|
154
163
|
throw new Error(`plugin "${pluginKey}": a notification needs a real user id.`)
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
const
|
|
158
|
-
|
|
166
|
+
const hasSubject = input.subject !== undefined
|
|
167
|
+
const hasSubjectKey = input.subjectKey !== undefined
|
|
168
|
+
if (hasSubject === hasSubjectKey) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`plugin "${pluginKey}": a notification needs exactly one of subject or subjectKey.`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const subject = input.subject?.trim() ?? ''
|
|
175
|
+
if (hasSubject && (subject === '' || subject.length > MAX_NOTIFY_SUBJECT)) {
|
|
159
176
|
throw new Error(
|
|
160
177
|
`plugin "${pluginKey}": a notification subject is 1 to ${MAX_NOTIFY_SUBJECT} characters.`,
|
|
161
178
|
)
|
|
162
179
|
}
|
|
163
180
|
|
|
181
|
+
const subjectKey = input.subjectKey?.trim() ?? ''
|
|
182
|
+
if (hasSubjectKey && subjectKey === '') {
|
|
183
|
+
throw new Error(`plugin "${pluginKey}": a notification subjectKey cannot be empty.`)
|
|
184
|
+
}
|
|
185
|
+
|
|
164
186
|
const body = (input.body ?? '').trim()
|
|
187
|
+
const hasBody = input.body !== undefined
|
|
188
|
+
const hasBodyKey = input.bodyKey !== undefined
|
|
189
|
+
if (hasBody && hasBodyKey) {
|
|
190
|
+
throw new Error(`plugin "${pluginKey}": a notification cannot have both body and bodyKey.`)
|
|
191
|
+
}
|
|
192
|
+
const bodyKey = input.bodyKey?.trim() ?? ''
|
|
193
|
+
if (hasBodyKey && bodyKey === '') {
|
|
194
|
+
throw new Error(`plugin "${pluginKey}": a notification bodyKey cannot be empty.`)
|
|
195
|
+
}
|
|
165
196
|
if (body.length > MAX_NOTIFY_BODY) {
|
|
166
197
|
throw new Error(
|
|
167
198
|
`plugin "${pluginKey}": a notification body caps at ${MAX_NOTIFY_BODY} characters — ` +
|
|
@@ -169,7 +200,10 @@ export function pluginNotify(
|
|
|
169
200
|
)
|
|
170
201
|
}
|
|
171
202
|
|
|
172
|
-
if (
|
|
203
|
+
if (
|
|
204
|
+
input.href !== undefined &&
|
|
205
|
+
(!input.href.startsWith('/') || input.href.startsWith('//'))
|
|
206
|
+
) {
|
|
173
207
|
throw new Error(
|
|
174
208
|
`plugin "${pluginKey}": a notification links within the board — the href must ` +
|
|
175
209
|
'start with a single "/".',
|
|
@@ -179,7 +213,28 @@ export function pluginNotify(
|
|
|
179
213
|
await backend.raise({
|
|
180
214
|
userId: input.userId,
|
|
181
215
|
kind: `plugin.${pluginKey}.${input.kind}`,
|
|
182
|
-
data:
|
|
216
|
+
data: {
|
|
217
|
+
...(hasSubject
|
|
218
|
+
? { subject }
|
|
219
|
+
: {
|
|
220
|
+
subjectKey,
|
|
221
|
+
...(input.subjectArgs === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { subjectArgs: JSON.stringify(input.subjectArgs) }),
|
|
224
|
+
}),
|
|
225
|
+
...(hasBody
|
|
226
|
+
? body === ''
|
|
227
|
+
? {}
|
|
228
|
+
: { body }
|
|
229
|
+
: !hasBodyKey
|
|
230
|
+
? {}
|
|
231
|
+
: {
|
|
232
|
+
bodyKey,
|
|
233
|
+
...(input.bodyArgs === undefined
|
|
234
|
+
? {}
|
|
235
|
+
: { bodyArgs: JSON.stringify(input.bodyArgs) }),
|
|
236
|
+
}),
|
|
237
|
+
},
|
|
183
238
|
href: input.href ?? null,
|
|
184
239
|
dedupeKey: input.dedupeKey ?? null,
|
|
185
240
|
})
|
package/src/settings.ts
CHANGED
|
@@ -31,10 +31,7 @@ export function serialisePluginSetting(value: PluginSettingValue): string {
|
|
|
31
31
|
return String(value)
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export function parsePluginSetting(
|
|
35
|
-
setting: PluginSetting,
|
|
36
|
-
raw: string,
|
|
37
|
-
): PluginSettingValue | null {
|
|
34
|
+
export function parsePluginSetting(setting: PluginSetting, raw: string): PluginSettingValue | null {
|
|
38
35
|
if (typeof setting.default === 'boolean') {
|
|
39
36
|
if (raw === '1' || raw === 'true') return true
|
|
40
37
|
if (raw === '0' || raw === 'false') return false
|
|
@@ -114,9 +111,7 @@ export function resolvePluginSettingDetails(
|
|
|
114
111
|
return (plugin.settings ?? []).map((setting) => resolveOne(plugin, setting, overrides, env))
|
|
115
112
|
}
|
|
116
113
|
|
|
117
|
-
export function operatorDisabledPlugins(
|
|
118
|
-
overrides: ReadonlyMap<string, string>,
|
|
119
|
-
): readonly string[] {
|
|
114
|
+
export function operatorDisabledPlugins(overrides: ReadonlyMap<string, string>): readonly string[] {
|
|
120
115
|
const disabled: string[] = []
|
|
121
116
|
|
|
122
117
|
for (const [key, value] of overrides) {
|