@better-apps/comments 0.0.0-stage → 0.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/dist/writes.js ADDED
@@ -0,0 +1,463 @@
1
+ import { checkMentions } from "@better-apps/mentions";
2
+ import { accessTo, isDeclaredVisibility, orgOf, requireView, subjectOf } from "./access.js";
3
+ import { checkAnchor, checkContent, checkFields, checkMetadata, stableJson } from "./content.js";
4
+ import { CommentsError } from "./errors.js";
5
+ import { inputs } from "./inputs.js";
6
+ import { authorIdOf, callerOf, mentionScope, forbid, loadThread, requireAuthor, runHook, threadAccess, wrote } from "./runtime.js";
7
+ import { commentModel, threadModel } from "./schema.js";
8
+ import { ulid } from "./ulid.js";
9
+ const taken = () => new CommentsError("INVALID_INPUT", "That id can't be used.");
10
+ function sameContent(comment, content) {
11
+ return comment.format === content.format && stableJson(comment.body) === stableJson(content.body);
12
+ }
13
+ function eventData(thread) {
14
+ return { subjectType: thread.subjectType, subjectId: thread.subjectId, orgId: thread.orgId, thread };
15
+ }
16
+ /** The ids of a thread's comments, for `thread.deleted`. */
17
+ async function commentIdsOf(context, threadId) {
18
+ const rows = await context.db.findMany({
19
+ model: commentModel,
20
+ where: [{ field: "threadId", value: threadId }],
21
+ select: ["id"]
22
+ });
23
+ return rows.map((row) => row.id);
24
+ }
25
+ function emitMentions(context, thread, comment, userIds) {
26
+ for (const userId of userIds) {
27
+ context.emit("comment.mentioned", { ...eventData(thread), comment, userId });
28
+ }
29
+ }
30
+ function requireFields(declared, values) {
31
+ for (const [name, field] of Object.entries(declared)) {
32
+ if (field.required !== false && field.defaultValue === undefined && values[name] === undefined) {
33
+ throw new CommentsError("INVALID_INPUT", `The "${name}" field is required.`);
34
+ }
35
+ }
36
+ }
37
+ function commentRow(id, thread, author, content, createdAt, fields) {
38
+ return {
39
+ id,
40
+ threadId: thread.id,
41
+ orgId: thread.orgId,
42
+ authorType: author.type,
43
+ authorId: authorIdOf(author),
44
+ authorName: author.type === "user" ? null : author.name,
45
+ format: content.format,
46
+ body: content.body,
47
+ bodyText: content.bodyText,
48
+ createdAt,
49
+ editedAt: null,
50
+ deletedAt: null,
51
+ metadata: null,
52
+ ...fields
53
+ };
54
+ }
55
+ /** Metadata from a write, checked: server code writes any, clients only what the subject type's schema allows. */
56
+ async function writableMetadata(context, rules, metadata) {
57
+ forbid(context.source === "api" || rules.metadata !== undefined, "Only server code can write this metadata.");
58
+ return checkMetadata(context.source === "api" ? undefined : rules.metadata, metadata);
59
+ }
60
+ export async function createThread(config, context) {
61
+ const author = requireAuthor(context);
62
+ const caller = callerOf(context);
63
+ const original = context.input;
64
+ const { subjectType, subjectId } = original;
65
+ const rules = subjectOf(config.subjects, subjectType);
66
+ const ref = { subjectType, subjectId };
67
+ const orgId = await orgOf(context.subjects, caller, ref);
68
+ const requireAccess = async (visibility) => {
69
+ if (!isDeclaredVisibility(rules, visibility)) {
70
+ throw new CommentsError("INVALID_INPUT", `The "${subjectType}" subject type has no "${visibility}" visibility.`);
71
+ }
72
+ const access = await accessTo(rules, context.subjects, caller, ref, visibility);
73
+ requireView(access);
74
+ forbid(access.comment, "You can't comment here.");
75
+ return access;
76
+ };
77
+ let access = await requireAccess(original.visibility ?? "default");
78
+ const changed = await runHook(config.hooks.createThread, { actor: context.actor, author, thread: null, input: original }, inputs.createThread);
79
+ const input = { ...changed, subjectType, subjectId };
80
+ const visibility = input.visibility ?? "default";
81
+ if (visibility !== (original.visibility ?? "default")) {
82
+ access = await requireAccess(visibility);
83
+ }
84
+ const fields = checkFields(config.threadFields, input.fields);
85
+ requireFields(config.threadFields, fields);
86
+ const commentFields = checkFields(config.commentFields, input.commentFields);
87
+ requireFields(config.commentFields, commentFields);
88
+ const metadata = input.metadata === null || input.metadata === undefined
89
+ ? null
90
+ : await writableMetadata(context, rules, input.metadata);
91
+ const anchor = await checkAnchor(config.anchors, input.anchor);
92
+ const content = await checkContent(config.formats, input.format, input.body);
93
+ const authorId = authorIdOf(author);
94
+ const id = input.id ?? ulid(context.now.getTime());
95
+ const commentId = input.commentId ?? ulid(context.now.getTime());
96
+ // A retry with the same ids returns what the first attempt wrote.
97
+ const replay = async () => {
98
+ const existing = await context.db.findOne({
99
+ model: threadModel,
100
+ where: [{ field: "id", value: id }]
101
+ });
102
+ if (!existing) {
103
+ return null;
104
+ }
105
+ if (existing.createdBy !== authorId || existing.subjectType !== subjectType || existing.subjectId !== subjectId) {
106
+ throw taken();
107
+ }
108
+ const [first] = await context.db.findMany({
109
+ model: commentModel,
110
+ where: [{ field: "threadId", value: id }],
111
+ sortBy: [
112
+ { field: "createdAt", direction: "asc" },
113
+ { field: "id", direction: "asc" }
114
+ ],
115
+ limit: 1
116
+ });
117
+ if (first && first.id === commentId && wrote(author, first) && sameContent(first, content)) {
118
+ return { thread: existing, comment: first, droppedMentions: [] };
119
+ }
120
+ throw new CommentsError("CONFLICT", "You already used that id for a different thread.");
121
+ };
122
+ if (input.id !== undefined) {
123
+ const replayed = await replay();
124
+ if (replayed) {
125
+ return replayed;
126
+ }
127
+ }
128
+ if (input.commentId !== undefined &&
129
+ (await context.db.findOne({ model: commentModel, where: [{ field: "id", value: commentId }] }))) {
130
+ throw taken();
131
+ }
132
+ const now = context.now;
133
+ const thread = {
134
+ id,
135
+ orgId,
136
+ subjectType,
137
+ subjectId,
138
+ visibility,
139
+ ...anchor,
140
+ status: "open",
141
+ resolvedAt: null,
142
+ resolvedBy: null,
143
+ createdBy: authorId,
144
+ createdAt: now,
145
+ lastActivityAt: now,
146
+ commentCount: 1,
147
+ metadata,
148
+ ...fields
149
+ };
150
+ const comment = commentRow(commentId, thread, author, content, now, commentFields);
151
+ const mentions = await checkMentions(mentionScope(config, context, thread), content.mentions, authorId);
152
+ context.emit("thread.created", { ...eventData(thread), comment });
153
+ context.emit("comment.created", { ...eventData(thread), comment });
154
+ emitMentions(context, thread, comment, mentions.allowed);
155
+ try {
156
+ const [createdThread, createdComment] = await context.db.batch([
157
+ { type: "create", model: threadModel, data: thread },
158
+ { type: "create", model: commentModel, data: comment }
159
+ ]);
160
+ return {
161
+ thread: createdThread,
162
+ comment: createdComment,
163
+ // The list reveals who can see the subject, so only moderators get it.
164
+ droppedMentions: access.moderate ? mentions.dropped : []
165
+ };
166
+ }
167
+ catch (error) {
168
+ const replayed = input.id === undefined ? null : await replay();
169
+ if (replayed) {
170
+ return replayed;
171
+ }
172
+ throw error;
173
+ }
174
+ }
175
+ export async function reply(config, context) {
176
+ const author = requireAuthor(context);
177
+ const original = context.input;
178
+ const thread = await loadThread(context, original.threadId);
179
+ const { access } = await threadAccess(config, context, thread);
180
+ requireView(access);
181
+ forbid(access.comment, "You can't reply here.");
182
+ const hookArgs = { actor: context.actor, author, thread };
183
+ const input = {
184
+ ...(await runHook(config.hooks.reply, { ...hookArgs, input: original }, inputs.reply)),
185
+ threadId: thread.id
186
+ };
187
+ const reopens = thread.status === "resolved";
188
+ if (reopens) {
189
+ await runHook(config.hooks.reopenThread, { ...hookArgs, input: { threadId: thread.id } }, inputs.thread);
190
+ }
191
+ const fields = checkFields(config.commentFields, input.fields);
192
+ requireFields(config.commentFields, fields);
193
+ const content = await checkContent(config.formats, input.format, input.body);
194
+ const authorId = authorIdOf(author);
195
+ const id = input.id ?? ulid(context.now.getTime());
196
+ const replay = async () => {
197
+ const existing = await context.db.findOne({ model: commentModel, where: [{ field: "id", value: id }] });
198
+ if (!existing) {
199
+ return null;
200
+ }
201
+ if (existing.threadId !== thread.id || !wrote(author, existing)) {
202
+ throw taken();
203
+ }
204
+ if (!sameContent(existing, content)) {
205
+ throw new CommentsError("CONFLICT", "You already used that id for a different comment.");
206
+ }
207
+ return { thread: await loadThread(context, thread.id), comment: existing, droppedMentions: [] };
208
+ };
209
+ if (input.id !== undefined) {
210
+ const replayed = await replay();
211
+ if (replayed) {
212
+ return replayed;
213
+ }
214
+ }
215
+ const now = context.now;
216
+ const lastActivityAt = now > thread.lastActivityAt ? now : thread.lastActivityAt;
217
+ const reopened = reopens ? { status: "open", resolvedAt: null, resolvedBy: null } : {};
218
+ const updated = { ...thread, ...reopened, commentCount: thread.commentCount + 1, lastActivityAt };
219
+ const comment = commentRow(id, thread, author, content, now, fields);
220
+ const mentions = await checkMentions(mentionScope(config, context, thread), content.mentions, authorId);
221
+ context.emit("comment.created", { ...eventData(updated), comment });
222
+ emitMentions(context, updated, comment, mentions.allowed);
223
+ if (reopens) {
224
+ context.emit("thread.reopened", eventData(updated));
225
+ }
226
+ try {
227
+ const [counted, created] = await context.db.batch([
228
+ {
229
+ type: "incrementOne",
230
+ model: threadModel,
231
+ where: [{ field: "id", value: thread.id }],
232
+ increment: { commentCount: 1 },
233
+ set: { lastActivityAt, ...reopened }
234
+ },
235
+ { type: "create", model: commentModel, data: comment }
236
+ ]);
237
+ return {
238
+ thread: (counted ?? updated),
239
+ comment: created,
240
+ droppedMentions: access.moderate ? mentions.dropped : []
241
+ };
242
+ }
243
+ catch (error) {
244
+ const replayed = input.id === undefined ? null : await replay();
245
+ if (replayed) {
246
+ return replayed;
247
+ }
248
+ throw error;
249
+ }
250
+ }
251
+ async function loadComment(context, commentId) {
252
+ const comment = await context.db.findOne({ model: commentModel, where: [{ field: "id", value: commentId }] });
253
+ if (!comment) {
254
+ throw new CommentsError("NOT_FOUND", "That comment doesn't exist, or you can't see it.");
255
+ }
256
+ return comment;
257
+ }
258
+ export async function editComment(config, context) {
259
+ const author = requireAuthor(context);
260
+ const original = context.input;
261
+ const comment = await loadComment(context, original.commentId);
262
+ const thread = await loadThread(context, comment.threadId);
263
+ const { caller, access } = await threadAccess(config, context, thread);
264
+ requireView(access);
265
+ forbid(caller.trusted || (wrote(author, comment) && access.comment), "Only the author can edit this comment.");
266
+ if (comment.deletedAt || comment.body === null) {
267
+ throw new CommentsError("INVALID_INPUT", "A deleted comment can't be edited.");
268
+ }
269
+ const input = {
270
+ ...(await runHook(config.hooks.editComment, { actor: context.actor, author, thread, input: original }, inputs.editComment)),
271
+ commentId: comment.id
272
+ };
273
+ const content = await checkContent(config.formats, input.format, input.body);
274
+ if (sameContent(comment, content)) {
275
+ return { comment, droppedMentions: [] };
276
+ }
277
+ const previous = await checkContent(config.formats, comment.format, comment.body).catch(() => null);
278
+ const added = content.mentions.filter((id) => !previous?.mentions.includes(id));
279
+ const mentions = await checkMentions(mentionScope(config, context, thread), added, comment.authorId);
280
+ const edited = {
281
+ ...comment,
282
+ format: content.format,
283
+ body: content.body,
284
+ bodyText: content.bodyText,
285
+ editedAt: context.now
286
+ };
287
+ context.emit("comment.edited", {
288
+ ...eventData(thread),
289
+ comment: edited,
290
+ previousFormat: comment.format,
291
+ previousBody: comment.body
292
+ });
293
+ emitMentions(context, thread, edited, mentions.allowed);
294
+ const [updated] = await context.db.batch([
295
+ {
296
+ type: "update",
297
+ model: commentModel,
298
+ where: [{ field: "id", value: comment.id }],
299
+ update: { format: edited.format, body: edited.body, bodyText: edited.bodyText, editedAt: edited.editedAt }
300
+ }
301
+ ]);
302
+ return {
303
+ comment: (updated ?? edited),
304
+ droppedMentions: access.moderate ? mentions.dropped : []
305
+ };
306
+ }
307
+ export async function deleteComment(config, context) {
308
+ const author = requireAuthor(context);
309
+ const original = context.input;
310
+ const comment = await loadComment(context, original.commentId);
311
+ const thread = await loadThread(context, comment.threadId);
312
+ const { access } = await threadAccess(config, context, thread);
313
+ requireView(access);
314
+ forbid(access.moderate || (wrote(author, comment) && access.comment), "You can't delete this comment.");
315
+ if (comment.deletedAt) {
316
+ return { comment, threadDeleted: false };
317
+ }
318
+ await runHook(config.hooks.deleteComment, { actor: context.actor, author, thread, input: original }, inputs.deleteComment);
319
+ const deleted = { ...comment, body: null, bodyText: "", deletedAt: context.now };
320
+ const remaining = { ...thread, commentCount: thread.commentCount - 1 };
321
+ context.emit("comment.deleted", {
322
+ ...eventData(remaining),
323
+ comment: deleted,
324
+ previousBody: comment.body,
325
+ previousText: comment.bodyText
326
+ });
327
+ const operations = [
328
+ {
329
+ type: "update",
330
+ model: commentModel,
331
+ where: [{ field: "id", value: comment.id }],
332
+ update: { body: null, bodyText: "", deletedAt: deleted.deletedAt }
333
+ },
334
+ {
335
+ type: "incrementOne",
336
+ model: threadModel,
337
+ where: [{ field: "id", value: thread.id }],
338
+ increment: { commentCount: -1 }
339
+ }
340
+ ];
341
+ const last = thread.commentCount <= 1;
342
+ const commentIds = last ? await commentIdsOf(context, thread.id) : [];
343
+ if (last) {
344
+ // The thread goes with its last comment, but only while its count is zero inside the batch, so a reply that lands
345
+ // first keeps it.
346
+ operations.push({
347
+ type: "deleteMany",
348
+ model: commentModel,
349
+ where: [
350
+ { field: "threadId", value: thread.id },
351
+ { field: "deletedAt", operator: "ne", value: null }
352
+ ]
353
+ }, {
354
+ type: "deleteMany",
355
+ model: threadModel,
356
+ where: [
357
+ { field: "id", value: thread.id },
358
+ { field: "commentCount", value: 0 }
359
+ ]
360
+ });
361
+ }
362
+ const results = await context.db.batch(operations);
363
+ const threadDeleted = last && results[3] === 1;
364
+ if (threadDeleted) {
365
+ // Emitted only once the thread is really gone, so a reply that won the race never sees its thread reported deleted.
366
+ context.emit("thread.deleted", { ...eventData(remaining), commentIds });
367
+ }
368
+ return { comment: deleted, threadDeleted };
369
+ }
370
+ async function changeStatus(config, context, status) {
371
+ const author = requireAuthor(context);
372
+ const original = context.input;
373
+ const thread = await loadThread(context, original.threadId);
374
+ const { access } = await threadAccess(config, context, thread);
375
+ requireView(access);
376
+ forbid(access.comment, status === "resolved" ? "You can't resolve this thread." : "You can't reopen this thread.");
377
+ const hook = status === "resolved" ? config.hooks.resolveThread : config.hooks.reopenThread;
378
+ await runHook(hook, { actor: context.actor, author, thread, input: original }, inputs.thread);
379
+ if (thread.status === status) {
380
+ return thread;
381
+ }
382
+ const change = status === "resolved"
383
+ ? { status, resolvedAt: context.now, resolvedBy: authorIdOf(author) }
384
+ : { status, resolvedAt: null, resolvedBy: null };
385
+ const changed = { ...thread, ...change };
386
+ context.emit(status === "resolved" ? "thread.resolved" : "thread.reopened", eventData(changed));
387
+ const [updated] = await context.db.batch([
388
+ { type: "update", model: threadModel, where: [{ field: "id", value: thread.id }], update: change }
389
+ ]);
390
+ return (updated ?? changed);
391
+ }
392
+ export const resolveThread = (config, context) => changeStatus(config, context, "resolved");
393
+ export const reopenThread = (config, context) => changeStatus(config, context, "open");
394
+ function createdByCaller(thread, author) {
395
+ return author.type === "user" && thread.createdBy === author.userId;
396
+ }
397
+ export async function updateThread(config, context) {
398
+ const author = requireAuthor(context);
399
+ const original = context.input;
400
+ const thread = await loadThread(context, original.threadId);
401
+ const { rules, access } = await threadAccess(config, context, thread);
402
+ requireView(access);
403
+ forbid(access.moderate || (createdByCaller(thread, author) && access.comment), "You can't change this thread.");
404
+ const input = {
405
+ ...(await runHook(config.hooks.updateThread, { actor: context.actor, author, thread, input: original }, inputs.updateThread)),
406
+ threadId: thread.id
407
+ };
408
+ const change = checkFields(config.threadFields, input.fields);
409
+ if (input.metadata !== undefined) {
410
+ change.metadata = input.metadata === null ? null : await writableMetadata(context, rules, input.metadata);
411
+ }
412
+ if (input.visibility !== undefined && input.visibility !== thread.visibility) {
413
+ forbid(access.moderate, "Only moderators can change who sees a thread.");
414
+ if (!isDeclaredVisibility(rules, input.visibility)) {
415
+ throw new CommentsError("INVALID_INPUT", `The "${thread.subjectType}" subject type has no "${input.visibility}" visibility.`);
416
+ }
417
+ change.visibility = input.visibility;
418
+ }
419
+ const changes = Object.keys(change).filter((key) => stableJson(change[key]) !== stableJson(thread[key]));
420
+ if (changes.length === 0) {
421
+ return thread;
422
+ }
423
+ const update = Object.fromEntries(changes.map((key) => [key, change[key]]));
424
+ const previous = Object.fromEntries(changes.map((key) => [key, thread[key]]));
425
+ const changed = { ...thread, ...update };
426
+ context.emit("thread.updated", { ...eventData(changed), previous });
427
+ const [updated] = await context.db.batch([
428
+ { type: "update", model: threadModel, where: [{ field: "id", value: thread.id }], update }
429
+ ]);
430
+ return (updated ?? changed);
431
+ }
432
+ export async function updateAnchor(config, context) {
433
+ const author = requireAuthor(context);
434
+ const original = context.input;
435
+ const thread = await loadThread(context, original.threadId);
436
+ const { access } = await threadAccess(config, context, thread);
437
+ requireView(access);
438
+ forbid(access.moderate || (createdByCaller(thread, author) && access.comment), "You can't move this thread.");
439
+ const input = await runHook(config.hooks.updateAnchor, { actor: context.actor, author, thread, input: original }, inputs.updateAnchor);
440
+ const anchor = await checkAnchor(config.anchors, input.anchor);
441
+ const previousAnchor = thread.anchorKind === null ? null : { kind: thread.anchorKind, value: thread.anchor, version: thread.anchorVersion };
442
+ const changed = { ...thread, ...anchor };
443
+ context.emit("thread.anchorUpdated", { ...eventData(changed), previousAnchor });
444
+ const [updated] = await context.db.batch([
445
+ { type: "update", model: threadModel, where: [{ field: "id", value: thread.id }], update: { ...anchor } }
446
+ ]);
447
+ return (updated ?? changed);
448
+ }
449
+ export async function deleteThread(config, context) {
450
+ const author = requireAuthor(context);
451
+ const original = context.input;
452
+ const thread = await loadThread(context, original.threadId);
453
+ const { access } = await threadAccess(config, context, thread);
454
+ requireView(access);
455
+ forbid(access.moderate, "Only moderators can delete a thread.");
456
+ await runHook(config.hooks.deleteThread, { actor: context.actor, author, thread, input: original }, inputs.thread);
457
+ context.emit("thread.deleted", { ...eventData(thread), commentIds: await commentIdsOf(context, thread.id) });
458
+ await context.db.batch([
459
+ { type: "deleteMany", model: commentModel, where: [{ field: "threadId", value: thread.id }] },
460
+ { type: "deleteMany", model: threadModel, where: [{ field: "id", value: thread.id }] }
461
+ ]);
462
+ return { threadId: thread.id };
463
+ }
package/package.json CHANGED
@@ -1,6 +1,92 @@
1
1
  {
2
2
  "name": "@better-apps/comments",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.0",
4
+ "description": "Comment threads on any record an app owns, optionally pinned to a spot inside it.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "engines": {
9
+ "node": ">=24"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./client": {
17
+ "types": "./dist/client.d.ts",
18
+ "import": "./dist/client.js"
19
+ },
20
+ "./react": {
21
+ "types": "./dist/react.d.ts",
22
+ "import": "./dist/react.js"
23
+ }
24
+ },
25
+ "types": "./dist/index.d.ts",
26
+ "files": [
27
+ "dist/**/*.js",
28
+ "dist/**/*.d.ts",
29
+ "README.md",
30
+ "LICENSE",
31
+ "CHANGELOG.md"
32
+ ],
33
+ "dependencies": {
34
+ "@better-apps/anchors": "0.0.0",
35
+ "@better-apps/mentions": "0.0.0",
36
+ "zod": "^4.6.7"
37
+ },
38
+ "peerDependencies": {
39
+ "@better-apps/react": "0.0.0",
40
+ "better-apps": "0.0.0",
41
+ "react": "^19.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@better-apps/react": "0.0.0",
45
+ "@better-apps/testing": "0.0.0",
46
+ "@repo/typescript-config": "0.0.0",
47
+ "@testing-library/react": "^16.3.3",
48
+ "@types/node": "24.19.2",
49
+ "@types/react": "^19.3.0",
50
+ "@types/react-dom": "^19.3.0",
51
+ "@vitest/coverage-v8": "5.0.5",
52
+ "better-apps": "0.0.0",
53
+ "drizzle-orm": "0.45.4",
54
+ "happy-dom": "^21.0.4",
55
+ "react": "19.3.1",
56
+ "react-dom": "19.3.1",
57
+ "typescript": "7.0.2",
58
+ "vitest": "5.0.5",
59
+ "vite-plus": "1.4.0"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/betterapps-hq/better-apps.git",
64
+ "directory": "packages/features/comments"
65
+ },
66
+ "homepage": "https://github.com/betterapps-hq/better-apps/tree/main/packages/features/comments#readme",
67
+ "bugs": {
68
+ "url": "https://github.com/betterapps-hq/better-apps/issues"
69
+ },
70
+ "publishConfig": {
71
+ "access": "public",
72
+ "registry": "https://registry.npmjs.org/"
73
+ },
74
+ "keywords": [
75
+ "better-apps",
76
+ "comments",
77
+ "threads"
78
+ ],
79
+ "peerDependenciesMeta": {
80
+ "@better-apps/react": {
81
+ "optional": true
82
+ },
83
+ "react": {
84
+ "optional": true
85
+ }
86
+ },
87
+ "scripts": {
88
+ "build": "tsc -p tsconfig.build.json",
89
+ "test": "vp test run",
90
+ "test:coverage": "vp test run --coverage"
91
+ }
6
92
  }