@meith/plugin-kit 0.1.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/LICENSE.md +165 -0
- package/package.json +31 -0
- package/src/hooks.ts +497 -0
- package/src/host.ts +283 -0
- package/src/index.ts +118 -0
- package/src/payloads.ts +414 -0
- package/src/plugin.ts +559 -0
- package/src/rate-limit.ts +42 -0
- package/src/regions.ts +48 -0
- package/src/runtime.ts +187 -0
- package/src/settings.ts +131 -0
package/src/payloads.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each hook is handed.
|
|
3
|
+
*
|
|
4
|
+
* One entry per hook in `HOOKS`, both directions asserted at the bottom, so a
|
|
5
|
+
* hook without a payload (or a payload without a hook) fails `pnpm typecheck`
|
|
6
|
+
* rather than surfacing as `unknown` inside a plugin.
|
|
7
|
+
*
|
|
8
|
+
* ## Every hook has the same shape: a value and a context
|
|
9
|
+
*
|
|
10
|
+
* `value` is the thing. For a **filter** it is what the handler returns a
|
|
11
|
+
* replacement for; for an **event** it is what happened. `context` is the
|
|
12
|
+
* surroundings — who, where, which thread — and is never returned by anything.
|
|
13
|
+
*
|
|
14
|
+
* Uniformity is worth more here than expressiveness. A plugin author learns one
|
|
15
|
+
* signature, the host has one dispatch path, and the generated documentation has
|
|
16
|
+
* one table shape. The alternative — a bespoke argument list per hook — reads
|
|
17
|
+
* better in isolation and is unusable across eighty of them.
|
|
18
|
+
*
|
|
19
|
+
* ## The payload rule: plain data, and the actor is not in it
|
|
20
|
+
*
|
|
21
|
+
* Payloads are JSON-shaped, for the same reasons view models are (they cross to
|
|
22
|
+
* the outbox, to a webhook, and into a log). And they carry a **viewer** —
|
|
23
|
+
* `{ userId, isGuest }` — never an `Actor`.
|
|
24
|
+
*
|
|
25
|
+
* That is a security boundary, not an ergonomic one. An `Actor` carries resolved
|
|
26
|
+
* group membership and is the input to `authorization.can()`; handing one to a
|
|
27
|
+
* plugin invites the plugin to make its own permission decision from the group
|
|
28
|
+
* ids, which is exactly what R4 forbids of core code and doubly so of code an
|
|
29
|
+
* operator installed from a directory. Plugins are handed the *result* of
|
|
30
|
+
* authorization — a link that is present or absent, a post that is in the list
|
|
31
|
+
* or is not — and never its inputs.
|
|
32
|
+
*
|
|
33
|
+
* ## Ids, not rows
|
|
34
|
+
*
|
|
35
|
+
* Most event payloads are ids plus the few fields a handler cannot look up
|
|
36
|
+
* cheaply. A plugin that needs the whole post can fetch it; a payload carrying
|
|
37
|
+
* every row would be a second read model to keep in step with the schema, and
|
|
38
|
+
* the first migration would break every plugin.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type {
|
|
42
|
+
AnnouncementModel,
|
|
43
|
+
BoardIndexModel,
|
|
44
|
+
BoardStatsModel,
|
|
45
|
+
CategoryBlockModel,
|
|
46
|
+
ErrorNoticeModel,
|
|
47
|
+
FooterModel,
|
|
48
|
+
ForumJumpModel,
|
|
49
|
+
ForumDisplayModel,
|
|
50
|
+
ForumRowSlotModel,
|
|
51
|
+
HeaderModel,
|
|
52
|
+
LatestPostsModel,
|
|
53
|
+
LatestThreadsModel,
|
|
54
|
+
MemberProfileModel,
|
|
55
|
+
NavigationModel,
|
|
56
|
+
NoticeModel,
|
|
57
|
+
PaginationModel,
|
|
58
|
+
PostActionsSlotModel,
|
|
59
|
+
PostBitSlotModel,
|
|
60
|
+
PostFormModel,
|
|
61
|
+
RedirectNoticeModel,
|
|
62
|
+
SearchFormModel,
|
|
63
|
+
ShellModel,
|
|
64
|
+
SubforumListModel,
|
|
65
|
+
ThreadRowSlotModel,
|
|
66
|
+
ThreadViewModel,
|
|
67
|
+
UserPanelModel,
|
|
68
|
+
WhoIsOnlineModel,
|
|
69
|
+
} from '@meith/theme-kit'
|
|
70
|
+
|
|
71
|
+
import type { HookName } from './hooks'
|
|
72
|
+
|
|
73
|
+
/* ------------------------------------------------------------------ *
|
|
74
|
+
* Shared context pieces
|
|
75
|
+
* ------------------------------------------------------------------ */
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Who is looking, as much as a plugin is told.
|
|
79
|
+
*
|
|
80
|
+
* Not an `Actor`. See this file's header — the omission is the point.
|
|
81
|
+
*/
|
|
82
|
+
export interface ViewerRef {
|
|
83
|
+
readonly userId: number | null
|
|
84
|
+
readonly isGuest: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The request a hook is running inside, when there is one. */
|
|
88
|
+
export interface RequestRef {
|
|
89
|
+
/** The request's correlation id, so a plugin's own logging joins up with the board's. */
|
|
90
|
+
readonly requestId: string | null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ForumRef {
|
|
94
|
+
readonly forumId: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ThreadRef {
|
|
98
|
+
readonly threadId: number
|
|
99
|
+
readonly forumId: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PostRef {
|
|
103
|
+
readonly postId: number
|
|
104
|
+
readonly threadId: number
|
|
105
|
+
readonly forumId: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface UserRef {
|
|
109
|
+
readonly userId: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A moderator acting, with the reason they gave. */
|
|
113
|
+
export interface ModerationRef {
|
|
114
|
+
readonly moderatorId: number
|
|
115
|
+
readonly reason: string | null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A draft, as the composer has it before anything is written. */
|
|
119
|
+
export interface DraftPayload {
|
|
120
|
+
readonly subject: string | null
|
|
121
|
+
readonly body: string
|
|
122
|
+
readonly prefixId: number | null
|
|
123
|
+
readonly forumId: number
|
|
124
|
+
readonly authorId: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Validation messages. Empty means "no objection"; anything else refuses. */
|
|
128
|
+
export type ValidationMessages = readonly string[]
|
|
129
|
+
|
|
130
|
+
/* ------------------------------------------------------------------ *
|
|
131
|
+
* The registry
|
|
132
|
+
* ------------------------------------------------------------------ */
|
|
133
|
+
|
|
134
|
+
export interface HookSignatures {
|
|
135
|
+
/* ---- Content rendering ---- */
|
|
136
|
+
'markdown.parse.text': {
|
|
137
|
+
value: string
|
|
138
|
+
context: ViewerRef & { source: 'post' | 'signature' | 'pm' }
|
|
139
|
+
}
|
|
140
|
+
'markdown.render.html': {
|
|
141
|
+
value: string
|
|
142
|
+
context: ViewerRef & { source: 'post' | 'signature' | 'pm' }
|
|
143
|
+
}
|
|
144
|
+
'markdown.directives': { value: readonly string[]; context: ForumRef | Record<string, never> }
|
|
145
|
+
'post.body.html': { value: string; context: PostRef & ViewerRef }
|
|
146
|
+
'signature.html': { value: string; context: ViewerRef & { authorId: number } }
|
|
147
|
+
'smilies.list': {
|
|
148
|
+
value: readonly { readonly code: string; readonly imageUrl: string }[]
|
|
149
|
+
context: ViewerRef
|
|
150
|
+
}
|
|
151
|
+
'word-filter.patterns': {
|
|
152
|
+
value: readonly { readonly pattern: string; readonly replacement: string }[]
|
|
153
|
+
context: Record<string, never>
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ---- View models ---- */
|
|
157
|
+
'view.header': { value: HeaderModel; context: ViewerRef & RequestRef }
|
|
158
|
+
'view.user-panel': { value: UserPanelModel; context: ViewerRef & RequestRef }
|
|
159
|
+
'view.navigation': { value: NavigationModel; context: ViewerRef & RequestRef }
|
|
160
|
+
'view.footer': { value: FooterModel; context: ViewerRef & RequestRef }
|
|
161
|
+
'view.forum-jump': { value: ForumJumpModel; context: ViewerRef & RequestRef }
|
|
162
|
+
'view.announcement': { value: AnnouncementModel; context: ViewerRef }
|
|
163
|
+
'view.board-index': { value: BoardIndexModel; context: ViewerRef }
|
|
164
|
+
'view.forum-row': { value: ForumRowSlotModel; context: ViewerRef }
|
|
165
|
+
'view.thread-row': { value: ThreadRowSlotModel; context: ViewerRef & ForumRef }
|
|
166
|
+
'view.post-bit': { value: PostBitSlotModel; context: ViewerRef & ThreadRef }
|
|
167
|
+
'view.post-actions': { value: PostActionsSlotModel; context: ViewerRef & ThreadRef }
|
|
168
|
+
'view.member-profile': { value: MemberProfileModel; context: ViewerRef }
|
|
169
|
+
'view.board-stats': { value: BoardStatsModel; context: ViewerRef }
|
|
170
|
+
'view.who-is-online': { value: WhoIsOnlineModel; context: ViewerRef }
|
|
171
|
+
'view.latest-threads': { value: LatestThreadsModel; context: ViewerRef }
|
|
172
|
+
'view.latest-posts': { value: LatestPostsModel; context: ViewerRef }
|
|
173
|
+
'view.pagination': { value: PaginationModel; context: ViewerRef }
|
|
174
|
+
'view.search-form': { value: SearchFormModel; context: ViewerRef }
|
|
175
|
+
'view.error-notice': { value: ErrorNoticeModel; context: ViewerRef & RequestRef }
|
|
176
|
+
'view.shell': { value: ShellModel; context: ViewerRef & RequestRef }
|
|
177
|
+
'view.notice': { value: NoticeModel; context: ViewerRef }
|
|
178
|
+
'view.category-block': { value: CategoryBlockModel; context: ViewerRef }
|
|
179
|
+
'view.subforum-list': { value: SubforumListModel; context: ViewerRef & ForumRef }
|
|
180
|
+
'view.forum-display': { value: ForumDisplayModel; context: ViewerRef & ForumRef }
|
|
181
|
+
'view.thread-view': { value: ThreadViewModel; context: ViewerRef & ThreadRef }
|
|
182
|
+
'view.post-form': { value: PostFormModel; context: ViewerRef }
|
|
183
|
+
'view.redirect-notice': { value: RedirectNoticeModel; context: ViewerRef }
|
|
184
|
+
|
|
185
|
+
/* ---- Posting ---- */
|
|
186
|
+
'thread.create.validate': { value: ValidationMessages; context: { draft: DraftPayload } }
|
|
187
|
+
'thread.create.before': { value: DraftPayload; context: ViewerRef }
|
|
188
|
+
'thread.created': { value: ThreadRef & { authorId: number; subject: string }; context: ViewerRef }
|
|
189
|
+
'post.create.validate': { value: ValidationMessages; context: { draft: DraftPayload; threadId: number } }
|
|
190
|
+
'post.create.before': { value: DraftPayload; context: ViewerRef & { threadId: number } }
|
|
191
|
+
'post.created': { value: PostRef & { authorId: number }; context: ViewerRef }
|
|
192
|
+
'post.edit.before': {
|
|
193
|
+
value: { readonly body: string; readonly reason: string | null }
|
|
194
|
+
context: PostRef & ViewerRef
|
|
195
|
+
}
|
|
196
|
+
'post.edited': { value: PostRef & { editorId: number; revision: number }; context: ViewerRef }
|
|
197
|
+
'post.delete.before': { value: PostRef; context: ModerationRef }
|
|
198
|
+
'post.deleted': { value: PostRef; context: ModerationRef }
|
|
199
|
+
'post.restored': { value: PostRef; context: ModerationRef }
|
|
200
|
+
'thread.moved': {
|
|
201
|
+
value: { readonly threadId: number; readonly fromForumId: number; readonly toForumId: number }
|
|
202
|
+
context: ModerationRef
|
|
203
|
+
}
|
|
204
|
+
'thread.merged': {
|
|
205
|
+
value: { readonly keptThreadId: number; readonly mergedThreadId: number; readonly postCount: number }
|
|
206
|
+
context: ModerationRef
|
|
207
|
+
}
|
|
208
|
+
'thread.split': {
|
|
209
|
+
value: { readonly sourceThreadId: number; readonly newThreadId: number; readonly postCount: number }
|
|
210
|
+
context: ModerationRef
|
|
211
|
+
}
|
|
212
|
+
'thread.locked': { value: ThreadRef & { isLocked: boolean }; context: ModerationRef }
|
|
213
|
+
'thread.stickied': { value: ThreadRef & { isSticky: boolean }; context: ModerationRef }
|
|
214
|
+
'attachment.upload.validate': {
|
|
215
|
+
value: ValidationMessages
|
|
216
|
+
context: {
|
|
217
|
+
readonly filename: string
|
|
218
|
+
readonly bytes: number
|
|
219
|
+
/** What the *bytes* say it is, not what the name claims. */
|
|
220
|
+
readonly detectedMimeType: string
|
|
221
|
+
readonly uploaderId: number
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
'attachment.uploaded': {
|
|
225
|
+
value: { readonly attachmentId: number; readonly postId: number | null; readonly bytes: number }
|
|
226
|
+
context: ViewerRef
|
|
227
|
+
}
|
|
228
|
+
'attachment.deleted': { value: { readonly attachmentId: number }; context: ViewerRef }
|
|
229
|
+
'poll.created': { value: ThreadRef & { pollId: number; optionCount: number }; context: ViewerRef }
|
|
230
|
+
'poll.voted': { value: { readonly pollId: number; readonly optionId: number }; context: ViewerRef }
|
|
231
|
+
'rating.recorded': {
|
|
232
|
+
value: { readonly threadId: number; readonly rating: number; readonly average: number }
|
|
233
|
+
context: ViewerRef
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ---- Moderation ---- */
|
|
237
|
+
'report.created': {
|
|
238
|
+
value: {
|
|
239
|
+
readonly reportId: number
|
|
240
|
+
readonly target: 'post' | 'thread' | 'user' | 'pm'
|
|
241
|
+
readonly targetId: number
|
|
242
|
+
readonly reporterId: number
|
|
243
|
+
}
|
|
244
|
+
context: RequestRef
|
|
245
|
+
}
|
|
246
|
+
'report.resolved': {
|
|
247
|
+
value: { readonly reportId: number; readonly resolution: 'actioned' | 'rejected' }
|
|
248
|
+
context: ModerationRef
|
|
249
|
+
}
|
|
250
|
+
'approval.queued': {
|
|
251
|
+
value: { readonly kind: 'thread' | 'post' | 'attachment'; readonly id: number }
|
|
252
|
+
context: ViewerRef
|
|
253
|
+
}
|
|
254
|
+
'approval.decided': {
|
|
255
|
+
value: {
|
|
256
|
+
readonly kind: 'thread' | 'post' | 'attachment'
|
|
257
|
+
readonly id: number
|
|
258
|
+
readonly approved: boolean
|
|
259
|
+
}
|
|
260
|
+
context: ModerationRef
|
|
261
|
+
}
|
|
262
|
+
'warning.issued': {
|
|
263
|
+
value: {
|
|
264
|
+
readonly warningId: number
|
|
265
|
+
readonly userId: number
|
|
266
|
+
readonly points: number
|
|
267
|
+
readonly expiresAt: string | null
|
|
268
|
+
}
|
|
269
|
+
context: ModerationRef
|
|
270
|
+
}
|
|
271
|
+
'warning.revoked': { value: { readonly warningId: number; readonly userId: number }; context: ModerationRef }
|
|
272
|
+
'moderation.logged': {
|
|
273
|
+
value: { readonly action: string; readonly targetId: number | null }
|
|
274
|
+
context: ModerationRef
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ---- Identity ---- */
|
|
278
|
+
'user.register.validate': {
|
|
279
|
+
value: ValidationMessages
|
|
280
|
+
context: { readonly username: string; readonly email: string; readonly ipPrefix: string | null }
|
|
281
|
+
}
|
|
282
|
+
'user.registered': { value: UserRef & { username: string; requiresActivation: boolean }; context: RequestRef }
|
|
283
|
+
'user.activated': { value: UserRef; context: RequestRef }
|
|
284
|
+
'user.login.attempted': {
|
|
285
|
+
value: {
|
|
286
|
+
readonly username: string
|
|
287
|
+
readonly outcome: 'ok' | 'bad-credentials' | 'locked-out' | 'banned'
|
|
288
|
+
/** Truncated. Never a full address. */
|
|
289
|
+
readonly ipPrefix: string | null
|
|
290
|
+
}
|
|
291
|
+
context: RequestRef
|
|
292
|
+
}
|
|
293
|
+
'user.logged-in': { value: UserRef; context: RequestRef }
|
|
294
|
+
'user.logged-out': { value: UserRef & { reason: 'requested' | 'revoked' }; context: RequestRef }
|
|
295
|
+
'user.banned': {
|
|
296
|
+
value: UserRef & { expiresAt: string | null }
|
|
297
|
+
context: ModerationRef
|
|
298
|
+
}
|
|
299
|
+
'user.unbanned': { value: UserRef & { expired: boolean }; context: ModerationRef }
|
|
300
|
+
'user.groups.changed': {
|
|
301
|
+
value: UserRef & { primaryGroupId: number; secondaryGroupIds: readonly number[] }
|
|
302
|
+
context: RequestRef
|
|
303
|
+
}
|
|
304
|
+
'user.profile.updated': { value: UserRef & { fields: readonly string[] }; context: RequestRef }
|
|
305
|
+
'user.merged': { value: { readonly keptUserId: number; readonly mergedUserId: number }; context: RequestRef }
|
|
306
|
+
'user.deleted': { value: UserRef & { reason: 'pruned' | 'deleted' }; context: RequestRef }
|
|
307
|
+
|
|
308
|
+
/* ---- Mail, notifications, messages ---- */
|
|
309
|
+
'notification.create.before': {
|
|
310
|
+
value: {
|
|
311
|
+
readonly userId: number
|
|
312
|
+
readonly kind: string
|
|
313
|
+
readonly subjectText: string
|
|
314
|
+
readonly href: string
|
|
315
|
+
} | null
|
|
316
|
+
context: RequestRef
|
|
317
|
+
}
|
|
318
|
+
'notification.created': { value: { readonly notificationId: number; readonly userId: number }; context: RequestRef }
|
|
319
|
+
'mail.send.before': {
|
|
320
|
+
value: {
|
|
321
|
+
readonly to: string
|
|
322
|
+
readonly subject: string
|
|
323
|
+
readonly textBody: string
|
|
324
|
+
readonly htmlBody: string | null
|
|
325
|
+
} | null
|
|
326
|
+
context: { readonly template: string }
|
|
327
|
+
}
|
|
328
|
+
'mail.sent': { value: { readonly to: string; readonly template: string }; context: RequestRef }
|
|
329
|
+
'pm.send.before': {
|
|
330
|
+
value: {
|
|
331
|
+
readonly senderId: number
|
|
332
|
+
readonly recipientIds: readonly number[]
|
|
333
|
+
readonly subject: string
|
|
334
|
+
readonly body: string
|
|
335
|
+
} | null
|
|
336
|
+
context: RequestRef
|
|
337
|
+
}
|
|
338
|
+
'pm.sent': { value: { readonly messageId: number; readonly recipientIds: readonly number[] }; context: RequestRef }
|
|
339
|
+
'subscription.changed': {
|
|
340
|
+
value: {
|
|
341
|
+
readonly userId: number
|
|
342
|
+
readonly target: 'thread' | 'forum'
|
|
343
|
+
readonly targetId: number
|
|
344
|
+
readonly subscribed: boolean
|
|
345
|
+
}
|
|
346
|
+
context: RequestRef
|
|
347
|
+
}
|
|
348
|
+
'reputation.changed': {
|
|
349
|
+
value: { readonly userId: number; readonly delta: number; readonly total: number }
|
|
350
|
+
context: ViewerRef
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* ---- Search, discovery, syndication ---- */
|
|
354
|
+
'search.query.before': { value: string; context: ViewerRef }
|
|
355
|
+
'search.results': {
|
|
356
|
+
value: readonly { readonly postId: number; readonly threadId: number; readonly rank: number }[]
|
|
357
|
+
context: ViewerRef & { terms: string }
|
|
358
|
+
}
|
|
359
|
+
'feed.items': {
|
|
360
|
+
value: readonly {
|
|
361
|
+
readonly title: string
|
|
362
|
+
readonly href: string
|
|
363
|
+
readonly publishedAt: string
|
|
364
|
+
readonly summary: string
|
|
365
|
+
}[]
|
|
366
|
+
/** Always a guest: a feed is cached under a shared URL. */
|
|
367
|
+
context: { readonly feed: 'board' | 'forum' | 'thread' }
|
|
368
|
+
}
|
|
369
|
+
'sitemap.entries': {
|
|
370
|
+
value: readonly { readonly href: string; readonly lastModified: string | null }[]
|
|
371
|
+
context: { readonly chunk: number }
|
|
372
|
+
}
|
|
373
|
+
'metadata.page': {
|
|
374
|
+
value: {
|
|
375
|
+
readonly title: string
|
|
376
|
+
readonly description: string | null
|
|
377
|
+
readonly canonical: string
|
|
378
|
+
readonly imageUrl: string | null
|
|
379
|
+
}
|
|
380
|
+
context: { readonly route: string }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* ---- Admin and system ---- */
|
|
384
|
+
'admin.navigation': {
|
|
385
|
+
value: readonly { readonly label: string; readonly href: string }[]
|
|
386
|
+
context: ViewerRef
|
|
387
|
+
}
|
|
388
|
+
'settings.saved': { value: { readonly keys: readonly string[] }; context: { readonly adminId: number } }
|
|
389
|
+
'task.run.before': { value: { readonly taskId: string }; context: Record<string, never> }
|
|
390
|
+
'task.run.after': {
|
|
391
|
+
value: { readonly taskId: string; readonly ok: boolean; readonly durationMs: number }
|
|
392
|
+
context: Record<string, never>
|
|
393
|
+
}
|
|
394
|
+
'cache.invalidated': { value: { readonly tag: string }; context: Record<string, never> }
|
|
395
|
+
'plugin.enabled': { value: { readonly pluginKey: string }; context: Record<string, never> }
|
|
396
|
+
'plugin.disabled': {
|
|
397
|
+
value: { readonly pluginKey: string; readonly reason: 'operator' | 'failures' }
|
|
398
|
+
context: Record<string, never>
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** What a handler for `K` is given. */
|
|
403
|
+
export type HookValue<K extends HookName> = HookSignatures[K]['value']
|
|
404
|
+
export type HookContext<K extends HookName> = HookSignatures[K]['context']
|
|
405
|
+
|
|
406
|
+
/* ------------------------------------------------------------------ *
|
|
407
|
+
* Compile-time proofs
|
|
408
|
+
* ------------------------------------------------------------------ */
|
|
409
|
+
|
|
410
|
+
/** Fails with the offending name in the message rather than a bare `never`. */
|
|
411
|
+
type AssertNever<T extends never> = T
|
|
412
|
+
|
|
413
|
+
type _NoHookWithoutSignature = AssertNever<Exclude<HookName, keyof HookSignatures>>
|
|
414
|
+
type _NoSignatureWithoutHook = AssertNever<Exclude<keyof HookSignatures, HookName>>
|