@meith/plugin-kit 0.9.0 → 0.11.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.9.0",
3
+ "version": "0.11.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.9.0"
23
+ "@meith/theme-kit": "^0.11.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: 'The smilie set offered by the editor and substituted at render.',
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: 'The interstitial shown after a mutation, before the meta refresh fires.',
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': { value: readonly string[]; context: ForumRef | Record<string, never> }
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 imageUrl: string }[]
156
- context: ViewerRef
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 { readonly pattern: string; readonly replacement: string }[]
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
+ }
@@ -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