@meith/plugin-kit 0.21.2 → 0.23.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.21.2",
3
+ "version": "0.23.0",
4
4
  "description": "The SDK for writing a Meith plugin: typed manifests, hooks, routes, pages and migrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,8 +20,8 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/core": "^0.21.2",
24
- "@meith/theme-kit": "^0.21.2"
23
+ "@meith/core": "^0.23.0",
24
+ "@meith/theme-kit": "^0.23.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": "^19.2.0"
package/src/host.ts CHANGED
@@ -2,7 +2,13 @@ import type { ReactNode } from 'react'
2
2
 
3
3
  import { HOOKS, type HookName } from './hooks'
4
4
  import type { HookContext, HookValue } from './payloads'
5
- import type { HookRegistration, PluginContribution, PluginDefinition } from './plugin'
5
+ import type {
6
+ HookRegistration,
7
+ HookRuntime,
8
+ PluginContribution,
9
+ PluginDefinition,
10
+ PluginRuntimeContext,
11
+ } from './plugin'
6
12
  import type { PluginRegion, PluginRegionContext } from './regions'
7
13
 
8
14
  export interface HostLogger {
@@ -17,10 +23,6 @@ export interface PluginFailure {
17
23
  readonly threshold: number
18
24
  }
19
25
 
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
26
  export interface PluginHealthSink {
25
27
  readonly failed: (failure: PluginFailure) => void
26
28
  }
@@ -30,6 +32,8 @@ export interface DurablyDisabledPlugin {
30
32
  readonly reason: string
31
33
  }
32
34
 
35
+ export type PluginRuntimeProvider = (pluginKey: string) => Promise<PluginRuntimeContext>
36
+
33
37
  export interface PluginHostOptions {
34
38
  readonly plugins: readonly PluginDefinition[]
35
39
  readonly logger?: HostLogger | undefined
@@ -37,6 +41,7 @@ export interface PluginHostOptions {
37
41
  readonly slowCallMs?: number | undefined
38
42
  readonly now?: (() => number) | undefined
39
43
  readonly health?: PluginHealthSink | undefined
44
+ readonly runtime?: PluginRuntimeProvider | undefined
40
45
  }
41
46
 
42
47
  export interface PluginHealth {
@@ -52,7 +57,7 @@ export interface PluginHealth {
52
57
  readonly lastError: { readonly hook: string; readonly message: string } | null
53
58
  }
54
59
 
55
- type StoredHandler = (value: unknown, context: unknown) => unknown
60
+ type StoredHandler = (value: unknown, context: unknown, runtime: HookRuntime) => unknown
56
61
 
57
62
  interface Entry {
58
63
  readonly pluginKey: string
@@ -86,6 +91,7 @@ export class PluginHost {
86
91
  readonly #slowCallMs: number
87
92
  readonly #now: () => number
88
93
  readonly #health: PluginHealthSink
94
+ readonly #runtime: PluginRuntimeProvider
89
95
 
90
96
  constructor(options: PluginHostOptions) {
91
97
  this.#logger = options.logger ?? { warn: () => {}, error: () => {} }
@@ -93,6 +99,15 @@ export class PluginHost {
93
99
  this.#slowCallMs = options.slowCallMs ?? 50
94
100
  this.#now = options.now ?? (() => performance.now())
95
101
  this.#health = options.health ?? { failed: () => {} }
102
+ this.#runtime =
103
+ options.runtime ??
104
+ ((pluginKey) =>
105
+ Promise.reject(
106
+ new Error(
107
+ `plugin "${pluginKey}": this host was built without a runtime provider, so a hook ` +
108
+ 'handler cannot reach settings, data, grants, users or notifications here.',
109
+ ),
110
+ ))
96
111
 
97
112
  for (const plugin of options.plugins) {
98
113
  this.#stats.set(plugin.key, {
@@ -158,7 +173,9 @@ export class PluginHost {
158
173
  for (const entry of entries) {
159
174
  if (!this.#isEnabled(entry.pluginKey)) continue
160
175
 
161
- const result = await this.#call(entry.pluginKey, name, () => entry.handler(current, context))
176
+ const result = await this.#call(entry.pluginKey, name, () =>
177
+ entry.handler(current, context, this.#runtimeFor(entry.pluginKey)),
178
+ )
162
179
 
163
180
  if (result.ok && result.value !== undefined) current = result.value as HookValue<K>
164
181
  }
@@ -175,14 +192,24 @@ export class PluginHost {
175
192
 
176
193
  for (const entry of entries) {
177
194
  if (!this.#isEnabled(entry.pluginKey)) continue
178
- await this.#call(entry.pluginKey, name, () => entry.handler(value, context))
195
+ await this.#call(entry.pluginKey, name, () =>
196
+ entry.handler(value, context, this.#runtimeFor(entry.pluginKey)),
197
+ )
179
198
  }
180
199
  }
181
200
 
182
- renderRegion(
201
+ #runtimeFor(pluginKey: string): HookRuntime {
202
+ let pending: Promise<PluginRuntimeContext> | null = null
203
+ return () => {
204
+ pending ??= this.#runtime(pluginKey)
205
+ return pending
206
+ }
207
+ }
208
+
209
+ async renderRegion(
183
210
  region: PluginRegion,
184
- context: PluginRegionContext,
185
- ): readonly { key: string; node: ReactNode }[] {
211
+ context: Omit<PluginRegionContext, 'runtime'>,
212
+ ): Promise<readonly { key: string; node: ReactNode }[]> {
186
213
  const entries = this.#contributions.get(region)
187
214
  if (entries === undefined) return []
188
215
 
@@ -192,7 +219,10 @@ export class PluginHost {
192
219
 
193
220
  const started = this.#now()
194
221
  try {
195
- const node = entry.contribution.render(context)
222
+ const node = await entry.contribution.render({
223
+ ...context,
224
+ runtime: this.#runtimeFor(entry.pluginKey),
225
+ })
196
226
  this.#record(entry.pluginKey, region, this.#now() - started)
197
227
  if (node !== null && node !== undefined) nodes.push({ key: entry.pluginKey, node })
198
228
  } catch (error) {
@@ -234,11 +264,6 @@ export class PluginHost {
234
264
  }
235
265
  }
236
266
 
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
267
  setDurablyDisabled(rows: readonly DurablyDisabledPlugin[]): void {
243
268
  const disabled = new Map(rows.map((row) => [row.key, row.reason]))
244
269
 
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  type PluginHealthSink,
18
18
  PluginHost,
19
19
  type PluginHostOptions,
20
+ type PluginRuntimeProvider,
20
21
  } from './host'
21
22
  export {
22
23
  type PluginNavigationPlacement,
@@ -44,6 +45,7 @@ export {
44
45
  type FilterHandler,
45
46
  type HookHandler,
46
47
  type HookRegistration,
48
+ type HookRuntime,
47
49
  MAX_ROUTE_BODY_BYTES,
48
50
  type PluginAdminPage,
49
51
  type PluginAdminPageContext,
@@ -107,9 +109,11 @@ export {
107
109
  type PluginUsers,
108
110
  pluginNotificationKindSpecs,
109
111
  pluginNotify,
112
+ unavailableHookRuntime,
110
113
  unavailablePluginData,
111
114
  unavailablePluginGrants,
112
115
  unavailablePluginNotify,
116
+ unavailablePluginRuntime,
113
117
  unavailablePluginUsers,
114
118
  } from './runtime'
115
119
  export {
package/src/navigation.ts CHANGED
@@ -9,7 +9,6 @@ export interface PluginNavigationPlacement {
9
9
  readonly key: string
10
10
  readonly href: string
11
11
  readonly audience: PluginNavigationAudience
12
- /** The namespaced key of the sibling item this one sits under by default. */
13
12
  readonly parentKey: string | null
14
13
  readonly label: string
15
14
  readonly labelKey: string | null
package/src/plugin.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export type { HookRuntime, PluginRuntimeContext } from './runtime'
2
+
1
3
  import type { ReactNode } from 'react'
2
4
 
3
5
  import type { Translator } from '@meith/theme-kit'
@@ -5,16 +7,18 @@ import type { Translator } from '@meith/theme-kit'
5
7
  import { type HOOKS, type HookName, isHookName } from './hooks'
6
8
  import type { HookContext, HookValue } from './payloads'
7
9
  import { isPluginRegion, type PluginRegion, type PluginRegionContext } from './regions'
8
- import type { PluginData, PluginGrants, PluginNotify, PluginUsers } from './runtime'
10
+ import type { HookRuntime, PluginRuntimeContext } from './runtime'
9
11
 
10
12
  export type FilterHandler<K extends HookName> = (
11
13
  value: HookValue<K>,
12
14
  context: HookContext<K>,
15
+ runtime: HookRuntime,
13
16
  ) => HookValue<K> | Promise<HookValue<K>>
14
17
 
15
18
  export type EventHandler<K extends HookName> = (
16
19
  value: HookValue<K>,
17
20
  context: HookContext<K>,
21
+ runtime: HookRuntime,
18
22
  ) => void | Promise<void>
19
23
 
20
24
  export type HookHandler<K extends HookName> = (typeof HOOKS)[K]['kind'] extends 'filter'
@@ -74,7 +78,7 @@ export interface PluginAdminPage {
74
78
  export interface PluginContribution {
75
79
  readonly region: PluginRegion
76
80
  readonly priority?: number | undefined
77
- readonly render: (context: PluginRegionContext) => ReactNode
81
+ readonly render: (context: PluginRegionContext) => ReactNode | Promise<ReactNode>
78
82
  }
79
83
 
80
84
  export interface PluginViewer {
@@ -153,44 +157,16 @@ type TranslationArgs = Parameters<Translator['t']>[1]
153
157
 
154
158
  export type PluginNavigationAudience = 'all' | 'guests' | 'members' | 'staff'
155
159
 
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
160
  export interface PluginNavigationItem {
165
161
  readonly key: string
166
162
  readonly label: string
167
163
  readonly labelKey?: string | undefined
168
164
  readonly labelArgs?: TranslationArgs | undefined
169
- /** A page path of this plugin's own — '' is its index page. */
170
165
  readonly path: string
171
166
  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
167
  readonly under?: string | undefined
179
168
  }
180
169
 
181
- export interface PluginRuntimeContext {
182
- readonly settings: Readonly<Record<string, string | number | boolean>>
183
- readonly logger: {
184
- readonly info: (message: string, detail?: Record<string, unknown>) => void
185
- readonly warn: (message: string, detail?: Record<string, unknown>) => void
186
- readonly error: (message: string, detail?: Record<string, unknown>) => void
187
- }
188
- readonly grants: PluginGrants
189
- readonly data: PluginData
190
- readonly users: PluginUsers
191
- readonly notify: PluginNotify
192
- }
193
-
194
170
  export interface PluginNotificationKind {
195
171
  readonly key: string
196
172
  readonly title: string
@@ -231,12 +207,6 @@ export interface PluginDefinition {
231
207
  readonly onUninstall?: ((context: PluginRuntimeContext) => Promise<void> | void) | undefined
232
208
  }
233
209
 
234
- /**
235
- * The rule a plugin key (and, by marketplace-gen.mjs's own mirrored copy, a
236
- * marketplace listing key) has to satisfy. Exported so
237
- * scripts/marketplace-gen.test.ts can pin its own copy directly against
238
- * this one rather than trusting the two stay in sync by comment alone.
239
- */
240
210
  export const KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
241
211
  const SETTING_KEY_PATTERN = /^[a-z][a-z0-9_]{1,39}$/
242
212
  const MIGRATION_ID_PATTERN = /^\d{4}_[a-z0-9_]{1,60}$/
package/src/regions.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { HookRuntime } from './runtime'
2
+
1
3
  export interface RegionSpec {
2
4
  readonly purpose: string
3
5
  readonly context: string
@@ -22,6 +24,12 @@ export const PLUGIN_REGIONS = {
22
24
  purpose: 'Below a post body, above its actions.',
23
25
  context: 'The viewer, the post id and the author id.',
24
26
  },
27
+ 'thread.header': {
28
+ purpose:
29
+ 'Above the first post of a thread, below its title. Runs once per thread page, ' +
30
+ 'so unlike postbit.* it can afford to read from the plugin’s own tables.',
31
+ context: 'The viewer, the thread id and the thread author’s id.',
32
+ },
25
33
  'profile.panel': {
26
34
  purpose: 'A panel on a member’s profile, below the standard fields.',
27
35
  context: 'The viewer and the profile’s member id.',
@@ -45,4 +53,5 @@ export interface PluginRegionContext {
45
53
  readonly viewer: { readonly userId: number | null; readonly isGuest: boolean }
46
54
  readonly subjectId: number | null
47
55
  readonly authorId: number | null
56
+ readonly runtime: HookRuntime
48
57
  }
package/src/runtime.ts CHANGED
@@ -246,3 +246,33 @@ export function pluginNotify(
246
246
  },
247
247
  }
248
248
  }
249
+
250
+ export interface PluginRuntimeContext {
251
+ readonly settings: Readonly<Record<string, string | number | boolean>>
252
+ readonly logger: {
253
+ readonly info: (message: string, detail?: Record<string, unknown>) => void
254
+ readonly warn: (message: string, detail?: Record<string, unknown>) => void
255
+ readonly error: (message: string, detail?: Record<string, unknown>) => void
256
+ }
257
+ readonly grants: PluginGrants
258
+ readonly data: PluginData
259
+ readonly users: PluginUsers
260
+ readonly notify: PluginNotify
261
+ }
262
+
263
+ export type HookRuntime = () => Promise<PluginRuntimeContext>
264
+
265
+ export function unavailablePluginRuntime(reason: string): PluginRuntimeContext {
266
+ return {
267
+ settings: {},
268
+ logger: { info: () => {}, warn: () => {}, error: () => {} },
269
+ grants: unavailablePluginGrants(reason),
270
+ data: unavailablePluginData(reason),
271
+ users: unavailablePluginUsers(reason),
272
+ notify: unavailablePluginNotify(reason),
273
+ }
274
+ }
275
+
276
+ export function unavailableHookRuntime(reason: string): HookRuntime {
277
+ return () => Promise.resolve(unavailablePluginRuntime(reason))
278
+ }
package/src/settings.ts CHANGED
@@ -47,11 +47,6 @@ export function parsePluginSetting(setting: PluginSetting, raw: string): PluginS
47
47
  return raw
48
48
  }
49
49
 
50
- /**
51
- * Matches a candidate select value against a setting's declared options —
52
- * trimmed and case-insensitively — and returns the *option's own* casing.
53
- * See "Settings" in docs/plugin-api.md.
54
- */
55
50
  function matchSelectOption(
56
51
  options: readonly { readonly value: string }[],
57
52
  candidate: PluginSettingValue | null,