@meith/plugin-kit 0.9.0 → 0.10.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/hooks.ts +7 -3
- package/src/host.ts +55 -4
- package/src/index.ts +16 -0
- package/src/navigation.ts +40 -0
- package/src/payloads.ts +11 -4
- package/src/plugin.ts +93 -0
- package/src/rendering.ts +29 -0
- package/src/runtime.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/plugin-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.10.0"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": "^19.2.0"
|
package/src/hooks.ts
CHANGED
|
@@ -74,7 +74,7 @@ export const HOOKS = {
|
|
|
74
74
|
kind: 'filter',
|
|
75
75
|
purpose:
|
|
76
76
|
'The declarative directive list, so a plugin can add a `:::name` block or ' +
|
|
77
|
-
'`:name[…]` span without core changes.',
|
|
77
|
+
'`:name[…]` span without core changes. Board-wide: rendered bodies are stored and shared, so the set cannot depend on who is reading.',
|
|
78
78
|
},
|
|
79
79
|
'post.body.html': {
|
|
80
80
|
kind: 'filter',
|
|
@@ -86,7 +86,9 @@ export const HOOKS = {
|
|
|
86
86
|
},
|
|
87
87
|
'smilies.list': {
|
|
88
88
|
kind: 'filter',
|
|
89
|
-
purpose:
|
|
89
|
+
purpose:
|
|
90
|
+
'The smilie set substituted at render. Board-wide, for the same reason ' +
|
|
91
|
+
'the directive list is.',
|
|
90
92
|
},
|
|
91
93
|
'word-filter.patterns': {
|
|
92
94
|
kind: 'filter',
|
|
@@ -247,7 +249,9 @@ export const HOOKS = {
|
|
|
247
249
|
},
|
|
248
250
|
'view.redirect-notice': {
|
|
249
251
|
kind: 'filter',
|
|
250
|
-
purpose:
|
|
252
|
+
purpose:
|
|
253
|
+
'The interstitial shown after a mutation, before the meta refresh fires. The target is re-checked against the board after the filter runs, so this ' +
|
|
254
|
+
'cannot send a member off-site.',
|
|
251
255
|
},
|
|
252
256
|
|
|
253
257
|
/* ---- Posting ---- */
|
package/src/host.ts
CHANGED
|
@@ -10,18 +10,40 @@ export interface HostLogger {
|
|
|
10
10
|
readonly error: (message: string, detail: Record<string, unknown>) => void
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export interface PluginFailure {
|
|
14
|
+
readonly pluginKey: string
|
|
15
|
+
readonly hook: string
|
|
16
|
+
readonly message: string
|
|
17
|
+
readonly threshold: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a failure goes once the host has counted it. The host stays
|
|
22
|
+
* synchronous and fire-and-forgets; whoever supplies this owns the write.
|
|
23
|
+
*/
|
|
24
|
+
export interface PluginHealthSink {
|
|
25
|
+
readonly failed: (failure: PluginFailure) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DurablyDisabledPlugin {
|
|
29
|
+
readonly key: string
|
|
30
|
+
readonly reason: string
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
export interface PluginHostOptions {
|
|
14
34
|
readonly plugins: readonly PluginDefinition[]
|
|
15
35
|
readonly logger?: HostLogger | undefined
|
|
16
36
|
readonly failureThreshold?: number | undefined
|
|
17
37
|
readonly slowCallMs?: number | undefined
|
|
18
38
|
readonly now?: (() => number) | undefined
|
|
39
|
+
readonly health?: PluginHealthSink | undefined
|
|
19
40
|
}
|
|
20
41
|
|
|
21
42
|
export interface PluginHealth {
|
|
22
43
|
readonly key: string
|
|
23
44
|
readonly enabled: boolean
|
|
24
45
|
readonly operatorDisabled: boolean
|
|
46
|
+
readonly durablyDisabled: boolean
|
|
25
47
|
readonly disabledReason: string | null
|
|
26
48
|
readonly calls: number
|
|
27
49
|
readonly failures: number
|
|
@@ -41,6 +63,7 @@ interface Entry {
|
|
|
41
63
|
interface Stats {
|
|
42
64
|
enabled: boolean
|
|
43
65
|
operatorDisabled: boolean
|
|
66
|
+
durablyDisabled: boolean
|
|
44
67
|
disabledReason: string | null
|
|
45
68
|
calls: number
|
|
46
69
|
failures: number
|
|
@@ -62,17 +85,20 @@ export class PluginHost {
|
|
|
62
85
|
readonly #failureThreshold: number
|
|
63
86
|
readonly #slowCallMs: number
|
|
64
87
|
readonly #now: () => number
|
|
88
|
+
readonly #health: PluginHealthSink
|
|
65
89
|
|
|
66
90
|
constructor(options: PluginHostOptions) {
|
|
67
91
|
this.#logger = options.logger ?? { warn: () => {}, error: () => {} }
|
|
68
92
|
this.#failureThreshold = options.failureThreshold ?? 5
|
|
69
93
|
this.#slowCallMs = options.slowCallMs ?? 50
|
|
70
94
|
this.#now = options.now ?? (() => performance.now())
|
|
95
|
+
this.#health = options.health ?? { failed: () => {} }
|
|
71
96
|
|
|
72
97
|
for (const plugin of options.plugins) {
|
|
73
98
|
this.#stats.set(plugin.key, {
|
|
74
99
|
enabled: true,
|
|
75
100
|
operatorDisabled: false,
|
|
101
|
+
durablyDisabled: false,
|
|
76
102
|
disabledReason: null,
|
|
77
103
|
calls: 0,
|
|
78
104
|
failures: 0,
|
|
@@ -182,6 +208,7 @@ export class PluginHost {
|
|
|
182
208
|
key,
|
|
183
209
|
enabled: stats.enabled && !stats.operatorDisabled,
|
|
184
210
|
operatorDisabled: stats.operatorDisabled,
|
|
211
|
+
durablyDisabled: stats.durablyDisabled,
|
|
185
212
|
disabledReason: stats.disabledReason,
|
|
186
213
|
calls: stats.calls,
|
|
187
214
|
failures: stats.failures,
|
|
@@ -207,6 +234,32 @@ export class PluginHost {
|
|
|
207
234
|
}
|
|
208
235
|
}
|
|
209
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Reconcile against the durable record. The stored rows are the answer,
|
|
239
|
+
* not a hint: a plugin an operator has cleared comes back without a
|
|
240
|
+
* restart, and one another instance switched off is off here too.
|
|
241
|
+
*/
|
|
242
|
+
setDurablyDisabled(rows: readonly DurablyDisabledPlugin[]): void {
|
|
243
|
+
const disabled = new Map(rows.map((row) => [row.key, row.reason]))
|
|
244
|
+
|
|
245
|
+
for (const [key, stats] of this.#stats) {
|
|
246
|
+
const reason = disabled.get(key)
|
|
247
|
+
|
|
248
|
+
if (reason !== undefined) {
|
|
249
|
+
stats.durablyDisabled = true
|
|
250
|
+
stats.enabled = false
|
|
251
|
+
stats.disabledReason = reason
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (stats.durablyDisabled) {
|
|
256
|
+
stats.durablyDisabled = false
|
|
257
|
+
stats.enabled = true
|
|
258
|
+
stats.disabledReason = null
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
210
263
|
isEnabled(pluginKey: string): boolean {
|
|
211
264
|
return this.#isEnabled(pluginKey)
|
|
212
265
|
}
|
|
@@ -277,12 +330,10 @@ export class PluginHost {
|
|
|
277
330
|
stats.lastError = { hook, message }
|
|
278
331
|
|
|
279
332
|
this.#logger.error('plugin hook failed', { plugin: pluginKey, hook, message })
|
|
333
|
+
this.#health.failed({ pluginKey, hook, message, threshold: this.#failureThreshold })
|
|
280
334
|
|
|
281
335
|
if (stats.failures >= this.#failureThreshold) {
|
|
282
|
-
this.disable(
|
|
283
|
-
pluginKey,
|
|
284
|
-
`${stats.failures} failures in this process, most recently in "${hook}"`,
|
|
285
|
-
)
|
|
336
|
+
this.disable(pluginKey, `${stats.failures} failures, most recently in "${hook}"`)
|
|
286
337
|
}
|
|
287
338
|
}
|
|
288
339
|
}
|
package/src/index.ts
CHANGED
|
@@ -8,13 +8,21 @@ export {
|
|
|
8
8
|
isHookName,
|
|
9
9
|
} from './hooks'
|
|
10
10
|
export {
|
|
11
|
+
type DurablyDisabledPlugin,
|
|
11
12
|
emptyHost,
|
|
12
13
|
type HostLogger,
|
|
13
14
|
isFilter,
|
|
15
|
+
type PluginFailure,
|
|
14
16
|
type PluginHealth,
|
|
17
|
+
type PluginHealthSink,
|
|
15
18
|
PluginHost,
|
|
16
19
|
type PluginHostOptions,
|
|
17
20
|
} from './host'
|
|
21
|
+
export {
|
|
22
|
+
type PluginNavigationPlacement,
|
|
23
|
+
pluginKeyOfNavigation,
|
|
24
|
+
pluginNavigationPlacements,
|
|
25
|
+
} from './navigation'
|
|
18
26
|
export type {
|
|
19
27
|
DraftPayload,
|
|
20
28
|
ForumRef,
|
|
@@ -44,6 +52,8 @@ export {
|
|
|
44
52
|
type PluginDefinition,
|
|
45
53
|
type PluginHooks,
|
|
46
54
|
type PluginMigration,
|
|
55
|
+
type PluginNavigationAudience,
|
|
56
|
+
type PluginNavigationItem,
|
|
47
57
|
type PluginNotificationKind,
|
|
48
58
|
type PluginPageAccess,
|
|
49
59
|
type PluginPageContext,
|
|
@@ -59,6 +69,7 @@ export {
|
|
|
59
69
|
type PluginViewer,
|
|
60
70
|
pluginAdminPath,
|
|
61
71
|
pluginAdminRoutePath,
|
|
72
|
+
pluginNavigationKey,
|
|
62
73
|
pluginNotificationKindId,
|
|
63
74
|
pluginPagePath,
|
|
64
75
|
pluginRoutePath,
|
|
@@ -79,6 +90,11 @@ export {
|
|
|
79
90
|
REGION_NAMES,
|
|
80
91
|
type RegionSpec,
|
|
81
92
|
} from './regions'
|
|
93
|
+
export {
|
|
94
|
+
renderingSignature,
|
|
95
|
+
rendersStoredContent,
|
|
96
|
+
STORED_RENDER_HOOKS,
|
|
97
|
+
} from './rendering'
|
|
82
98
|
export {
|
|
83
99
|
type PluginData,
|
|
84
100
|
type PluginGrantRow,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type PluginDefinition,
|
|
3
|
+
type PluginNavigationAudience,
|
|
4
|
+
pluginNavigationKey,
|
|
5
|
+
pluginPagePath,
|
|
6
|
+
} from './plugin'
|
|
7
|
+
|
|
8
|
+
export interface PluginNavigationPlacement {
|
|
9
|
+
readonly key: string
|
|
10
|
+
readonly href: string
|
|
11
|
+
readonly audience: PluginNavigationAudience
|
|
12
|
+
/** The namespaced key of the sibling item this one sits under by default. */
|
|
13
|
+
readonly parentKey: string | null
|
|
14
|
+
readonly label: string
|
|
15
|
+
readonly labelKey: string | null
|
|
16
|
+
readonly labelArgs: Record<string, string | number> | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function pluginNavigationPlacements(
|
|
20
|
+
plugins: readonly PluginDefinition[],
|
|
21
|
+
): readonly PluginNavigationPlacement[] {
|
|
22
|
+
return plugins.flatMap((plugin) =>
|
|
23
|
+
(plugin.navigation ?? []).map((item) => ({
|
|
24
|
+
key: pluginNavigationKey(plugin.key, item.key),
|
|
25
|
+
href: pluginPagePath(plugin.key, item.path),
|
|
26
|
+
audience: item.audience ?? ('all' as const),
|
|
27
|
+
parentKey: item.under === undefined ? null : pluginNavigationKey(plugin.key, item.under),
|
|
28
|
+
label: item.label,
|
|
29
|
+
labelKey: item.labelKey ?? null,
|
|
30
|
+
labelArgs: (item.labelArgs ?? null) as Record<string, string | number> | null,
|
|
31
|
+
})),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function pluginKeyOfNavigation(key: string | null): string | null {
|
|
36
|
+
if (key === null) return null
|
|
37
|
+
|
|
38
|
+
const parts = key.split('.')
|
|
39
|
+
return parts.length === 3 && parts[0] === 'plugin' ? (parts[1] ?? null) : null
|
|
40
|
+
}
|
package/src/payloads.ts
CHANGED
|
@@ -148,15 +148,22 @@ export interface HookSignatures {
|
|
|
148
148
|
value: string
|
|
149
149
|
context: ViewerRef & { source: 'post' | 'signature' | 'pm' }
|
|
150
150
|
}
|
|
151
|
-
'markdown.directives': {
|
|
151
|
+
'markdown.directives': {
|
|
152
|
+
value: readonly { readonly name: string; readonly block: boolean }[]
|
|
153
|
+
context: ForumRef | Record<string, never>
|
|
154
|
+
}
|
|
152
155
|
'post.body.html': { value: string; context: PostRef & ViewerRef }
|
|
153
156
|
'signature.html': { value: string; context: ViewerRef & { authorId: number } }
|
|
154
157
|
'smilies.list': {
|
|
155
|
-
value: readonly { readonly code: string; readonly
|
|
156
|
-
context:
|
|
158
|
+
value: readonly { readonly code: string; readonly src: string; readonly alt?: string }[]
|
|
159
|
+
context: Record<string, never>
|
|
157
160
|
}
|
|
158
161
|
'word-filter.patterns': {
|
|
159
|
-
value: readonly {
|
|
162
|
+
value: readonly {
|
|
163
|
+
readonly pattern: string
|
|
164
|
+
readonly replacement: string
|
|
165
|
+
readonly wholeWord: boolean
|
|
166
|
+
}[]
|
|
160
167
|
context: Record<string, never>
|
|
161
168
|
}
|
|
162
169
|
|
package/src/plugin.ts
CHANGED
|
@@ -149,6 +149,35 @@ export interface PluginBoardPage {
|
|
|
149
149
|
readonly render: (context: PluginPageContext) => ReactNode | Promise<ReactNode>
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
type TranslationArgs = Parameters<Translator['t']>[1]
|
|
153
|
+
|
|
154
|
+
export type PluginNavigationAudience = 'all' | 'guests' | 'members' | 'staff'
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A board navigation entry a plugin asks for.
|
|
158
|
+
*
|
|
159
|
+
* It is a **request, not a placement**: the host writes it into the board's own
|
|
160
|
+
* navigation table, where an operator renames, reorders, nests, scopes or hides
|
|
161
|
+
* it like any other item. A plugin that appended to the header model instead
|
|
162
|
+
* would put a link where no operator could reach it.
|
|
163
|
+
*/
|
|
164
|
+
export interface PluginNavigationItem {
|
|
165
|
+
readonly key: string
|
|
166
|
+
readonly label: string
|
|
167
|
+
readonly labelKey?: string | undefined
|
|
168
|
+
readonly labelArgs?: TranslationArgs | undefined
|
|
169
|
+
/** A page path of this plugin's own — '' is its index page. */
|
|
170
|
+
readonly path: string
|
|
171
|
+
readonly audience?: PluginNavigationAudience | undefined
|
|
172
|
+
/**
|
|
173
|
+
* The `key` of another of this plugin's navigation items to sit under by
|
|
174
|
+
* default. The board's navigation is one level deep, so the item named here
|
|
175
|
+
* must itself be top-level. Like `audience`, it only seeds the row — the
|
|
176
|
+
* operator re-nests it like any other item.
|
|
177
|
+
*/
|
|
178
|
+
readonly under?: string | undefined
|
|
179
|
+
}
|
|
180
|
+
|
|
152
181
|
export interface PluginRuntimeContext {
|
|
153
182
|
readonly settings: Readonly<Record<string, string | number | boolean>>
|
|
154
183
|
readonly logger: {
|
|
@@ -169,6 +198,7 @@ export interface PluginNotificationKind {
|
|
|
169
198
|
readonly description: string
|
|
170
199
|
readonly descriptionKey?: string | undefined
|
|
171
200
|
readonly emailByDefault?: boolean | undefined
|
|
201
|
+
readonly pushByDefault?: boolean | undefined
|
|
172
202
|
}
|
|
173
203
|
|
|
174
204
|
export interface PluginDefinition {
|
|
@@ -192,6 +222,7 @@ export interface PluginDefinition {
|
|
|
192
222
|
readonly routes?: readonly PluginRoute[] | undefined
|
|
193
223
|
readonly pages?: readonly PluginBoardPage[] | undefined
|
|
194
224
|
readonly notifications?: readonly PluginNotificationKind[] | undefined
|
|
225
|
+
readonly navigation?: readonly PluginNavigationItem[] | undefined
|
|
195
226
|
readonly allowedRedirectHosts?: readonly string[] | undefined
|
|
196
227
|
|
|
197
228
|
readonly onInstall?: ((context: PluginRuntimeContext) => Promise<void> | void) | undefined
|
|
@@ -540,6 +571,64 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
|
|
|
540
571
|
}
|
|
541
572
|
}
|
|
542
573
|
|
|
574
|
+
assertUnique(
|
|
575
|
+
where,
|
|
576
|
+
'navigation item',
|
|
577
|
+
(plugin.navigation ?? []).map((item) => item.key),
|
|
578
|
+
)
|
|
579
|
+
for (const item of plugin.navigation ?? []) {
|
|
580
|
+
if (!TASK_ID_PATTERN.test(item.key)) {
|
|
581
|
+
throw new Error(
|
|
582
|
+
`${where}: navigation key "${item.key}" must be lower-case letters, digits and ` +
|
|
583
|
+
`hyphens. It becomes plugin.<plugin>.<key> in the board’s navigation, which is ` +
|
|
584
|
+
`how an operator’s edits survive a redeploy.`,
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
if (item.label.trim() === '') {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`${where}: navigation item "${item.key}" needs a label — it is what the item is ` +
|
|
590
|
+
`called until an operator renames it.`,
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
if (item.path !== '' && !PAGE_PATH_PATTERN.test(item.path)) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
`${where}: navigation item "${item.key}" points at "${item.path}", which is not ` +
|
|
596
|
+
`one of this plugin’s page paths. A navigation item links to a page the ` +
|
|
597
|
+
`plugin declares, not to anywhere on the board.`,
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
if (!(plugin.pages ?? []).some((page) => page.path === item.path)) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
`${where}: navigation item "${item.key}" points at the page "${item.path}", ` +
|
|
603
|
+
`which this plugin does not declare.`,
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
if (
|
|
607
|
+
item.audience !== undefined &&
|
|
608
|
+
!['all', 'guests', 'members', 'staff'].includes(item.audience)
|
|
609
|
+
) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`${where}: navigation item "${item.key}" audience must be all, guests, members ` +
|
|
612
|
+
`or staff.`,
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
if (item.under !== undefined) {
|
|
616
|
+
const parent = (plugin.navigation ?? []).find((other) => other.key === item.under)
|
|
617
|
+
if (parent === undefined || parent.key === item.key) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
`${where}: navigation item "${item.key}" sits under "${item.under}", which is ` +
|
|
620
|
+
`not another navigation item of this plugin.`,
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
if (parent.under !== undefined) {
|
|
624
|
+
throw new Error(
|
|
625
|
+
`${where}: navigation item "${item.key}" cannot sit under "${item.under}", ` +
|
|
626
|
+
`which is itself nested — the board’s navigation is one level deep.`,
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
543
632
|
assertUnique(
|
|
544
633
|
where,
|
|
545
634
|
'notification kind',
|
|
@@ -608,3 +697,7 @@ export function pluginAdminRoutePath(pluginKey: string, path: string): string {
|
|
|
608
697
|
export function pluginPagePath(pluginKey: string, path: string): string {
|
|
609
698
|
return `/plugins/${pluginKey}${path === '' ? '' : `/${path}`}`
|
|
610
699
|
}
|
|
700
|
+
|
|
701
|
+
export function pluginNavigationKey(pluginKey: string, itemKey: string): string {
|
|
702
|
+
return `plugin.${pluginKey}.${itemKey}`
|
|
703
|
+
}
|
package/src/rendering.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { HookName } from './hooks'
|
|
2
|
+
import type { PluginDefinition } from './plugin'
|
|
3
|
+
|
|
4
|
+
export const STORED_RENDER_HOOKS: readonly HookName[] = [
|
|
5
|
+
'markdown.parse.text',
|
|
6
|
+
'markdown.render.html',
|
|
7
|
+
'markdown.directives',
|
|
8
|
+
'smilies.list',
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
export function rendersStoredContent(plugin: PluginDefinition): boolean {
|
|
12
|
+
const hooks = Object.keys(plugin.hooks ?? {})
|
|
13
|
+
return STORED_RENDER_HOOKS.some((hook) => hooks.includes(hook))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderingSignature(plugins: readonly PluginDefinition[]): number {
|
|
17
|
+
const parts = plugins
|
|
18
|
+
.filter(rendersStoredContent)
|
|
19
|
+
.map((plugin) => `${plugin.key}@${plugin.version}`)
|
|
20
|
+
.sort()
|
|
21
|
+
|
|
22
|
+
let hash = 2_166_136_261
|
|
23
|
+
for (const character of parts.join(',')) {
|
|
24
|
+
hash ^= character.codePointAt(0) ?? 0
|
|
25
|
+
hash = Math.imul(hash, 16_777_619)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (hash >>> 1) + 1
|
|
29
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -104,6 +104,7 @@ export interface PluginNotifyKindInput {
|
|
|
104
104
|
readonly description: string
|
|
105
105
|
readonly descriptionKey?: string | undefined
|
|
106
106
|
readonly emailByDefault?: boolean | undefined
|
|
107
|
+
readonly pushByDefault?: boolean | undefined
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
export interface PluginNotificationKindSpec {
|
|
@@ -115,6 +116,8 @@ export interface PluginNotificationKindSpec {
|
|
|
115
116
|
readonly audience: 'member'
|
|
116
117
|
readonly emailByDefault: boolean
|
|
117
118
|
readonly emailConfigurable: true
|
|
119
|
+
readonly pushByDefault: boolean
|
|
120
|
+
readonly pushConfigurable: true
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
export interface PluginNotifyBackend {
|
|
@@ -140,6 +143,8 @@ export function pluginNotificationKindSpecs(
|
|
|
140
143
|
audience: 'member',
|
|
141
144
|
emailByDefault: kind.emailByDefault ?? true,
|
|
142
145
|
emailConfigurable: true,
|
|
146
|
+
pushByDefault: kind.pushByDefault ?? false,
|
|
147
|
+
pushConfigurable: true,
|
|
143
148
|
}))
|
|
144
149
|
}
|
|
145
150
|
|