@meith/plugin-kit 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-kit",
3
- "version": "0.6.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.6.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<PluginRegion, { pluginKey: string; priority: number; contribution: PluginContribution }[]>()
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' ? registration : (registration as HookRegistration<HookName>).handler
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 }>(a: T, b: T): number =>
108
- a.priority - b.priority || (a.pluginKey < b.pluginKey ? -1 : a.pluginKey > b.pluginKey ? 1 : 0)
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>(name: K, value: HookValue<K>, context: HookContext<K>): Promise<void> {
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 !== undefined && stats.enabled && !stats.operatorDisabled
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
- hookKind,
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
- operatorDisabledPlugins,
66
- parsePluginSetting,
67
- pluginEnabledKey,
68
- pluginSettingType,
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
- createRouteRateLimiter,
116
- type RateLimitVerdict,
117
- type RouteRateLimiter,
118
- } from './rate-limit'
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': { value: ValidationMessages; context: { draft: DraftPayload; threadId: number } }
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: { readonly keptThreadId: number; readonly mergedThreadId: number; readonly postCount: number }
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: { readonly sourceThreadId: number; readonly newThreadId: number; readonly postCount: number }
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': { value: { readonly pollId: number; readonly optionId: number }; context: ViewerRef }
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': { value: { readonly warningId: number; readonly userId: number }; context: ModerationRef }
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': { value: UserRef & { username: string; requiresActivation: boolean }; context: RequestRef }
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': { value: { readonly keptUserId: number; readonly mergedUserId: number }; context: RequestRef }
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': { value: { readonly notificationId: number; readonly userId: number }; context: RequestRef }
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': { value: { readonly messageId: number; readonly recipientIds: readonly number[] }; context: RequestRef }
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': { value: { readonly keys: readonly string[] }; context: { readonly adminId: number } }
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 { isHookName, type HOOKS, type HookName } from './hooks'
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?: readonly { readonly value: string; readonly label: string }[] | undefined
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.replace(/[(;,].*$/s, '').replace(/"/g, '').toLowerCase()
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(`${where}: version must be semver (major.minor.patch), got "${plugin.version}".`)
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' ? registration : (registration as HookRegistration<HookName>)?.handler
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(where, 'setting', (plugin.settings ?? []).map((setting) => setting.key))
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(where, 'migration', (plugin.migrations ?? []).map((migration) => migration.id))
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(where, 'task', (plugin.tasks ?? []).map((task) => task.id))
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(`${where}: task id "${task.id}" must be lower-case letters, digits and hyphens.`)
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(where, 'admin page', (plugin.adminPages ?? []).map((page) => page.path))
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(where, 'page', (plugin.pages ?? []).map((page) => page.path))
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: string
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 subject = input.subject.trim()
158
- if (subject === '' || subject.length > MAX_NOTIFY_SUBJECT) {
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 (input.href !== undefined && (!input.href.startsWith('/') || input.href.startsWith('//'))) {
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: body === '' ? { subject } : { subject, body },
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) {