@meith/plugin-kit 0.30.1 → 0.32.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.30.1",
3
+ "version": "0.32.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.30.1",
24
- "@meith/theme-kit": "^0.30.1"
23
+ "@meith/core": "^0.32.0",
24
+ "@meith/theme-kit": "^0.32.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": "^19.2.0"
package/src/host.ts CHANGED
@@ -5,11 +5,12 @@ import type { HookContext, HookValue } from './payloads'
5
5
  import type {
6
6
  HookRegistration,
7
7
  HookRuntime,
8
- PluginContribution,
9
8
  PluginDefinition,
9
+ PluginRegionContribution,
10
10
  PluginRuntimeContext,
11
+ ThreadRowBadgesContribution,
11
12
  } from './plugin'
12
- import type { PluginRegion, PluginRegionContext } from './regions'
13
+ import type { PluginRegion, PluginRegionContext, ThreadRowBadgesContext } from './regions'
13
14
 
14
15
  export interface HostLogger {
15
16
  readonly warn: (message: string, detail: Record<string, unknown>) => void
@@ -83,8 +84,13 @@ export class PluginHost {
83
84
  readonly #entries = new Map<HookName, Entry[]>()
84
85
  readonly #contributions = new Map<
85
86
  PluginRegion,
86
- { pluginKey: string; priority: number; contribution: PluginContribution }[]
87
+ { pluginKey: string; priority: number; render: PluginRegionContribution['render'] }[]
87
88
  >()
89
+ readonly #badgeContributions: {
90
+ pluginKey: string
91
+ priority: number
92
+ render: ThreadRowBadgesContribution['render']
93
+ }[] = []
88
94
  readonly #stats = new Map<string, Stats>()
89
95
  readonly #logger: HostLogger
90
96
  readonly #failureThreshold: number
@@ -140,11 +146,20 @@ export class PluginHost {
140
146
  }
141
147
 
142
148
  for (const contribution of plugin.contributions ?? []) {
149
+ if (contribution.region === 'threadrow.badges') {
150
+ this.#badgeContributions.push({
151
+ pluginKey: plugin.key,
152
+ priority: contribution.priority ?? DEFAULT_PRIORITY,
153
+ render: contribution.render,
154
+ })
155
+ continue
156
+ }
157
+
143
158
  const list = this.#contributions.get(contribution.region) ?? []
144
159
  list.push({
145
160
  pluginKey: plugin.key,
146
161
  priority: contribution.priority ?? DEFAULT_PRIORITY,
147
- contribution,
162
+ render: contribution.render,
148
163
  })
149
164
  this.#contributions.set(contribution.region, list)
150
165
  }
@@ -159,6 +174,7 @@ export class PluginHost {
159
174
 
160
175
  for (const list of this.#entries.values()) list.sort(byPriorityThenKey)
161
176
  for (const list of this.#contributions.values()) list.sort(byPriorityThenKey)
177
+ this.#badgeContributions.sort(byPriorityThenKey)
162
178
  }
163
179
 
164
180
  async applyFilter<K extends HookName>(
@@ -219,7 +235,7 @@ export class PluginHost {
219
235
 
220
236
  const started = this.#now()
221
237
  try {
222
- const node = await entry.contribution.render({
238
+ const node = await entry.render({
223
239
  ...context,
224
240
  runtime: this.#runtimeFor(entry.pluginKey),
225
241
  })
@@ -232,6 +248,35 @@ export class PluginHost {
232
248
  return nodes
233
249
  }
234
250
 
251
+ async renderThreadRowBadges(
252
+ context: Omit<ThreadRowBadgesContext, 'runtime'>,
253
+ ): Promise<ReadonlyMap<number, readonly { key: string; node: ReactNode }[]>> {
254
+ const byThread = new Map<number, { key: string; node: ReactNode }[]>()
255
+ if (this.#badgeContributions.length === 0) return byThread
256
+
257
+ for (const entry of this.#badgeContributions) {
258
+ if (!this.#isEnabled(entry.pluginKey)) continue
259
+
260
+ const started = this.#now()
261
+ try {
262
+ const badges = await entry.render({
263
+ ...context,
264
+ runtime: this.#runtimeFor(entry.pluginKey),
265
+ })
266
+ this.#record(entry.pluginKey, 'threadrow.badges', this.#now() - started)
267
+ for (const [threadId, node] of badges) {
268
+ if (node === null || node === undefined) continue
269
+ const list = byThread.get(threadId) ?? []
270
+ list.push({ key: entry.pluginKey, node })
271
+ byThread.set(threadId, list)
272
+ }
273
+ } catch (error) {
274
+ this.#fail(entry.pluginKey, 'threadrow.badges', error)
275
+ }
276
+ }
277
+ return byThread
278
+ }
279
+
235
280
  health(): readonly PluginHealth[] {
236
281
  return [...this.#stats.entries()]
237
282
  .map(([key, stats]) => ({
package/src/index.ts CHANGED
@@ -53,18 +53,21 @@ export {
53
53
  type PluginContribution,
54
54
  type PluginDefinition,
55
55
  type PluginHooks,
56
+ type PluginIntervalTask,
56
57
  type PluginMigration,
57
58
  type PluginNavigationAudience,
58
59
  type PluginNavigationItem,
59
60
  type PluginNotificationKind,
60
61
  type PluginPageAccess,
61
62
  type PluginPageContext,
63
+ type PluginRegionContribution,
62
64
  type PluginRequest,
63
65
  type PluginResponse,
64
66
  type PluginRoute,
65
67
  type PluginRouteAccess,
66
68
  type PluginRouteRateLimit,
67
69
  type PluginRuntimeContext,
70
+ type PluginScheduledTask,
68
71
  type PluginSetting,
69
72
  type PluginSettingType,
70
73
  type PluginTask,
@@ -76,8 +79,11 @@ export {
76
79
  pluginPagePath,
77
80
  pluginRoutePath,
78
81
  pluginSettingKey,
82
+ pluginStaffPagePath,
79
83
  pluginTablePrefix,
80
84
  pluginTaskId,
85
+ type ThreadRowBadges,
86
+ type ThreadRowBadgesContribution,
81
87
  } from './plugin'
82
88
  export {
83
89
  createRouteRateLimiter,
@@ -91,6 +97,8 @@ export {
91
97
  type PluginRegionContext,
92
98
  REGION_NAMES,
93
99
  type RegionSpec,
100
+ type ThreadRowBadgeSubject,
101
+ type ThreadRowBadgesContext,
94
102
  } from './regions'
95
103
  export {
96
104
  renderingSignature,
package/src/navigation.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  type PluginNavigationAudience,
4
4
  pluginNavigationKey,
5
5
  pluginPagePath,
6
+ pluginStaffPagePath,
6
7
  } from './plugin'
7
8
 
8
9
  export interface PluginNavigationPlacement {
@@ -21,7 +22,9 @@ export function pluginNavigationPlacements(
21
22
  return plugins.flatMap((plugin) =>
22
23
  (plugin.navigation ?? []).map((item) => ({
23
24
  key: pluginNavigationKey(plugin.key, item.key),
24
- href: pluginPagePath(plugin.key, item.path),
25
+ href: ((plugin.pages ?? []).find((page) => page.path === item.path)?.access === 'staff'
26
+ ? pluginStaffPagePath
27
+ : pluginPagePath)(plugin.key, item.path),
25
28
  audience: item.audience ?? ('all' as const),
26
29
  parentKey: item.under === undefined ? null : pluginNavigationKey(plugin.key, item.under),
27
30
  label: item.label,
package/src/plugin.ts CHANGED
@@ -2,11 +2,17 @@ export type { HookRuntime, PluginRuntimeContext } from './runtime'
2
2
 
3
3
  import type { ReactNode } from 'react'
4
4
 
5
+ import { CADENCE_REFERENCE, nextRun, parseCron } from '@meith/core'
5
6
  import type { Translator } from '@meith/theme-kit'
6
7
 
7
8
  import { type HOOKS, type HookName, isHookName } from './hooks'
8
9
  import type { HookContext, HookValue } from './payloads'
9
- import { isPluginRegion, type PluginRegion, type PluginRegionContext } from './regions'
10
+ import {
11
+ isPluginRegion,
12
+ type PluginRegion,
13
+ type PluginRegionContext,
14
+ type ThreadRowBadgesContext,
15
+ } from './regions'
10
16
  import type { HookRuntime, PluginRuntimeContext } from './runtime'
11
17
 
12
18
  export type FilterHandler<K extends HookName> = (
@@ -61,12 +67,23 @@ export interface PluginMigration {
61
67
  readonly statements: readonly string[]
62
68
  }
63
69
 
64
- export interface PluginTask {
70
+ interface PluginTaskBase {
65
71
  readonly id: string
66
- readonly intervalSeconds: number
67
72
  readonly run: (context: PluginRuntimeContext) => Promise<void> | void
68
73
  }
69
74
 
75
+ export interface PluginIntervalTask extends PluginTaskBase {
76
+ readonly intervalSeconds: number
77
+ readonly schedule?: undefined
78
+ }
79
+
80
+ export interface PluginScheduledTask extends PluginTaskBase {
81
+ readonly schedule: string
82
+ readonly intervalSeconds?: undefined
83
+ }
84
+
85
+ export type PluginTask = PluginIntervalTask | PluginScheduledTask
86
+
70
87
  export interface PluginAdminPage {
71
88
  readonly path: string
72
89
  readonly title: string
@@ -75,12 +92,22 @@ export interface PluginAdminPage {
75
92
  readonly render: (context: PluginAdminPageContext) => ReactNode | Promise<ReactNode>
76
93
  }
77
94
 
78
- export interface PluginContribution {
79
- readonly region: PluginRegion
95
+ export interface PluginRegionContribution {
96
+ readonly region: Exclude<PluginRegion, 'threadrow.badges'>
80
97
  readonly priority?: number | undefined
81
98
  readonly render: (context: PluginRegionContext) => ReactNode | Promise<ReactNode>
82
99
  }
83
100
 
101
+ export type ThreadRowBadges = ReadonlyMap<number, ReactNode>
102
+
103
+ export interface ThreadRowBadgesContribution {
104
+ readonly region: 'threadrow.badges'
105
+ readonly priority?: number | undefined
106
+ readonly render: (context: ThreadRowBadgesContext) => ThreadRowBadges | Promise<ThreadRowBadges>
107
+ }
108
+
109
+ export type PluginContribution = PluginRegionContribution | ThreadRowBadgesContribution
110
+
84
111
  export interface PluginViewer {
85
112
  readonly userId: number | null
86
113
  readonly isGuest: boolean
@@ -108,8 +135,8 @@ export type PluginResponse =
108
135
  }
109
136
  | { readonly kind: 'redirect'; readonly to: string }
110
137
 
111
- export type PluginRouteAccess = 'anonymous' | 'member' | 'admin'
112
- export type PluginPageAccess = 'anonymous' | 'member'
138
+ export type PluginRouteAccess = 'anonymous' | 'member' | 'staff' | 'admin'
139
+ export type PluginPageAccess = 'anonymous' | 'member' | 'staff'
113
140
 
114
141
  export interface PluginRouteRateLimit {
115
142
  readonly limit: number
@@ -438,7 +465,24 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
438
465
  `${where}: task id "${task.id}" must be lower-case letters, digits and hyphens.`,
439
466
  )
440
467
  }
441
- if (!Number.isInteger(task.intervalSeconds) || task.intervalSeconds < 60) {
468
+
469
+ const hasInterval = task.intervalSeconds !== undefined
470
+ const hasSchedule = task.schedule !== undefined
471
+ if (hasInterval === hasSchedule) {
472
+ throw new Error(
473
+ `${where}: task "${task.id}" must set exactly one of intervalSeconds (a fixed cadence) ` +
474
+ 'or schedule (a five-field cron expression evaluated in UTC).',
475
+ )
476
+ }
477
+
478
+ if (task.schedule !== undefined) {
479
+ try {
480
+ nextRun(parseCron(task.schedule), CADENCE_REFERENCE)
481
+ } catch (error) {
482
+ const detail = error instanceof Error ? error.message : String(error)
483
+ throw new Error(`${where}: task "${task.id}" ${detail}.`)
484
+ }
485
+ } else if (!Number.isInteger(task.intervalSeconds) || task.intervalSeconds < 60) {
442
486
  throw new Error(
443
487
  `${where}: task "${task.id}" has an interval of ${task.intervalSeconds}s. The tick ` +
444
488
  'is minute-granular at best on a serverless platform; anything under 60s is a ' +
@@ -487,9 +531,14 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
487
531
  if (route.method !== 'GET' && route.method !== 'POST') {
488
532
  throw new Error(`${where}: route "${route.path}" method must be GET or POST.`)
489
533
  }
490
- if (route.access !== 'anonymous' && route.access !== 'member' && route.access !== 'admin') {
534
+ if (
535
+ route.access !== 'anonymous' &&
536
+ route.access !== 'member' &&
537
+ route.access !== 'staff' &&
538
+ route.access !== 'admin'
539
+ ) {
491
540
  throw new Error(
492
- `${where}: route "${route.path}" access must be "anonymous", "member" or "admin".`,
541
+ `${where}: route "${route.path}" access must be "anonymous", "member", "staff" or "admin".`,
493
542
  )
494
543
  }
495
544
  if (typeof route.handler !== 'function') {
@@ -539,8 +588,10 @@ export function definePlugin(plugin: PluginDefinition): PluginDefinition {
539
588
  if (page.title.trim() === '') {
540
589
  throw new Error(`${where}: the page at "${page.path}" needs a title.`)
541
590
  }
542
- if (page.access !== 'anonymous' && page.access !== 'member') {
543
- throw new Error(`${where}: page "${page.path}" access must be "anonymous" or "member".`)
591
+ if (page.access !== 'anonymous' && page.access !== 'member' && page.access !== 'staff') {
592
+ throw new Error(
593
+ `${where}: page "${page.path}" access must be "anonymous", "member" or "staff".`,
594
+ )
544
595
  }
545
596
  if (typeof page.render !== 'function') {
546
597
  throw new Error(`${where}: page "${page.path}" needs a render function.`)
@@ -674,6 +725,10 @@ export function pluginPagePath(pluginKey: string, path: string): string {
674
725
  return `/plugins/${pluginKey}${path === '' ? '' : `/${path}`}`
675
726
  }
676
727
 
728
+ export function pluginStaffPagePath(pluginKey: string, path: string): string {
729
+ return `/modcp/plugins/${pluginKey}${path === '' ? '' : `/${path}`}`
730
+ }
731
+
677
732
  export function pluginNavigationKey(pluginKey: string, itemKey: string): string {
678
733
  return `plugin.${pluginKey}.${itemKey}`
679
734
  }
package/src/regions.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { Translator } from '@meith/theme-kit'
2
+
1
3
  import type { HookRuntime } from './runtime'
2
4
 
3
5
  export interface RegionSpec {
@@ -8,35 +10,50 @@ export interface RegionSpec {
8
10
  export const PLUGIN_REGIONS = {
9
11
  'header.notice': {
10
12
  purpose: 'Directly below the board header, above the page body. Board-wide notices.',
11
- context: 'The viewer.',
13
+ context: 'The viewer, with the reader’s locale and a translator.',
12
14
  },
13
15
  'index.footer': {
14
16
  purpose: 'The bottom of the board index, below the statistics block.',
15
- context: 'The viewer.',
17
+ context: 'The viewer, with the reader’s locale and a translator.',
16
18
  },
17
19
  'postbit.badges': {
18
20
  purpose:
19
21
  'Beside a post author’s name. Runs once per post on every thread page — the ' +
20
22
  'most expensive region on the board, and the one to keep trivial.',
21
- context: 'The viewer, the post id and the author id.',
23
+ context:
24
+ 'The viewer, the post id and the author id, with the reader’s locale and a translator.',
22
25
  },
23
26
  'postbit.footer': {
24
27
  purpose: 'Below a post body, above its actions.',
25
- context: 'The viewer, the post id and the author id.',
28
+ context:
29
+ 'The viewer, the post id and the author id, with the reader’s locale and a translator.',
30
+ },
31
+ 'threadrow.badges': {
32
+ purpose:
33
+ 'Beside a thread’s title in a listing, to mark threads across a forum page. A ' +
34
+ 'batch region: unlike every other region it runs once per page, not once per ' +
35
+ 'row — a listing of twenty threads is one call, returning a badge per thread ' +
36
+ 'id — because a forum page is on a tight budget and a per-row region there is ' +
37
+ 'twenty calls before the page has drawn a thing.',
38
+ context:
39
+ 'The viewer and the page’s visible threads, each as a thread id and its author ' +
40
+ 'id, with the reader’s locale and a translator.',
26
41
  },
27
42
  'thread.header': {
28
43
  purpose:
29
44
  'Above the first post of a thread, below its title. Runs once per thread page, ' +
30
45
  '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.',
46
+ context:
47
+ 'The viewer, the thread id and the thread author’s id, with the reader’s locale and ' +
48
+ 'a translator.',
32
49
  },
33
50
  'profile.panel': {
34
51
  purpose: 'A panel on a member’s profile, below the standard fields.',
35
- context: 'The viewer and the profile’s member id.',
52
+ context: 'The viewer and the profile’s member id, with the reader’s locale and a translator.',
36
53
  },
37
54
  'admin.dashboard': {
38
55
  purpose: 'A card on the admin dashboard. Only rendered for administrators.',
39
- context: 'The viewer.',
56
+ context: 'The viewer, with the reader’s locale and a translator.',
40
57
  },
41
58
  } as const satisfies Readonly<Record<string, RegionSpec>>
42
59
 
@@ -53,5 +70,20 @@ export interface PluginRegionContext {
53
70
  readonly viewer: { readonly userId: number | null; readonly isGuest: boolean }
54
71
  readonly subjectId: number | null
55
72
  readonly authorId: number | null
73
+ readonly locale: string
74
+ readonly t: Translator
75
+ readonly runtime: HookRuntime
76
+ }
77
+
78
+ export interface ThreadRowBadgeSubject {
79
+ readonly threadId: number
80
+ readonly authorId: number | null
81
+ }
82
+
83
+ export interface ThreadRowBadgesContext {
84
+ readonly viewer: { readonly userId: number | null; readonly isGuest: boolean }
85
+ readonly threads: readonly ThreadRowBadgeSubject[]
86
+ readonly locale: string
87
+ readonly t: Translator
56
88
  readonly runtime: HookRuntime
57
89
  }
package/src/runtime.ts CHANGED
@@ -25,13 +25,15 @@ export interface PluginGrants {
25
25
  }): Promise<void>
26
26
 
27
27
  list(userId: number): Promise<readonly PluginGrantRow[]>
28
+
29
+ holds(userId: number, groupKey: string): Promise<boolean>
28
30
  }
29
31
 
30
32
  export function unavailablePluginGrants(reason: string): PluginGrants {
31
33
  const refuse = async (): Promise<never> => {
32
34
  throw new Error(`Plugin grants are unavailable: ${reason}`)
33
35
  }
34
- return { grant: refuse, extend: refuse, revoke: refuse, list: refuse }
36
+ return { grant: refuse, extend: refuse, revoke: refuse, list: refuse, holds: refuse }
35
37
  }
36
38
 
37
39
  export interface PluginData {