@softr/sdk 0.0.55 → 0.0.56-alpha.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.
Files changed (37) hide show
  1. package/dist/action.d.ts +31 -1
  2. package/dist/action.js +26 -1
  3. package/dist/auth/isomorphic.d.ts +1 -1
  4. package/dist/auth/isomorphic.js +1 -2
  5. package/dist/client.d.ts +2 -1
  6. package/dist/client.js +2 -1
  7. package/dist/comments/comments.d.ts +168 -0
  8. package/dist/comments/comments.js +413 -0
  9. package/dist/comments/use-comment-composer.d.ts +46 -0
  10. package/dist/comments/use-comment-composer.js +134 -0
  11. package/dist/core/file-upload.d.ts +7 -2
  12. package/dist/core/file-upload.js +6 -1
  13. package/dist/core/use-file-upload.d.ts +2 -2
  14. package/dist/default-error-overlay.d.ts +2 -2
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.js +2 -3
  17. package/dist/mcp.d.ts +2 -2
  18. package/dist/package.js +1 -1
  19. package/dist/server.d.ts +2 -1
  20. package/dist/server.js +2 -1
  21. package/package.json +3 -1
  22. package/skills/softr-sdk/SKILL.md +7 -7
  23. package/skills/softr-sdk/auth/SKILL.md +1 -1
  24. package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
  25. package/skills/softr-sdk/comments/SKILL.md +182 -0
  26. package/skills/softr-sdk/current-user/SKILL.md +1 -1
  27. package/skills/softr-sdk/file-uploads/SKILL.md +5 -4
  28. package/skills/softr-sdk/integrations/SKILL.md +1 -1
  29. package/skills/softr-sdk/mcp/SKILL.md +1 -1
  30. package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
  31. package/skills/softr-sdk/record-queries/SKILL.md +2 -2
  32. package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
  33. package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
  34. package/skills/softr-sdk/workflows/SKILL.md +1 -1
  35. package/dist/pwa.d.ts +0 -54
  36. package/dist/pwa.js +0 -152
  37. package/skills/softr-sdk/pwa/SKILL.md +0 -77
@@ -0,0 +1,46 @@
1
+ import { CommentMention } from "./comments.js";
2
+ import { ChangeEvent, RefObject, SyntheticEvent } from "react";
3
+
4
+ //#region src/comments/use-comment-composer.d.ts
5
+
6
+ /**
7
+ * The state of a comment box: the text, the people mentioned in it, and the `@` fragment being
8
+ * typed. Spread `textareaProps` onto a `<textarea>`. Show a mention menu while `query` is not
9
+ * `null`, and call `pick` with the person chosen from it.
10
+ *
11
+ * Post `text` and `mentions` as they are, then call `reset` once the post succeeds.
12
+ *
13
+ * To edit a comment, pass its source as initial, in a composer mounted only while editing.
14
+ * `initial` is read once, on mount.
15
+ *
16
+ * @example
17
+ * const composer = useCommentComposer()
18
+ * <textarea {...composer.textareaProps} />
19
+ * {composer.query !== null && <MentionMenu query={composer.query} onPick={composer.pick} />}
20
+ */
21
+ declare function useCommentComposer(options?: {
22
+ /** Fills the box to edit a comment: pass its `source`. */
23
+ initial?: {
24
+ text: string;
25
+ mentions: ReadonlyArray<CommentMention>;
26
+ };
27
+ }): {
28
+ text: string;
29
+ mentions: Array<CommentMention>;
30
+ /** What follows the `@` before the caret, or `null` when the caret is not in a mention. */
31
+ query: string | null;
32
+ textareaProps: {
33
+ ref: RefObject<HTMLTextAreaElement | null>;
34
+ value: string;
35
+ onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
36
+ onSelect: (event: SyntheticEvent<HTMLTextAreaElement>) => void;
37
+ };
38
+ /** Replaces the `@` fragment with the person's `@name`, and puts the caret after it. */
39
+ pick: (person: {
40
+ name: string;
41
+ email: string;
42
+ }) => void;
43
+ reset: () => void;
44
+ };
45
+ //#endregion
46
+ export { useCommentComposer };
@@ -0,0 +1,134 @@
1
+ import { assert } from "../assertions.js";
2
+ import { useLayoutEffect, useRef, useState } from "react";
3
+
4
+ //#region src/comments/use-comment-composer.ts
5
+ const EMPTY = {
6
+ text: "",
7
+ mentions: [],
8
+ caret: 0
9
+ };
10
+ /**
11
+ * The state of a comment box: the text, the people mentioned in it, and the `@` fragment being
12
+ * typed. Spread `textareaProps` onto a `<textarea>`. Show a mention menu while `query` is not
13
+ * `null`, and call `pick` with the person chosen from it.
14
+ *
15
+ * Post `text` and `mentions` as they are, then call `reset` once the post succeeds.
16
+ *
17
+ * To edit a comment, pass its source as initial, in a composer mounted only while editing.
18
+ * `initial` is read once, on mount.
19
+ *
20
+ * @example
21
+ * const composer = useCommentComposer()
22
+ * <textarea {...composer.textareaProps} />
23
+ * {composer.query !== null && <MentionMenu query={composer.query} onPick={composer.pick} />}
24
+ */
25
+ function useCommentComposer(options) {
26
+ const [state, setState] = useState(() => options?.initial === void 0 ? EMPTY : {
27
+ text: options.initial.text,
28
+ mentions: [...options.initial.mentions],
29
+ caret: options.initial.text.length
30
+ });
31
+ const ref = useRef(null);
32
+ const pendingCaret = useRef(null);
33
+ useLayoutEffect(() => {
34
+ const caret = pendingCaret.current;
35
+ if (caret === null || ref.current === null) return;
36
+ pendingCaret.current = null;
37
+ ref.current.focus();
38
+ ref.current.setSelectionRange(caret, caret);
39
+ });
40
+ const fragment = findMentionQuery(state.text, state.caret, state.mentions);
41
+ return {
42
+ text: state.text,
43
+ mentions: state.mentions,
44
+ query: fragment?.query ?? null,
45
+ textareaProps: {
46
+ ref,
47
+ value: state.text,
48
+ onChange(event) {
49
+ const next = event.currentTarget.value;
50
+ const caret = event.currentTarget.selectionStart;
51
+ setState((previous) => ({
52
+ text: next,
53
+ mentions: remapMentions(previous.text, next, previous.mentions),
54
+ caret
55
+ }));
56
+ },
57
+ onSelect(event) {
58
+ const caret = event.currentTarget.selectionStart;
59
+ setState((previous) => previous.caret === caret ? previous : {
60
+ ...previous,
61
+ caret
62
+ });
63
+ }
64
+ },
65
+ pick(person) {
66
+ assert(fragment, "pick needs an @ fragment under the caret");
67
+ const label = `@${person.name}`;
68
+ const text = `${state.text.slice(0, fragment.start)}${label} ${state.text.slice(state.caret)}`;
69
+ const added = {
70
+ ...person,
71
+ start: fragment.start,
72
+ end: fragment.start + label.length
73
+ };
74
+ const caret = added.end + 1;
75
+ pendingCaret.current = caret;
76
+ setState({
77
+ text,
78
+ mentions: [...remapMentions(state.text, text, state.mentions), added],
79
+ caret
80
+ });
81
+ },
82
+ reset() {
83
+ setState(EMPTY);
84
+ }
85
+ };
86
+ }
87
+ /** A fragment longer than this is prose, not a name being typed. */
88
+ const MENTION_QUERY_MAX_LENGTH = 32;
89
+ /**
90
+ * Moves mention ranges through an edit. A textarea reports only its new value, so the edit is the
91
+ * span between the longest common prefix and suffix. Ranges after it shift. Ranges it reached into
92
+ * are dropped, which is how deleting part of a name un-mentions that person.
93
+ */
94
+ function remapMentions(previous, next, mentions) {
95
+ if (previous === next) return mentions;
96
+ let prefix = 0;
97
+ while (prefix < previous.length && prefix < next.length && previous[prefix] === next[prefix]) prefix += 1;
98
+ let suffix = 0;
99
+ while (suffix < previous.length - prefix && suffix < next.length - prefix && previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]) suffix += 1;
100
+ const editStart = prefix;
101
+ const editEnd = previous.length - suffix;
102
+ const delta = next.length - previous.length;
103
+ return mentions.flatMap((mention) => {
104
+ if (mention.end <= editStart) return [mention];
105
+ if (mention.start >= editEnd) return [{
106
+ ...mention,
107
+ start: mention.start + delta,
108
+ end: mention.end + delta
109
+ }];
110
+ return [];
111
+ });
112
+ }
113
+ /**
114
+ * The `@` fragment the caret sits in, or `null`. An `@` does not count when:
115
+ * - it does not start a word, so an email address opens no menu;
116
+ * - it belongs to a mention already made;
117
+ * - the fragment ran past a newline or is long enough to be prose. Spaces are fine, names have them.
118
+ */
119
+ function findMentionQuery(text, caret, mentions) {
120
+ const before = text.slice(0, caret);
121
+ const at = before.lastIndexOf("@");
122
+ if (at === -1) return null;
123
+ if (at > 0 && !/\s/.test(before.charAt(at - 1))) return null;
124
+ if (mentions.some((mention) => at >= mention.start && at < mention.end)) return null;
125
+ const query = before.slice(at + 1);
126
+ if (query.includes("\n") || query.length > MENTION_QUERY_MAX_LENGTH) return null;
127
+ return {
128
+ query,
129
+ start: at
130
+ };
131
+ }
132
+
133
+ //#endregion
134
+ export { useCommentComposer };
@@ -1,4 +1,4 @@
1
- import { DatasourceAction } from "../action.js";
1
+ import { CommentsAction, DatasourceAction } from "../action.js";
2
2
 
3
3
  //#region src/core/file-upload.d.ts
4
4
 
@@ -10,7 +10,12 @@ type FileAttachment = {
10
10
  url: string;
11
11
  filename: string;
12
12
  id: string;
13
+ type?: string | null;
13
14
  };
14
- declare function upload(action: DatasourceAction, file: File): Promise<FileAttachment>;
15
+ /**
16
+ * A COMMENTS action uploads a comment attachment.
17
+ * The platform checks the comment settings.
18
+ */
19
+ declare function upload(action: DatasourceAction | CommentsAction, file: File): Promise<FileAttachment>;
15
20
  //#endregion
16
21
  export { FileAttachment, upload };
@@ -5,6 +5,10 @@ import { platformApiUrl } from "../auth/platform.js";
5
5
  import { randomUUID } from "node:crypto";
6
6
 
7
7
  //#region src/core/file-upload.ts
8
+ /**
9
+ * A COMMENTS action uploads a comment attachment.
10
+ * The platform checks the comment settings.
11
+ */
8
12
  async function upload(action, file) {
9
13
  const { applicationId } = sessionConfig();
10
14
  const baseUrl = await platformApiUrl();
@@ -23,7 +27,8 @@ async function upload(action, file) {
23
27
  return {
24
28
  url: resultJSON.url,
25
29
  filename: resultJSON.filename || "Untitled file",
26
- id: resultJSON.id || randomUUID()
30
+ id: resultJSON.id || randomUUID(),
31
+ type: resultJSON.type ?? null
27
32
  };
28
33
  }
29
34
 
@@ -1,4 +1,4 @@
1
- import { DatasourceAction } from "../action.js";
1
+ import { CommentsAction, DatasourceAction } from "../action.js";
2
2
  import { FileAttachment } from "./file-upload.js";
3
3
 
4
4
  //#region src/core/use-file-upload.d.ts
@@ -16,7 +16,7 @@ type FileItem = {
16
16
  filename: string;
17
17
  error: Error;
18
18
  };
19
- declare function useFileUpload(action: DatasourceAction, options?: {
19
+ declare function useFileUpload(action: DatasourceAction | CommentsAction, options?: {
20
20
  initialAttachments?: FileAttachment[];
21
21
  }): {
22
22
  files: FileItem[];
@@ -1,4 +1,4 @@
1
- import * as react1 from "react";
1
+ import * as react0 from "react";
2
2
 
3
3
  //#region src/default-error-overlay.d.ts
4
4
  type Props = {
@@ -8,6 +8,6 @@ type Props = {
8
8
  declare function DefaultErrorOverlay({
9
9
  error,
10
10
  reset
11
- }: Props): react1.JSX.Element;
11
+ }: Props): react0.JSX.Element;
12
12
  //#endregion
13
13
  export { DefaultErrorOverlay };
package/dist/index.d.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
2
- import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
2
+ import { ActionPermissions, SoftrDatasources, defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { FileAttachment } from "./core/file-upload.js";
4
4
  import { FileItem, useFileUpload } from "./core/use-file-upload.js";
5
+ import { CommentAttachmentInput, CommentMention, CommentThread, CommentView } from "./comments/comments.js";
5
6
  import { SoftrContext } from "./auth/context.js";
6
7
  import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
7
8
  import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
8
9
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
9
10
  import { softrMcpMiddleware } from "./mcp.js";
10
11
  import { useUserTimezone } from "./timezone.js";
11
- import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
12
12
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
13
13
  import { Audience } from "./visibility/audience.js";
14
- export { type ActionPermissions, Audience, DefaultErrorOverlay, type FileAttachment, type FileItem, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, usePwaInstall, useUserTimezone };
14
+ export { type ActionPermissions, Audience, type CommentAttachmentInput, type CommentMention, type CommentThread, type CommentView, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
package/dist/index.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { useFileUpload } from "./core/use-file-upload.js";
2
- import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
2
+ import { defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
4
4
  import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
5
- import { SoftrPwaHead, usePwaInstall } from "./pwa.js";
6
5
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
7
6
  import { softrMcpMiddleware } from "./mcp.js";
8
7
  import { useUserTimezone } from "./timezone.js";
9
8
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
10
9
  import { Audience } from "./visibility/audience.js";
11
10
 
12
- export { Audience, DefaultErrorOverlay, SoftrPwaHead, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, usePwaInstall, useUserTimezone };
11
+ export { Audience, DefaultErrorOverlay, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DatasourceAction } from "./action.js";
2
- import * as _tanstack_react_start0 from "@tanstack/react-start";
2
+ import * as _tanstack_react_start2 from "@tanstack/react-start";
3
3
 
4
4
  //#region src/mcp.d.ts
5
5
 
@@ -37,6 +37,6 @@ type SoftrMcpOptions = {
37
37
  * ],
38
38
  * }))
39
39
  */
40
- declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>;
40
+ declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start2.RequestMiddlewareAfterServer<{}, undefined, undefined>;
41
41
  //#endregion
42
42
  export { softrMcpMiddleware };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.55";
2
+ var version = "0.0.56-alpha.0";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/server.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { upload } from "./core/file-upload.js";
2
+ import { comments } from "./comments/comments.js";
2
3
  import { auth } from "./auth/server.js";
3
4
  import { ds } from "./datasource/datasource.js";
4
5
  import { workflow } from "./core/workflow.js";
5
6
  import { integration } from "./core/integration.js";
6
7
  import "@tanstack/react-start/server-only";
7
- export { auth, ds, integration, upload, workflow };
8
+ export { auth, comments, ds, integration, upload, workflow };
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@ import { ds } from "./datasource/datasource.js";
3
3
  import { auth } from "./auth/server.js";
4
4
  import { workflow } from "./core/workflow.js";
5
5
  import { integration } from "./core/integration.js";
6
+ import { comments } from "./comments/comments.js";
6
7
  import "@tanstack/react-start/server-only";
7
8
 
8
- export { auth, ds, integration, upload, workflow };
9
+ export { auth, comments, ds, integration, upload, workflow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.55",
3
+ "version": "0.0.56-alpha.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -17,6 +17,8 @@
17
17
  "typecheck": "tsc --noEmit"
18
18
  },
19
19
  "dependencies": {
20
+ "marked": "^18.0.14",
21
+ "xss": "^1.0.15",
20
22
  "zod": "^4.4.3"
21
23
  },
22
24
  "devDependencies": {
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.55
14
+ library_version: 0.0.56-alpha.0
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -29,9 +29,9 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
29
29
 
30
30
  | Import | Runs | Holds |
31
31
  | --- | --- | --- |
32
- | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `SoftrPwaHead`, `usePwaInstall`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
33
- | `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
34
- | `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
32
+ | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineComments`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
33
+ | `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration`, `comments` |
34
+ | `@softr/sdk/client` | Browser only | `useCommentComposer` |
35
35
  | `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
36
36
 
37
37
  ## Sub-Skills
@@ -48,7 +48,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
48
48
  | Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
49
49
  | Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
50
50
  | Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
51
- | Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
51
+ | Add comments to a record | [comments/SKILL.md](./comments/SKILL.md) |
52
52
 
53
53
  ## Quick Decision Tree
54
54
 
@@ -83,8 +83,8 @@ Calling a connected third-party API (Slack, Stripe, Gmail, Google Calendar, …)
83
83
  Opening the app to an AI assistant over MCP?
84
84
  load softr-sdk/mcp
85
85
 
86
- Adding an install button, or making the app installable?
87
- load softr-sdk/pwa
86
+ Letting visitors comment on a record?
87
+ load softr-sdk/comments
88
88
 
89
89
  Writing a server function, loader, or middleware in general?
90
90
  not a Softr concern. Use the TanStack Start skills.
@@ -16,7 +16,7 @@ description: >-
16
16
  metadata:
17
17
  type: sub-skill
18
18
  library: softr-sdk
19
- library_version: 0.0.55
19
+ library_version: 0.0.56-alpha.0
20
20
  ---
21
21
 
22
22
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.55
15
+ library_version: 0.0.56-alpha.0
16
16
  ---
17
17
 
18
18
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -0,0 +1,182 @@
1
+ ---
2
+ name: comments
3
+ description: >-
4
+ Comment threads on records: defineComments from @softr/sdk declares which
5
+ datasource has comments; comments.list, create, update, delete, react,
6
+ subscription, setSubscription and searchMentions from @softr/sdk/server read
7
+ and write one record's thread inside a server function; useCommentComposer
8
+ and useFileUpload from @softr/sdk/client keep a textarea's text, its
9
+ @mentions and its attachments in step. Bodies come back as sanitised
10
+ bodyHtml. A null thread means render nothing.
11
+ metadata:
12
+ type: sub-skill
13
+ library: softr-sdk
14
+ library_version: 0.0.56-alpha.0
15
+ ---
16
+
17
+ **Boundary.** `comments` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler. Importing it into a component fails the build. `useCommentComposer` lives on `@softr/sdk/client` and runs in components. `defineComments` comes from `@softr/sdk` and can be imported anywhere.
18
+
19
+ # Comments on records
20
+
21
+ Every record of a commented datasource has one thread. The SDK reads and writes it. The app owns everything you see: layout, copy, sort order, dates.
22
+
23
+ ## Setup
24
+
25
+ ```ts
26
+ import { createServerFn } from '@tanstack/react-start'
27
+ import { defineComments, type CommentMention, type CommentAttachmentInput } from '@softr/sdk'
28
+ import { comments } from '@softr/sdk/server'
29
+ import { useCommentComposer, useFileUpload } from '@softr/sdk/client'
30
+ ```
31
+
32
+ ## Declare the comments action
33
+
34
+ ```ts
35
+ export const orderComments = defineComments({
36
+ id: 'orderComments',
37
+ datasourceId: '8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77',
38
+ })
39
+ ```
40
+
41
+ - `id` is a name you choose, unique across the app. `datasourceId` is the table whose records get threads.
42
+ - There is no `permissions`. Who may read, post and delete is set in Softr under Settings → Comments, the same settings native comment blocks use.
43
+ - Declare it once per datasource. Two declarations on the same datasource are the **same** threads, so "internal notes" next to "customer comments" on one table is not possible.
44
+ - Write the object inline. Passing it through a variable hides it from the build.
45
+
46
+ ## Read a thread
47
+
48
+ ```ts
49
+ const getThread = createServerFn({ method: 'GET' })
50
+ .validator((data: { recordId: string; offset?: number | null }) => data)
51
+ .handler(({ data }) => comments.list(orderComments, { recordId: data.recordId, offset: data.offset }))
52
+ ```
53
+
54
+ `comments.list` answers `null` when this visitor cannot see comments on the record: comments are off for the app, the table is not set up yet, or the visitor's group may not read them. Render nothing for `null`, not an error.
55
+
56
+ Otherwise it answers:
57
+
58
+ | Field | What it is |
59
+ | --- | --- |
60
+ | `items` | The comments for this page, newest first. Each is a `CommentView` |
61
+ | `canPost` | Whether this visitor may post. Hide the composer when false |
62
+ | `allowMentions` | Whether `@` opens a mention menu. Do not call `searchMentions` when false |
63
+ | `quickReactions` | The five `{ emoji, label }` a comment can be reacted with. There are no others |
64
+ | `total`, `nextOffset` | Paging. The first page is the newest comments; each further page, fetched by passing `nextOffset` back as `offset`, goes further back in time. `null` on the last page |
65
+
66
+ A `CommentView` is `{ id, bodyHtml, attachments, author: { name, avatar, isDeactivated }, createdAt, canDelete, canEdit, edited, source, reactions }`. `createdAt` is ISO 8601. Format it with `useUserTimezone`. Each reaction is `{ emoji, label, count, isSelected }`.
67
+
68
+ - `allowAttachments` (on the thread): whether the composer may attach files.
69
+ - `attachments[].type` (per attachment): the file's content type, or `null` when the platform does not know it.
70
+ - `notificationsEnabled` (on the thread): whether to show the notifications switch at all. Hide it when false, even for a visitor who may post.
71
+ - `canEdit` (per comment): whether this visitor may edit it. Show Edit only then.
72
+ - `edited` (per comment): whether it was changed since it was posted. Show "(edited)" beside its date.
73
+ - `source` (per comment): `{ text, mentions }` to fill an edit box, or `null` when `canEdit` is false. It is the body as the visitor typed it, with each mention as its own `@name` and range, the same shape the composer works with.
74
+
75
+ `bodyHtml` is already sanitised. Render it with `dangerouslySetInnerHTML` and nothing else:
76
+
77
+ ```tsx
78
+ <div className="comment-body" dangerouslySetInnerHTML={{ __html: comment.bodyHtml }} />
79
+ ```
80
+
81
+ It holds `p`, `br`, `strong`, `em`, `u`, `s`, `del`, `a`, `ul`, `ol`, `li`, `code`, `pre`, `blockquote`, `h1`–`h3` and mention `span[data-type="mention"]`. Links open in a new tab. Style those tags under the body's class, because the reset strips list bullets, link colour and spacing.
82
+
83
+ ## Post a comment
84
+
85
+ ```ts
86
+ const postComment = createServerFn({ method: 'POST' })
87
+ .validator(
88
+ (data: {
89
+ recordId: string
90
+ body: string
91
+ mentions: Array<CommentMention>
92
+ attachments?: Array<CommentAttachmentInput>
93
+ }) => data,
94
+ )
95
+ .handler(({ data }) => comments.create(orderComments, data))
96
+ ```
97
+
98
+ Pass the composer's `text` as `body` and its `mentions` as they are. The SDK stores the mentions in the format native blocks read, and the people mentioned get an email. A mention whose `@name` was edited is posted as plain text.
99
+
100
+ `attachments` are the files the visitor uploaded with the post, from `useFileUpload(orderComments).files.filter((f) => f.status === 'done')`. Send them only when the thread's `allowAttachments` is true.
101
+
102
+ `useFileUpload` takes the comments declaration as it takes a mutation. No extra declaration.
103
+
104
+ ## The composer
105
+
106
+ ```tsx
107
+ const composer = useCommentComposer()
108
+
109
+ <textarea {...composer.textareaProps} placeholder="Write a comment" />
110
+ {/* While composer.query is not null, list findPeople's answer for it. Picking a person calls composer.pick(person). */}
111
+ <button
112
+ disabled={composer.text.trim() === ''}
113
+ onClick={async () => {
114
+ await postComment({ data: { recordId, body: composer.text, mentions: composer.mentions } })
115
+ composer.reset()
116
+ }}
117
+ >
118
+ Post
119
+ </button>
120
+ ```
121
+
122
+ - Spread `textareaProps` onto the `<textarea>`. It carries the `ref`, `value`, `onChange` and `onSelect` the composer needs, so do not replace them.
123
+ - `query` is what follows `@` before the caret, or `null`. It is `""` the moment `@` is typed.
124
+ - `pick({ name, email })` writes `@name`, records the mention and puts the caret after it.
125
+ - `reset()` empties the box. Call it after the post succeeds, never before.
126
+
127
+ ## Editing a comment
128
+
129
+ Offer Edit only where the comment's `canEdit` is true.
130
+
131
+ - Fill the edit box with `useCommentComposer({ initial: comment.source })`, mounted only while editing.
132
+ - Seed the edit's `useFileUpload` with the comment's kept files: `useFileUpload(orderComments, { initialAttachments: comment.attachments.map((a) => ({ ...a, id: a.url })) })`. A comment's attachment has no `id` of its own, so the url stands in for one.
133
+ - Pass `comment.attachments` (the files kept) plus any new uploads to `comments.update` as `attachments`.
134
+ - Re-read the thread after the write. `update` answers nothing.
135
+
136
+ ## Mentions
137
+
138
+ ```ts
139
+ const findPeople = createServerFn({ method: 'GET' })
140
+ .validator((data: { query: string }) => data)
141
+ .handler(({ data }) => comments.searchMentions(orderComments, data))
142
+ ```
143
+
144
+ It answers `[{ name, email, avatar }]`, the people this visitor may mention whose name or email matches. Who is mentionable is set in Settings → Comments, per user group.
145
+
146
+ ## Delete, update, react, subscribe
147
+
148
+ ```ts
149
+ await comments.delete(orderComments, { recordId, commentId })
150
+ await comments.update(orderComments, { recordId, commentId, body, mentions, attachments })
151
+ await comments.react(orderComments, { recordId, commentId, emoji: '👍', selected: !reaction.isSelected })
152
+ const subscribed = await comments.subscription(orderComments, { recordId })
153
+ await comments.setSubscription(orderComments, { recordId, subscribed: !subscribed })
154
+ ```
155
+
156
+ Each runs inside its own server function, like `list`.
157
+
158
+ - Offer delete only where `canDelete` is true.
159
+ - `update` saves an edit; see "Editing a comment" above for `attachments`.
160
+ - `react` adds the visitor's reaction when `selected` is true and removes it when false. `emoji` must be one of `quickReactions`.
161
+ - A subscription is whether the visitor gets an email when someone comments on this record. It needs a
162
+ signed-in visitor. Posting subscribes the author, and a mention subscribes the person mentioned.
163
+ - Mails go out a few minutes after a comment, never to its own author, and only while notifications are
164
+ on in Settings → Comments.
165
+
166
+ Re-read the thread after each write. The writes answer nothing.
167
+
168
+ ## Failure modes
169
+
170
+ **Keying the thread on the URL instead of the record.** Pass the fetched `record.id` as `recordId`, never a route param or a search param. A param can be a slug, stale, or a different record's id, and the thread then shows another record's comments.
171
+
172
+ **Treating `null` as an error.** A visitor who may not read comments gets `null`. Render nothing. An error box for it shows every such visitor a broken page.
173
+
174
+ **Rendering a body as text, or sanitising it again.** Use `bodyHtml` with `dangerouslySetInnerHTML`. As text it shows raw tags, and a second sanitiser strips the mention markup.
175
+
176
+ **Inventing reactions.** Only the five in `quickReactions` can be stored. `react` throws for any other emoji.
177
+
178
+ **Calling `comments.*` from a component.** It is server only. Wrap each call in a `createServerFn`.
179
+
180
+ ## References
181
+
182
+ - [record-queries/SKILL.md](../record-queries/SKILL.md): fetching the record whose `id` keys the thread
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.55
12
+ library_version: 0.0.56-alpha.0
13
13
  ---
14
14
 
15
15
  # The current user
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.55
13
+ library_version: 0.0.56-alpha.0
14
14
  ---
15
15
 
16
16
  **Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
@@ -75,9 +75,10 @@ function ApplicationForm() {
75
75
 
76
76
  ```ts
77
77
  type FileAttachment = {
78
- url: string // clean URL, no internal query params
79
- filename: string // original filename
80
- id: string // file id extracted from the upload response
78
+ url: string // clean URL, no internal query params
79
+ filename: string // original filename
80
+ id: string // file id extracted from the upload response
81
+ type?: string | null // content type from the upload response, or null when the platform doesn't know it
81
82
  }
82
83
  ```
83
84
 
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.55
13
+ library_version: 0.0.56-alpha.0
14
14
  ---
15
15
 
16
16
  **Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.55
12
+ library_version: 0.0.56-alpha.0
13
13
  ---
14
14
 
15
15
  **Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.