@basaltkit/comments 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machize Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @basaltkit/comments
2
+
3
+ Per-resource comments for Basalt: **threads** (nested replies), **@mentions**, and **resolve/reopen**, isolated by tenant, emitting **events** that connect to [`@basaltkit/realtime`](https://www.npmjs.com/package/@basaltkit/realtime) (live discussion) and [`@basaltkit/notifications`](https://www.npmjs.com/package/@basaltkit/notifications) (notify who was mentioned). You need this module when you want collaboration — commenting on a note, a project, a task.
4
+
5
+ ## What this module solves
6
+
7
+ A comment system involves more than storing text: threads with replies, extracting @mentions to notify, marking a discussion as resolved, and restricting edits to the author. This module gives you all of that — attached to **any resource** (`resourceType`:`resourceId`) and isolated by tenant — and emits events for the rest of the ecosystem to react to.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pnpm add @basaltkit/comments
13
+ ```
14
+
15
+ Depends on `@basaltkit/core` and `@basaltkit/fastify` (routes). No database required: the default store is in-memory (`CommentStore` contract for production).
16
+
17
+ ## Get started in 5 minutes
18
+
19
+ ```ts
20
+ import { createApp } from '@basaltkit/core'
21
+ import { commentsPlugin, COMMENTS, commentRoutes } from '@basaltkit/comments'
22
+ import { fastifyPlugin } from '@basaltkit/fastify'
23
+
24
+ const app = await createApp({
25
+ plugins: [commentsPlugin(), fastifyPlugin({ routes: [...commentRoutes()] })],
26
+ }).boot()
27
+
28
+ const comments = app.container.get(COMMENTS)
29
+
30
+ // add a comment to a resource
31
+ const root = await comments.on('note', 'note-1', 'acme').add({ authorId: 'u1', body: 'Great work @u2!' })
32
+
33
+ // reply (thread)
34
+ await comments.on('note', 'note-1', 'acme').add({ authorId: 'u2', body: 'Thanks!', parentId: root.id })
35
+
36
+ // get the comment tree
37
+ const tree = await comments.on('note', 'note-1', 'acme').tree()
38
+ ```
39
+
40
+ `add` extracts @mentions from the body (default `@id`), stores them on the comment, and emits `comment:mentioned` for each mentioned user.
41
+
42
+ ## Connecting to realtime and notifications
43
+
44
+ The power is in the events. Push comments live and notify mentioned users without coupling anything:
45
+
46
+ ```ts
47
+ // live discussion (realtime)
48
+ hooks.on('comment:created', ({ comment }) =>
49
+ realtime.to(comment.tenantId).channel(`${comment.resourceType}:${comment.resourceId}`).emit('comment', comment))
50
+
51
+ // notify whoever was mentioned
52
+ hooks.on('comment:mentioned', ({ comment, userId }) =>
53
+ notifications.to(userId).send('comment.mention', { by: comment.authorId, resource: comment.resourceId }))
54
+ ```
55
+
56
+ ## Routes
57
+
58
+ `commentRoutes()` (all require login; author comes from `ctx().user`):
59
+
60
+ | Route | Description |
61
+ |---|---|
62
+ | `GET /comments?resourceType=&resourceId=` | Comment tree for the resource. |
63
+ | `POST /comments` `{ resourceType, resourceId, body, parentId? }` | Create (or reply). |
64
+ | `PATCH /comments/:id` `{ body }` | Edit — **author only**. |
65
+ | `DELETE /comments/:id` | Delete — **author only**. |
66
+ | `POST /comments/:id/resolve` · `/reopen` | Resolve / reopen the discussion. |
67
+
68
+ ## API reference
69
+
70
+ ### `commentsPlugin({ store?, mentionPattern? })`
71
+
72
+ Registers the `COMMENTS` token. `mentionPattern` is a regex whose first group is the mentioned id (default `@([\w-]+)`).
73
+
74
+ ### `class Comments`
75
+
76
+ | Method | Description |
77
+ |---|---|
78
+ | `on(resourceType, resourceId, tenantId?)` | `{ add, list, tree }` for a resource. |
79
+ | `get(id, tenantId?)` | A single comment. |
80
+ | `edit(id, body, tenantId?)` | Edits and re-extracts mentions; emits `comment:updated`. |
81
+ | `remove(id, tenantId?)` | Deletes; emits `comment:deleted`. |
82
+ | `resolve(id, by, tenantId?)` · `reopen(id, tenantId?)` | Emits `comment:resolved` / `comment:reopened`. |
83
+
84
+ Without `tenantId`, uses `ctx().tenant.id` (otherwise `CommentTenantRequiredError`).
85
+
86
+ ### Events
87
+
88
+ `comment:created` · `comment:updated` · `comment:deleted` · `comment:resolved` · `comment:reopened` · `comment:mentioned` (one per mentioned user).
89
+
90
+ ## How it connects to other modules
91
+
92
+ - **`@basaltkit/realtime`** — pushes `comment:created` to the resource's channel (live discussion).
93
+ - **`@basaltkit/notifications`** — reacts to `comment:mentioned` to notify mentioned users.
94
+ - **`@basaltkit/auth` / `@basaltkit/tenancy`** — provide the user (author) and tenant from context.
@@ -0,0 +1,138 @@
1
+ import * as _basaltkit_core from '@basaltkit/core';
2
+ import { HookBus, BasaltError } from '@basaltkit/core';
3
+ import { BasaltRoute } from '@basaltkit/fastify';
4
+
5
+ /** A comment attached to a resource (`resourceType`:`resourceId`) within a tenant. */
6
+ interface Comment {
7
+ id: string;
8
+ tenantId: string;
9
+ resourceType: string;
10
+ resourceId: string;
11
+ /** Set when this comment is a reply to another. */
12
+ parentId?: string;
13
+ authorId: string;
14
+ body: string;
15
+ /** User ids extracted from @mentions in the body. */
16
+ mentions: string[];
17
+ resolvedAt?: number;
18
+ resolvedBy?: string;
19
+ editedAt?: number;
20
+ createdAt: number;
21
+ }
22
+ interface CommentPatch {
23
+ body?: string;
24
+ mentions?: string[];
25
+ editedAt?: number;
26
+ /** `undefined` clears it (reopen). */
27
+ resolvedAt?: number | undefined;
28
+ resolvedBy?: string | undefined;
29
+ }
30
+ interface CommentStore {
31
+ create(comment: Comment): Promise<void>;
32
+ find(tenantId: string, id: string): Promise<Comment | null>;
33
+ /** Every comment on a resource (the whole thread), oldest first. */
34
+ list(tenantId: string, resourceType: string, resourceId: string): Promise<Comment[]>;
35
+ update(tenantId: string, id: string, patch: CommentPatch): Promise<Comment | null>;
36
+ delete(tenantId: string, id: string): Promise<void>;
37
+ }
38
+ declare class MemoryCommentStore implements CommentStore {
39
+ private readonly records;
40
+ private key;
41
+ create(comment: Comment): Promise<void>;
42
+ find(tenantId: string, id: string): Promise<Comment | null>;
43
+ list(tenantId: string, resourceType: string, resourceId: string): Promise<Comment[]>;
44
+ update(tenantId: string, id: string, patch: CommentPatch): Promise<Comment | null>;
45
+ delete(tenantId: string, id: string): Promise<void>;
46
+ }
47
+
48
+ declare class CommentNotFoundError extends BasaltError {
49
+ readonly status = 404;
50
+ constructor();
51
+ }
52
+ declare class CommentTenantRequiredError extends BasaltError {
53
+ readonly status = 400;
54
+ constructor();
55
+ }
56
+ /** A comment plus its nested replies. */
57
+ interface CommentNode extends Comment {
58
+ replies: CommentNode[];
59
+ }
60
+ interface CommentsOptions {
61
+ store?: CommentStore;
62
+ hooks?: HookBus;
63
+ /** Regex whose first capture group is a mentioned user id. Default `@([\w-]+)`. */
64
+ mentionPattern?: RegExp;
65
+ now?: () => number;
66
+ }
67
+ interface AddCommentInput {
68
+ authorId: string;
69
+ body: string;
70
+ parentId?: string;
71
+ }
72
+ /** Everything scoped to one resource (`resourceType`:`resourceId`) of a tenant. */
73
+ interface ResourceComments {
74
+ add(input: AddCommentInput): Promise<Comment>;
75
+ list(): Promise<Comment[]>;
76
+ tree(): Promise<CommentNode[]>;
77
+ }
78
+ /**
79
+ * Per-resource comment threads with @mentions and resolve/reopen, scoped by
80
+ * tenant. Emits hooks (`comment:created`, `comment:mentioned`, …) so live
81
+ * updates (@basaltkit/realtime) and notifications wire up without coupling.
82
+ */
83
+ declare class Comments {
84
+ private readonly store;
85
+ private readonly hooks;
86
+ private readonly mentionPattern;
87
+ private readonly now;
88
+ constructor(options?: CommentsOptions);
89
+ on(resourceType: string, resourceId: string, tenantId?: string): ResourceComments;
90
+ get(id: string, tenantId?: string): Promise<Comment | null>;
91
+ edit(id: string, body: string, tenantId?: string): Promise<Comment>;
92
+ remove(id: string, tenantId?: string): Promise<void>;
93
+ resolve(id: string, resolvedBy: string, tenantId?: string): Promise<Comment>;
94
+ reopen(id: string, tenantId?: string): Promise<Comment>;
95
+ private add;
96
+ private mutate;
97
+ private mentions;
98
+ private tenant;
99
+ }
100
+
101
+ declare module '@basaltkit/core' {
102
+ interface BasaltHooks {
103
+ 'comment:created': {
104
+ comment: Comment;
105
+ };
106
+ 'comment:updated': {
107
+ comment: Comment;
108
+ };
109
+ 'comment:deleted': {
110
+ tenantId: string;
111
+ id: string;
112
+ resourceType: string;
113
+ resourceId: string;
114
+ };
115
+ 'comment:resolved': {
116
+ comment: Comment;
117
+ };
118
+ 'comment:reopened': {
119
+ comment: Comment;
120
+ };
121
+ /** One per mentioned user — wire to notifications. */
122
+ 'comment:mentioned': {
123
+ comment: Comment;
124
+ userId: string;
125
+ };
126
+ }
127
+ }
128
+ declare const COMMENTS: _basaltkit_core.Token<Comments>;
129
+ type CommentsPluginOptions = Omit<CommentsOptions, 'hooks'>;
130
+ declare function commentsPlugin(options?: CommentsPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
131
+ /**
132
+ * REST routes for the current tenant's comments, all requiring a logged-in
133
+ * user. The author is taken from `ctx().user`; editing and deleting are
134
+ * restricted to the comment's author.
135
+ */
136
+ declare function commentRoutes(): BasaltRoute[];
137
+
138
+ export { type AddCommentInput, COMMENTS, type Comment, type CommentNode, CommentNotFoundError, type CommentPatch, type CommentStore, CommentTenantRequiredError, Comments, type CommentsOptions, type CommentsPluginOptions, MemoryCommentStore, type ResourceComments, commentRoutes, commentsPlugin };
package/dist/index.js ADDED
@@ -0,0 +1,251 @@
1
+ // src/comments.ts
2
+ import { randomUUID } from "crypto";
3
+ import { BasaltError, tryCtx } from "@basaltkit/core";
4
+
5
+ // src/store.ts
6
+ var MemoryCommentStore = class {
7
+ records = /* @__PURE__ */ new Map();
8
+ key(tenantId, id) {
9
+ return `${tenantId} ${id}`;
10
+ }
11
+ async create(comment) {
12
+ this.records.set(this.key(comment.tenantId, comment.id), comment);
13
+ }
14
+ async find(tenantId, id) {
15
+ return this.records.get(this.key(tenantId, id)) ?? null;
16
+ }
17
+ async list(tenantId, resourceType, resourceId) {
18
+ const out = [];
19
+ for (const c of this.records.values()) {
20
+ if (c.tenantId === tenantId && c.resourceType === resourceType && c.resourceId === resourceId) out.push(c);
21
+ }
22
+ return out.sort((a, b) => a.createdAt - b.createdAt);
23
+ }
24
+ async update(tenantId, id, patch) {
25
+ const record = this.records.get(this.key(tenantId, id));
26
+ if (!record) return null;
27
+ Object.assign(record, patch);
28
+ return record;
29
+ }
30
+ async delete(tenantId, id) {
31
+ this.records.delete(this.key(tenantId, id));
32
+ }
33
+ };
34
+
35
+ // src/comments.ts
36
+ var CommentNotFoundError = class extends BasaltError {
37
+ status = 404;
38
+ constructor() {
39
+ super("COMMENT_NOT_FOUND", "Comment not found.");
40
+ }
41
+ };
42
+ var CommentTenantRequiredError = class extends BasaltError {
43
+ status = 400;
44
+ constructor() {
45
+ super("COMMENT_TENANT_REQUIRED", "A tenant is required \u2014 pass tenantId or run inside a tenant context.");
46
+ }
47
+ };
48
+ var buildTree = (comments2) => {
49
+ const nodes = new Map(comments2.map((c) => [c.id, { ...c, replies: [] }]));
50
+ const roots = [];
51
+ for (const node of nodes.values()) {
52
+ const parent = node.parentId ? nodes.get(node.parentId) : void 0;
53
+ if (parent) parent.replies.push(node);
54
+ else roots.push(node);
55
+ }
56
+ return roots;
57
+ };
58
+ var Comments = class {
59
+ store;
60
+ hooks;
61
+ mentionPattern;
62
+ now;
63
+ constructor(options = {}) {
64
+ this.store = options.store ?? new MemoryCommentStore();
65
+ this.hooks = options.hooks;
66
+ this.mentionPattern = options.mentionPattern ?? /@([\w-]+)/g;
67
+ this.now = options.now ?? Date.now;
68
+ }
69
+ on(resourceType, resourceId, tenantId) {
70
+ const tenant = this.tenant(tenantId);
71
+ return {
72
+ add: (input) => this.add(tenant, resourceType, resourceId, input),
73
+ list: () => this.store.list(tenant, resourceType, resourceId),
74
+ tree: async () => buildTree(await this.store.list(tenant, resourceType, resourceId))
75
+ };
76
+ }
77
+ get(id, tenantId) {
78
+ return this.store.find(this.tenant(tenantId), id);
79
+ }
80
+ async edit(id, body, tenantId) {
81
+ const tenant = this.tenant(tenantId);
82
+ if (!await this.store.find(tenant, id)) throw new CommentNotFoundError();
83
+ const updated = await this.store.update(tenant, id, { body, mentions: this.mentions(body), editedAt: this.now() });
84
+ await this.hooks?.emit("comment:updated", { comment: updated });
85
+ return updated;
86
+ }
87
+ async remove(id, tenantId) {
88
+ const tenant = this.tenant(tenantId);
89
+ const comment = await this.store.find(tenant, id);
90
+ if (!comment) return;
91
+ await this.store.delete(tenant, id);
92
+ await this.hooks?.emit("comment:deleted", {
93
+ tenantId: tenant,
94
+ id,
95
+ resourceType: comment.resourceType,
96
+ resourceId: comment.resourceId
97
+ });
98
+ }
99
+ async resolve(id, resolvedBy, tenantId) {
100
+ const comment = await this.mutate(id, { resolvedAt: this.now(), resolvedBy }, tenantId);
101
+ await this.hooks?.emit("comment:resolved", { comment });
102
+ return comment;
103
+ }
104
+ async reopen(id, tenantId) {
105
+ const comment = await this.mutate(id, { resolvedAt: void 0, resolvedBy: void 0 }, tenantId);
106
+ await this.hooks?.emit("comment:reopened", { comment });
107
+ return comment;
108
+ }
109
+ async add(tenantId, resourceType, resourceId, input) {
110
+ const mentions = this.mentions(input.body);
111
+ const comment = {
112
+ id: randomUUID(),
113
+ tenantId,
114
+ resourceType,
115
+ resourceId,
116
+ authorId: input.authorId,
117
+ body: input.body,
118
+ mentions,
119
+ createdAt: this.now(),
120
+ ...input.parentId !== void 0 ? { parentId: input.parentId } : {}
121
+ };
122
+ await this.store.create(comment);
123
+ await this.hooks?.emit("comment:created", { comment });
124
+ for (const userId of mentions) await this.hooks?.emit("comment:mentioned", { comment, userId });
125
+ return comment;
126
+ }
127
+ async mutate(id, patch, tenantId) {
128
+ const tenant = this.tenant(tenantId);
129
+ if (!await this.store.find(tenant, id)) throw new CommentNotFoundError();
130
+ return await this.store.update(tenant, id, patch);
131
+ }
132
+ mentions(body) {
133
+ const ids = /* @__PURE__ */ new Set();
134
+ for (const match of body.matchAll(this.mentionPattern)) if (match[1]) ids.add(match[1]);
135
+ return [...ids];
136
+ }
137
+ tenant(explicit) {
138
+ const id = explicit ?? tryCtx()?.["tenant"]?.id;
139
+ if (!id) throw new CommentTenantRequiredError();
140
+ return id;
141
+ }
142
+ };
143
+
144
+ // src/plugin.ts
145
+ import { createToken, ctx, definePlugin, BasaltError as BasaltError2 } from "@basaltkit/core";
146
+ import { route } from "@basaltkit/fastify";
147
+ import { z } from "zod";
148
+ var COMMENTS = createToken("comments");
149
+ function commentsPlugin(options = {}) {
150
+ return definePlugin({
151
+ name: "basalt:comments",
152
+ register({ container, hooks }) {
153
+ container.singleton(COMMENTS, () => new Comments({ ...options, hooks }));
154
+ }
155
+ });
156
+ }
157
+ var CommentForbiddenError = class extends BasaltError2 {
158
+ status = 403;
159
+ constructor() {
160
+ super("COMMENT_FORBIDDEN", "You can only modify your own comments.");
161
+ }
162
+ };
163
+ var UserRequiredError = class extends BasaltError2 {
164
+ status = 401;
165
+ constructor() {
166
+ super("AUTH_REQUIRED", "Authentication required.");
167
+ }
168
+ };
169
+ var comments = () => ctx().container.get(COMMENTS);
170
+ var currentUser = () => {
171
+ const id = ctx().user?.id;
172
+ if (!id) throw new UserRequiredError();
173
+ return id;
174
+ };
175
+ var assertAuthor = async (id) => {
176
+ const comment = await comments().get(id);
177
+ if (!comment || comment.authorId !== currentUser()) throw new CommentForbiddenError();
178
+ };
179
+ function commentRoutes() {
180
+ const resource = z.object({ resourceType: z.string().min(1), resourceId: z.string().min(1) });
181
+ return [
182
+ route({
183
+ method: "GET",
184
+ url: "/comments",
185
+ meta: { auth: true },
186
+ query: resource,
187
+ async handler({ query }) {
188
+ return comments().on(query.resourceType, query.resourceId).tree();
189
+ }
190
+ }),
191
+ route({
192
+ method: "POST",
193
+ url: "/comments",
194
+ meta: { auth: true },
195
+ body: resource.extend({ body: z.string().min(1), parentId: z.string().optional() }),
196
+ async handler({ body, reply }) {
197
+ const created = await comments().on(body.resourceType, body.resourceId).add({ authorId: currentUser(), body: body.body, ...body.parentId ? { parentId: body.parentId } : {} });
198
+ return reply.code(201).send(created);
199
+ }
200
+ }),
201
+ route({
202
+ method: "PATCH",
203
+ url: "/comments/:id",
204
+ meta: { auth: true },
205
+ params: z.object({ id: z.string() }),
206
+ body: z.object({ body: z.string().min(1) }),
207
+ async handler({ params, body }) {
208
+ await assertAuthor(params.id);
209
+ return comments().edit(params.id, body.body);
210
+ }
211
+ }),
212
+ route({
213
+ method: "DELETE",
214
+ url: "/comments/:id",
215
+ meta: { auth: true },
216
+ params: z.object({ id: z.string() }),
217
+ async handler({ params, reply }) {
218
+ await assertAuthor(params.id);
219
+ await comments().remove(params.id);
220
+ return reply.code(204).send();
221
+ }
222
+ }),
223
+ route({
224
+ method: "POST",
225
+ url: "/comments/:id/resolve",
226
+ meta: { auth: true },
227
+ params: z.object({ id: z.string() }),
228
+ async handler({ params }) {
229
+ return comments().resolve(params.id, currentUser());
230
+ }
231
+ }),
232
+ route({
233
+ method: "POST",
234
+ url: "/comments/:id/reopen",
235
+ meta: { auth: true },
236
+ params: z.object({ id: z.string() }),
237
+ async handler({ params }) {
238
+ return comments().reopen(params.id);
239
+ }
240
+ })
241
+ ];
242
+ }
243
+ export {
244
+ COMMENTS,
245
+ CommentNotFoundError,
246
+ CommentTenantRequiredError,
247
+ Comments,
248
+ MemoryCommentStore,
249
+ commentRoutes,
250
+ commentsPlugin
251
+ };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@basaltkit/comments",
3
+ "version": "1.0.0",
4
+ "description": "Comments for Basalt: per-resource threads with @mentions and resolve/reopen, tenant-scoped, emitting events that bridge to realtime and notifications.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "dependencies": {
17
+ "@basaltkit/core": "^1.0.0",
18
+ "@basaltkit/fastify": "^1.0.0"
19
+ },
20
+ "peerDependencies": {
21
+ "zod": "^3.24.0 || ^4.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.15.0",
25
+ "tsup": "^8.4.0",
26
+ "typescript": "^5.8.0",
27
+ "vitest": "^3.1.0",
28
+ "zod": "^3.24.0",
29
+ "@basaltkit/auth": "^1.0.0",
30
+ "@basaltkit/tenancy": "^1.0.0",
31
+ "@basaltkit/tsconfig": "^0.24.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/Zebedeu/basalt.git",
39
+ "directory": "packages/comments"
40
+ },
41
+ "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/comments#readme",
42
+ "bugs": "https://github.com/Zebedeu/basalt/issues",
43
+ "keywords": [
44
+ "basalt",
45
+ "typescript",
46
+ "saas",
47
+ "comments",
48
+ "threads",
49
+ "mentions"
50
+ ],
51
+ "scripts": {
52
+ "build": "tsup src/index.ts --format esm --dts --clean",
53
+ "test": "vitest run",
54
+ "typecheck": "tsc --noEmit"
55
+ }
56
+ }