@farcaster/snap 2.0.0 → 2.0.2

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 (194) hide show
  1. package/dist/colors.d.ts +4 -4
  2. package/dist/colors.js +20 -20
  3. package/dist/constants.d.ts +17 -1
  4. package/dist/constants.js +19 -1
  5. package/dist/index.d.ts +4 -6
  6. package/dist/index.js +2 -4
  7. package/dist/react/accent-context.d.ts +3 -1
  8. package/dist/react/accent-context.js +7 -4
  9. package/dist/react/catalog-renderer.js +4 -0
  10. package/dist/react/components/action-button.d.ts +2 -1
  11. package/dist/react/components/action-button.js +35 -13
  12. package/dist/react/components/badge.js +8 -8
  13. package/dist/react/components/bar-chart.d.ts +5 -0
  14. package/dist/react/components/bar-chart.js +26 -0
  15. package/dist/react/components/cell-grid.d.ts +5 -0
  16. package/dist/react/components/cell-grid.js +87 -0
  17. package/dist/react/components/icon.js +4 -10
  18. package/dist/react/components/input.js +12 -6
  19. package/dist/react/components/item-group.js +3 -1
  20. package/dist/react/components/item.d.ts +3 -3
  21. package/dist/react/components/item.js +4 -3
  22. package/dist/react/components/progress.js +3 -3
  23. package/dist/react/components/separator.js +3 -1
  24. package/dist/react/components/slider.js +15 -10
  25. package/dist/react/components/switch.js +10 -12
  26. package/dist/react/components/text.js +6 -14
  27. package/dist/react/components/toggle-group.js +20 -6
  28. package/dist/react/hooks/use-snap-colors.d.ts +38 -0
  29. package/dist/react/hooks/use-snap-colors.js +81 -0
  30. package/dist/react/index.d.ts +13 -1
  31. package/dist/react/index.js +9 -188
  32. package/dist/react/snap-view-core.d.ts +11 -0
  33. package/dist/react/snap-view-core.js +227 -0
  34. package/dist/react/v1/snap-view.d.ts +16 -0
  35. package/dist/react/v1/snap-view.js +90 -0
  36. package/dist/react/v2/snap-view.d.ts +23 -0
  37. package/dist/react/v2/snap-view.js +91 -0
  38. package/dist/react-native/catalog-renderer.d.ts +5 -0
  39. package/dist/react-native/catalog-renderer.js +40 -0
  40. package/dist/react-native/components/snap-action-button.d.ts +2 -0
  41. package/dist/react-native/components/snap-action-button.js +69 -0
  42. package/dist/react-native/components/snap-badge.d.ts +2 -0
  43. package/dist/react-native/components/snap-badge.js +41 -0
  44. package/dist/react-native/components/snap-bar-chart.d.ts +2 -0
  45. package/dist/react-native/components/snap-bar-chart.js +39 -0
  46. package/dist/react-native/components/snap-cell-grid.d.ts +2 -0
  47. package/dist/react-native/components/snap-cell-grid.js +94 -0
  48. package/dist/react-native/components/snap-icon.d.ts +5 -0
  49. package/dist/react-native/components/snap-icon.js +56 -0
  50. package/dist/react-native/components/snap-image.d.ts +2 -0
  51. package/dist/react-native/components/snap-image.js +23 -0
  52. package/dist/react-native/components/snap-input.d.ts +2 -0
  53. package/dist/react-native/components/snap-input.js +37 -0
  54. package/dist/react-native/components/snap-item-group.d.ts +5 -0
  55. package/dist/react-native/components/snap-item-group.js +23 -0
  56. package/dist/react-native/components/snap-item.d.ts +5 -0
  57. package/dist/react-native/components/snap-item.js +42 -0
  58. package/dist/react-native/components/snap-progress.d.ts +2 -0
  59. package/dist/react-native/components/snap-progress.js +26 -0
  60. package/dist/react-native/components/snap-separator.d.ts +2 -0
  61. package/dist/react-native/components/snap-separator.js +23 -0
  62. package/dist/react-native/components/snap-slider.d.ts +2 -0
  63. package/dist/react-native/components/snap-slider.js +43 -0
  64. package/dist/react-native/components/snap-stack.d.ts +5 -0
  65. package/dist/react-native/components/snap-stack.js +49 -0
  66. package/dist/react-native/components/snap-switch.d.ts +2 -0
  67. package/dist/react-native/components/snap-switch.js +31 -0
  68. package/dist/react-native/components/snap-text.d.ts +2 -0
  69. package/dist/react-native/components/snap-text.js +35 -0
  70. package/dist/react-native/components/snap-toggle-group.d.ts +2 -0
  71. package/dist/react-native/components/snap-toggle-group.js +99 -0
  72. package/dist/react-native/confetti-overlay.d.ts +1 -0
  73. package/dist/react-native/confetti-overlay.js +106 -0
  74. package/dist/react-native/index.d.ts +28 -0
  75. package/dist/react-native/index.js +15 -0
  76. package/dist/react-native/snap-view-core.d.ts +11 -0
  77. package/dist/react-native/snap-view-core.js +156 -0
  78. package/dist/react-native/theme.d.ts +27 -0
  79. package/dist/react-native/theme.js +43 -0
  80. package/dist/react-native/types.d.ts +42 -0
  81. package/dist/react-native/types.js +1 -0
  82. package/dist/react-native/use-snap-palette.d.ts +13 -0
  83. package/dist/react-native/use-snap-palette.js +48 -0
  84. package/dist/react-native/v1/snap-view.d.ts +24 -0
  85. package/dist/react-native/v1/snap-view.js +96 -0
  86. package/dist/react-native/v2/snap-view.d.ts +33 -0
  87. package/dist/react-native/v2/snap-view.js +114 -0
  88. package/dist/schemas.d.ts +100 -13
  89. package/dist/schemas.js +28 -10
  90. package/dist/server/parseRequest.d.ts +10 -0
  91. package/dist/server/parseRequest.js +48 -7
  92. package/dist/server/verify.d.ts +1 -0
  93. package/dist/server/verify.js +1 -0
  94. package/dist/ui/badge.d.ts +7 -2
  95. package/dist/ui/badge.js +2 -0
  96. package/dist/ui/bar-chart.d.ts +30 -0
  97. package/dist/ui/bar-chart.js +30 -0
  98. package/dist/ui/button.d.ts +4 -6
  99. package/dist/ui/button.js +1 -1
  100. package/dist/ui/catalog.d.ts +90 -16
  101. package/dist/ui/catalog.js +17 -3
  102. package/dist/ui/cell-grid.d.ts +34 -0
  103. package/dist/ui/cell-grid.js +39 -0
  104. package/dist/ui/icon.d.ts +2 -2
  105. package/dist/ui/image.d.ts +1 -2
  106. package/dist/ui/image.js +1 -1
  107. package/dist/ui/index.d.ts +4 -0
  108. package/dist/ui/index.js +2 -0
  109. package/dist/ui/item.d.ts +1 -3
  110. package/dist/ui/item.js +1 -1
  111. package/dist/ui/schema.d.ts +6 -2
  112. package/dist/ui/schema.js +2 -2
  113. package/dist/ui/slider.d.ts +1 -0
  114. package/dist/ui/slider.js +2 -0
  115. package/dist/ui/text.d.ts +2 -4
  116. package/dist/ui/text.js +2 -2
  117. package/dist/validator.d.ts +3 -2
  118. package/dist/validator.js +203 -2
  119. package/llms.txt +199 -0
  120. package/package.json +9 -3
  121. package/src/colors.ts +20 -20
  122. package/src/constants.ts +23 -1
  123. package/src/index.ts +16 -13
  124. package/src/react/accent-context.tsx +13 -6
  125. package/src/react/catalog-renderer.tsx +4 -0
  126. package/src/react/components/action-button.tsx +50 -20
  127. package/src/react/components/badge.tsx +14 -18
  128. package/src/react/components/bar-chart.tsx +69 -0
  129. package/src/react/components/cell-grid.tsx +128 -0
  130. package/src/react/components/icon.tsx +5 -18
  131. package/src/react/components/input.tsx +20 -9
  132. package/src/react/components/item-group.tsx +4 -1
  133. package/src/react/components/item.tsx +13 -10
  134. package/src/react/components/progress.tsx +12 -7
  135. package/src/react/components/separator.tsx +8 -1
  136. package/src/react/components/slider.tsx +28 -15
  137. package/src/react/components/switch.tsx +12 -16
  138. package/src/react/components/text.tsx +14 -23
  139. package/src/react/components/toggle-group.tsx +26 -9
  140. package/src/react/hooks/use-snap-colors.ts +128 -0
  141. package/src/react/index.tsx +49 -265
  142. package/src/react/snap-view-core.tsx +343 -0
  143. package/src/react/v1/snap-view.tsx +176 -0
  144. package/src/react/v2/snap-view.tsx +199 -0
  145. package/src/react-native/catalog-renderer.tsx +41 -0
  146. package/src/react-native/components/snap-action-button.tsx +96 -0
  147. package/src/react-native/components/snap-badge.tsx +60 -0
  148. package/src/react-native/components/snap-bar-chart.tsx +73 -0
  149. package/src/react-native/components/snap-cell-grid.tsx +150 -0
  150. package/src/react-native/components/snap-icon.tsx +102 -0
  151. package/src/react-native/components/snap-image.tsx +37 -0
  152. package/src/react-native/components/snap-input.tsx +58 -0
  153. package/src/react-native/components/snap-item-group.tsx +43 -0
  154. package/src/react-native/components/snap-item.tsx +66 -0
  155. package/src/react-native/components/snap-progress.tsx +40 -0
  156. package/src/react-native/components/snap-separator.tsx +32 -0
  157. package/src/react-native/components/snap-slider.tsx +85 -0
  158. package/src/react-native/components/snap-stack.tsx +66 -0
  159. package/src/react-native/components/snap-switch.tsx +46 -0
  160. package/src/react-native/components/snap-text.tsx +51 -0
  161. package/src/react-native/components/snap-toggle-group.tsx +127 -0
  162. package/src/react-native/confetti-overlay.tsx +134 -0
  163. package/src/react-native/index.tsx +83 -0
  164. package/src/react-native/snap-view-core.tsx +212 -0
  165. package/src/react-native/theme.tsx +85 -0
  166. package/src/react-native/types.ts +38 -0
  167. package/src/react-native/use-snap-palette.ts +64 -0
  168. package/src/react-native/v1/snap-view.tsx +229 -0
  169. package/src/react-native/v2/snap-view.tsx +283 -0
  170. package/src/schemas.ts +68 -17
  171. package/src/server/parseRequest.ts +68 -9
  172. package/src/server/verify.ts +2 -0
  173. package/src/ui/README.md +8 -8
  174. package/src/ui/badge.ts +2 -0
  175. package/src/ui/bar-chart.ts +38 -0
  176. package/src/ui/button.ts +1 -1
  177. package/src/ui/catalog.ts +19 -3
  178. package/src/ui/cell-grid.ts +49 -0
  179. package/src/ui/image.ts +1 -1
  180. package/src/ui/index.ts +6 -0
  181. package/src/ui/item.ts +1 -1
  182. package/src/ui/schema.ts +2 -2
  183. package/src/ui/slider.ts +2 -0
  184. package/src/ui/text.ts +2 -2
  185. package/src/validator.ts +251 -2
  186. package/dist/dataStore.d.ts +0 -12
  187. package/dist/dataStore.js +0 -35
  188. package/dist/middleware.d.ts +0 -3
  189. package/dist/middleware.js +0 -3
  190. package/dist/react/hooks/use-snap-accent.d.ts +0 -13
  191. package/dist/react/hooks/use-snap-accent.js +0 -32
  192. package/src/dataStore.ts +0 -62
  193. package/src/middleware.ts +0 -7
  194. package/src/react/hooks/use-snap-accent.ts +0 -45
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { PALETTE_COLOR_VALUES } from "../colors.js";
3
+ import {
4
+ GRID_MIN_COLS,
5
+ GRID_MAX_COLS,
6
+ GRID_MIN_ROWS,
7
+ GRID_MAX_ROWS,
8
+ GRID_GAP_VALUES,
9
+ } from "../constants.js";
10
+
11
+ const cellGridCellSchema = z.object({
12
+ row: z.number().int().nonnegative(),
13
+ col: z.number().int().nonnegative(),
14
+ color: z.enum(PALETTE_COLOR_VALUES).optional(),
15
+ content: z.string().optional(),
16
+ });
17
+
18
+ export const cellGridProps = z
19
+ .object({
20
+ name: z.string().min(1).optional(),
21
+ cols: z.number().int().min(GRID_MIN_COLS).max(GRID_MAX_COLS),
22
+ rows: z.number().int().min(GRID_MIN_ROWS).max(GRID_MAX_ROWS),
23
+ cells: z.array(cellGridCellSchema),
24
+ gap: z.enum(GRID_GAP_VALUES).optional(),
25
+ rowHeight: z.number().int().min(8).max(64).optional(),
26
+ select: z.enum(["off", "single", "multiple"]).optional(),
27
+ })
28
+ .superRefine((val, ctx) => {
29
+ const { cols, rows, cells } = val;
30
+ for (let i = 0; i < cells.length; i++) {
31
+ const c = cells[i]!;
32
+ if (c.row < 0 || c.row >= rows) {
33
+ ctx.addIssue({
34
+ code: "custom",
35
+ message: `cell_grid cell row ${c.row} out of bounds (0–${rows - 1})`,
36
+ path: ["cells", i, "row"],
37
+ });
38
+ }
39
+ if (c.col < 0 || c.col >= cols) {
40
+ ctx.addIssue({
41
+ code: "custom",
42
+ message: `cell_grid cell col ${c.col} out of bounds (0–${cols - 1})`,
43
+ path: ["cells", i, "col"],
44
+ });
45
+ }
46
+ }
47
+ });
48
+
49
+ export type CellGridProps = z.infer<typeof cellGridProps>;
package/src/ui/image.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
 
3
- export const IMAGE_ASPECTS = ["1:1", "16:9", "4:3", "3:4", "9:16"] as const;
3
+ export const IMAGE_ASPECTS = ["1:1", "16:9", "4:3", "9:16"] as const;
4
4
 
5
5
  export const imageProps = z.object({
6
6
  url: z.string(),
package/src/ui/index.ts CHANGED
@@ -42,3 +42,9 @@ export type { StackProps } from "./stack.js";
42
42
 
43
43
  export { textProps } from "./text.js";
44
44
  export type { TextProps } from "./text.js";
45
+
46
+ export { barChartProps } from "./bar-chart.js";
47
+ export type { BarChartProps } from "./bar-chart.js";
48
+
49
+ export { cellGridProps } from "./cell-grid.js";
50
+ export type { CellGridProps } from "./cell-grid.js";
package/src/ui/item.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
 
3
- export const ITEM_VARIANTS = ["default", "outline", "muted"] as const;
3
+ export const ITEM_VARIANTS = ["default"] as const;
4
4
  export const ITEM_MAX_TITLE_CHARS = 100;
5
5
  export const ITEM_MAX_DESCRIPTION_CHARS = 160;
6
6
 
package/src/ui/schema.ts CHANGED
@@ -11,7 +11,7 @@ export const snapJsonRenderSchema = defineSchema(
11
11
  elements: s.record(
12
12
  s.object({
13
13
  type: s.ref("catalog.components"),
14
- props: s.propsOf("catalog.components"),
14
+ props: { ...s.propsOf("catalog.components"), optional: true },
15
15
  children: { ...s.array(s.string()), optional: true },
16
16
  }),
17
17
  ),
@@ -30,7 +30,7 @@ export const snapJsonRenderSchema = defineSchema(
30
30
  {
31
31
  defaultRules: [
32
32
  "You are generating auxiliary UI for a Farcaster Snap. Prefer components matching snap element types (Item, Badge, ButtonGroup, Input, Switch, ToggleGroup, Slider, Progress, Image, Separator).",
33
- "Snap pages use a Stack root with at most 6 body children and 1 media element (Image); keep generated trees small.",
33
+ "Snap structural limits: max 64 elements, max 7 children on root, max 6 children per non-root container, max 4 levels of nesting. Keep generated trees small.",
34
34
  "Bottom-of-card snap buttons are Button components; use actions post / link / mini_app / sdk per SPEC.md.",
35
35
  ],
36
36
  },
package/src/ui/slider.ts CHANGED
@@ -12,6 +12,8 @@ export const sliderProps = z
12
12
  step: z.number().optional(),
13
13
  defaultValue: z.number().optional(),
14
14
  label: z.string().max(SLIDER_MAX_LABEL_CHARS).optional(),
15
+ /** When true, display the current value next to the label. */
16
+ showValue: z.boolean().optional(),
15
17
  })
16
18
  .superRefine((val, ctx) => {
17
19
  if (val.min > val.max) {
package/src/ui/text.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
- export const TEXT_SIZES = ["lg", "md", "sm"] as const;
4
- export const TEXT_WEIGHTS = ["bold", "medium", "normal"] as const;
3
+ export const TEXT_SIZES = ["md", "sm"] as const;
4
+ export const TEXT_WEIGHTS = ["bold", "normal"] as const;
5
5
  export const TEXT_ALIGNS = ["left", "center", "right"] as const;
6
6
  export const TEXT_MAX_CONTENT_CHARS = 320;
7
7
 
package/src/validator.ts CHANGED
@@ -1,15 +1,231 @@
1
1
  import { z } from "zod";
2
2
  import { snapResponseSchema } from "./schemas";
3
+ import { MAX_CHILDREN, MAX_DEPTH, MAX_ELEMENTS, MAX_ROOT_CHILDREN, SPEC_VERSION_1 } from "./constants";
4
+ import { snapJsonRenderCatalog } from "./ui/catalog.js";
3
5
 
4
6
  export type ValidationResult = {
5
7
  valid: boolean;
6
8
  issues: z.core.$ZodIssue[];
7
9
  };
8
10
 
11
+ // ─── Helpers ──────────────────────────────────────────
12
+
13
+ /** Actions whose `params.target` must be a valid URL. */
14
+ const URL_TARGET_ACTIONS = new Set([
15
+ "submit",
16
+ "open_url",
17
+ "open_snap",
18
+ "open_mini_app",
19
+ ]);
20
+
21
+ /** Image file extensions allowed in image URLs. */
22
+ const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
23
+
24
+ /**
25
+ * Returns true if the URL is a loopback address (localhost dev exception).
26
+ */
27
+ function isLoopback(url: URL): boolean {
28
+ const host = url.hostname;
29
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
30
+ }
31
+
32
+ /**
33
+ * Validate a URL string: must be HTTPS (or HTTP on loopback for dev).
34
+ * Returns an error message or null if valid.
35
+ */
36
+ function validateUrl(raw: string): string | null {
37
+ let url: URL;
38
+ try {
39
+ url = new URL(raw);
40
+ } catch {
41
+ return `Invalid URL: "${raw}"`;
42
+ }
43
+
44
+ if (url.protocol === "https:") return null;
45
+ if (url.protocol === "http:" && isLoopback(url)) return null;
46
+ if (url.protocol === "javascript:") return `javascript: URIs are not allowed`;
47
+
48
+ return `URL must use HTTPS (got ${url.protocol.replace(":", "")}): "${raw}"`;
49
+ }
50
+
51
+ /**
52
+ * Validate an image URL: must pass URL validation + have an allowed extension.
53
+ */
54
+ function validateImageUrl(raw: string): string | null {
55
+ const urlError = validateUrl(raw);
56
+ if (urlError) return urlError;
57
+
58
+ let url: URL;
59
+ try {
60
+ url = new URL(raw);
61
+ } catch {
62
+ return null; // already caught above
63
+ }
64
+
65
+ const pathname = url.pathname;
66
+ const lastDot = pathname.lastIndexOf(".");
67
+ if (lastDot === -1) {
68
+ return `Image URL must end with a supported extension (${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
69
+ }
70
+
71
+ const ext = pathname.slice(lastDot + 1).toLowerCase();
72
+ if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
73
+ return `Image URL has unsupported extension ".${ext}" (allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ // ─── Depth measurement ────────────────────────────────
80
+
9
81
  /**
10
- * Validates a snap response against the schema.
82
+ * Walk the element tree from `root` and return the max depth reached.
83
+ * Avoids infinite loops by tracking visited element ids.
84
+ */
85
+ function measureDepth(
86
+ elements: Record<string, { children?: string[] }>,
87
+ id: string,
88
+ visited: Set<string> = new Set(),
89
+ ): number {
90
+ if (visited.has(id)) return 0;
91
+ visited.add(id);
92
+
93
+ const el = elements[id];
94
+ if (!el?.children?.length) return 1;
95
+
96
+ let max = 0;
97
+ for (const childId of el.children) {
98
+ max = Math.max(max, measureDepth(elements, childId, visited));
99
+ }
100
+ return 1 + max;
101
+ }
102
+
103
+ // ─── Element types for traversal ──────────────────────
104
+
105
+ type ElementShape = {
106
+ type?: string;
107
+ children?: string[];
108
+ props?: Record<string, unknown>;
109
+ on?: Record<string, { action?: string; params?: Record<string, unknown> }>;
110
+ };
111
+
112
+ // ─── Structural validation ────────────────────────────
113
+
114
+ /**
115
+ * Validate structural constraints on the snap UI tree:
116
+ * - root must reference an existing element
117
+ * - Total element count ≤ MAX_ELEMENTS
118
+ * - Children per element ≤ MAX_CHILDREN
119
+ * - Nesting depth ≤ MAX_DEPTH
120
+ */
121
+ function validateStructure(
122
+ ui: { root: string; elements: Record<string, unknown> },
123
+ ): z.core.$ZodIssue[] {
124
+ const issues: z.core.$ZodIssue[] = [];
125
+ const elements = ui.elements as Record<string, ElementShape>;
126
+
127
+ const elementCount = Object.keys(elements).length;
128
+ if (elementCount > MAX_ELEMENTS) {
129
+ issues.push({
130
+ code: "custom",
131
+ message: `Snap exceeds maximum of ${MAX_ELEMENTS} elements (found ${elementCount})`,
132
+ path: ["ui", "elements"],
133
+ });
134
+ }
135
+
136
+ // Root element has a stricter children limit
137
+ const rootEl = elements[ui.root];
138
+ if (rootEl?.children && rootEl.children.length > MAX_ROOT_CHILDREN) {
139
+ issues.push({
140
+ code: "custom",
141
+ message: `Root element "${ui.root}" exceeds maximum of ${MAX_ROOT_CHILDREN} children (found ${rootEl.children.length})`,
142
+ path: ["ui", "elements", ui.root, "children"],
143
+ });
144
+ }
145
+
146
+ for (const [id, el] of Object.entries(elements)) {
147
+ if (id === ui.root) continue; // already checked above
148
+ if (el.children && el.children.length > MAX_CHILDREN) {
149
+ issues.push({
150
+ code: "custom",
151
+ message: `Element "${id}" exceeds maximum of ${MAX_CHILDREN} children (found ${el.children.length})`,
152
+ path: ["ui", "elements", id, "children"],
153
+ });
154
+ }
155
+ }
156
+
157
+ const depth = measureDepth(
158
+ elements as Record<string, { children?: string[] }>,
159
+ ui.root,
160
+ );
161
+ if (depth > MAX_DEPTH) {
162
+ issues.push({
163
+ code: "custom",
164
+ message: `Snap exceeds maximum nesting depth of ${MAX_DEPTH} (found ${depth})`,
165
+ path: ["ui", "root"],
166
+ });
167
+ }
168
+
169
+ return issues;
170
+ }
171
+
172
+ // ─── URL validation ───────────────────────────────────
173
+
174
+ /**
175
+ * Validate all URLs in the snap:
176
+ * - image.url: must be HTTPS with allowed extension
177
+ * - action target URLs (submit, open_url, open_snap, open_mini_app): must be HTTPS
178
+ */
179
+ function validateUrls(
180
+ elements: Record<string, unknown>,
181
+ ): z.core.$ZodIssue[] {
182
+ const issues: z.core.$ZodIssue[] = [];
183
+ const els = elements as Record<string, ElementShape>;
184
+
185
+ for (const [id, el] of Object.entries(els)) {
186
+ // Validate image URLs
187
+ if (el.type === "image" && typeof el.props?.url === "string") {
188
+ const error = validateImageUrl(el.props.url);
189
+ if (error) {
190
+ issues.push({
191
+ code: "custom",
192
+ message: error,
193
+ path: ["ui", "elements", id, "props", "url"],
194
+ });
195
+ }
196
+ }
197
+
198
+ // Validate action target URLs
199
+ if (el.on) {
200
+ for (const [event, binding] of Object.entries(el.on)) {
201
+ if (
202
+ binding &&
203
+ URL_TARGET_ACTIONS.has(binding.action ?? "") &&
204
+ typeof binding.params?.target === "string"
205
+ ) {
206
+ const error = validateUrl(binding.params.target);
207
+ if (error) {
208
+ issues.push({
209
+ code: "custom",
210
+ message: error,
211
+ path: ["ui", "elements", id, "on", event, "params", "target"],
212
+ });
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ return issues;
220
+ }
221
+
222
+ // ─── Public API ───────────────────────────────────────
223
+
224
+ /**
225
+ * Validates a snap response against the schema, structural constraints, and URL rules.
11
226
  * Element-level prop validation is handled by the json-render catalog.
12
- * This validates the snap envelope (version, theme, effects, spec shape).
227
+ * This validates the snap envelope (version, theme, effects, spec shape)
228
+ * and enforces structural limits (element count, children, depth) and URL validation.
13
229
  */
14
230
  export function validateSnapResponse(json: unknown): ValidationResult {
15
231
  const parsed = snapResponseSchema.safeParse(json);
@@ -19,5 +235,38 @@ export function validateSnapResponse(json: unknown): ValidationResult {
19
235
  issues: parsed.error.issues,
20
236
  };
21
237
  }
238
+
239
+ const ui = parsed.data.ui;
240
+
241
+ // Root reference check applies to all versions
242
+ if (!(ui.root in ui.elements)) {
243
+ return {
244
+ valid: false,
245
+ issues: [{
246
+ code: "custom",
247
+ message: `ui.root "${ui.root}" does not exist in ui.elements`,
248
+ path: ["ui", "root"],
249
+ }],
250
+ };
251
+ }
252
+
253
+ // Structural limits and URL validation only apply to v2+ snaps
254
+ if (parsed.data.version !== SPEC_VERSION_1) {
255
+ const structuralIssues = validateStructure(ui);
256
+ if (structuralIssues.length > 0) {
257
+ return { valid: false, issues: structuralIssues };
258
+ }
259
+
260
+ const urlIssues = validateUrls(ui.elements);
261
+ if (urlIssues.length > 0) {
262
+ return { valid: false, issues: urlIssues };
263
+ }
264
+
265
+ const catalogResult = snapJsonRenderCatalog.validate(ui);
266
+ if (!catalogResult.success) {
267
+ return { valid: false, issues: catalogResult.error?.issues ?? [] };
268
+ }
269
+ }
270
+
22
271
  return { valid: true, issues: [] };
23
272
  }
@@ -1,12 +0,0 @@
1
- export type DataStoreValue = string | number | boolean | null | DataStoreValue[] | {
2
- [key: string]: DataStoreValue;
3
- };
4
- export type SnapDataStoreOperations = {
5
- get(key: string): Promise<DataStoreValue | null>;
6
- set(key: string, value: DataStoreValue): Promise<void>;
7
- };
8
- export type SnapDataStore = SnapDataStoreOperations & {
9
- withLock<T>(fn: (store: SnapDataStoreOperations) => Promise<T>): Promise<T>;
10
- };
11
- export declare function createDefaultDataStore(): SnapDataStore;
12
- export declare function createInMemoryDataStore(): SnapDataStore;
package/dist/dataStore.js DELETED
@@ -1,35 +0,0 @@
1
- export function createDefaultDataStore() {
2
- const err = new Error("Data store is not configured. Use withUpstash() from @farcaster/snap-upstash or provide a data store implementation.");
3
- return {
4
- get(_key) {
5
- return Promise.reject(err);
6
- },
7
- set(_key, _value) {
8
- return Promise.reject(err);
9
- },
10
- withLock(_fn) {
11
- return Promise.reject(err);
12
- },
13
- };
14
- }
15
- export function createInMemoryDataStore() {
16
- const data = new Map();
17
- const ops = {
18
- get: async (key) => {
19
- return data.get(key) ?? null;
20
- },
21
- set: async (key, value) => {
22
- data.set(key, value);
23
- },
24
- };
25
- /** Serializes `withLock` callbacks so async work does not interleave across callers. */
26
- let lockChain = Promise.resolve();
27
- return {
28
- ...ops,
29
- withLock(fn) {
30
- const run = lockChain.then(() => fn(ops));
31
- lockChain = run.then(() => undefined, () => undefined);
32
- return run;
33
- },
34
- };
35
- }
@@ -1,3 +0,0 @@
1
- import { SnapFunction } from "./schemas.js";
2
- export type Middleware = (fn: SnapFunction) => SnapFunction;
3
- export declare function useMiddleware(fn: SnapFunction, middleware: Middleware[]): SnapFunction;
@@ -1,3 +0,0 @@
1
- export function useMiddleware(fn, middleware) {
2
- return middleware.reduce((acc, middleware) => middleware(acc), fn);
3
- }
@@ -1,13 +0,0 @@
1
- import { type CSSProperties } from "react";
2
- import type { PaletteColor } from "@farcaster/snap";
3
- /**
4
- * CSS variables so Neynar controls (`bg-primary`, `data-checked:bg-primary`, etc.)
5
- * use the snap `theme.accent` inside json-render catalog components.
6
- */
7
- export declare function useSnapAccentScopeStyle(): CSSProperties;
8
- /** Active snap palette table for the current docs shell theme. */
9
- export declare function useSnapPalette(): {
10
- hex: (name: string) => string;
11
- map: Record<PaletteColor, string>;
12
- theme: "light" | "dark";
13
- };
@@ -1,32 +0,0 @@
1
- "use client";
2
- import { useMemo } from "react";
3
- import { useStateStore } from "@json-render/react";
4
- import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
5
- import { useColorMode } from "@neynar/ui/color-mode";
6
- import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex.js";
7
- import { snapPreviewPrimaryCssProperties } from "../lib/preview-primary-css.js";
8
- import { useSnapPreviewPageAccent } from "../accent-context.js";
9
- /**
10
- * CSS variables so Neynar controls (`bg-primary`, `data-checked:bg-primary`, etc.)
11
- * use the snap `theme.accent` inside json-render catalog components.
12
- */
13
- export function useSnapAccentScopeStyle() {
14
- const { get } = useStateStore();
15
- const { mode } = useColorMode();
16
- const pageAccent = useSnapPreviewPageAccent();
17
- const fromState = get("/theme/accent");
18
- const accentRaw = (typeof pageAccent === "string" && pageAccent.length > 0
19
- ? pageAccent
20
- : fromState) ?? undefined;
21
- const accentName = typeof accentRaw === "string" && accentRaw.length > 0
22
- ? accentRaw
23
- : "purple";
24
- return useMemo(() => snapPreviewPrimaryCssProperties(accentName, mode), [accentName, mode]);
25
- }
26
- /** Active snap palette table for the current docs shell theme. */
27
- export function useSnapPalette() {
28
- const { mode } = useColorMode();
29
- const map = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
30
- const hex = (name) => resolveSnapPaletteHex(name, mode);
31
- return { hex, map, theme: mode };
32
- }
package/src/dataStore.ts DELETED
@@ -1,62 +0,0 @@
1
- export type DataStoreValue =
2
- | string
3
- | number
4
- | boolean
5
- | null
6
- | DataStoreValue[]
7
- | { [key: string]: DataStoreValue };
8
-
9
- export type SnapDataStoreOperations = {
10
- get(key: string): Promise<DataStoreValue | null>;
11
- set(key: string, value: DataStoreValue): Promise<void>;
12
- };
13
-
14
- export type SnapDataStore = SnapDataStoreOperations & {
15
- withLock<T>(fn: (store: SnapDataStoreOperations) => Promise<T>): Promise<T>;
16
- };
17
-
18
- export function createDefaultDataStore(): SnapDataStore {
19
- const err = new Error(
20
- "Data store is not configured. Use withUpstash() from @farcaster/snap-upstash or provide a data store implementation.",
21
- );
22
- return {
23
- get(_key: string): Promise<never> {
24
- return Promise.reject(err);
25
- },
26
- set(_key: string, _value: DataStoreValue): Promise<never> {
27
- return Promise.reject(err);
28
- },
29
- withLock<T>(
30
- _fn: (store: SnapDataStoreOperations) => Promise<T>,
31
- ): Promise<never> {
32
- return Promise.reject(err);
33
- },
34
- };
35
- }
36
-
37
- export function createInMemoryDataStore(): SnapDataStore {
38
- const data = new Map<string, DataStoreValue>();
39
- const ops: SnapDataStoreOperations = {
40
- get: async (key: string): Promise<DataStoreValue | null> => {
41
- return data.get(key) ?? null;
42
- },
43
- set: async (key: string, value: DataStoreValue): Promise<void> => {
44
- data.set(key, value);
45
- },
46
- };
47
- /** Serializes `withLock` callbacks so async work does not interleave across callers. */
48
- let lockChain: Promise<unknown> = Promise.resolve();
49
- return {
50
- ...ops,
51
- withLock<T>(
52
- fn: (store: SnapDataStoreOperations) => Promise<T>,
53
- ): Promise<T> {
54
- const run = lockChain.then(() => fn(ops));
55
- lockChain = run.then(
56
- () => undefined,
57
- () => undefined,
58
- );
59
- return run;
60
- },
61
- };
62
- }
package/src/middleware.ts DELETED
@@ -1,7 +0,0 @@
1
- import { SnapFunction } from "./schemas";
2
-
3
- export type Middleware = (fn: SnapFunction) => SnapFunction;
4
-
5
- export function useMiddleware(fn: SnapFunction, middleware: Middleware[]) {
6
- return middleware.reduce((acc, middleware) => middleware(acc), fn);
7
- }
@@ -1,45 +0,0 @@
1
- "use client";
2
-
3
- import { useMemo, type CSSProperties } from "react";
4
- import { useStateStore } from "@json-render/react";
5
- import type { PaletteColor } from "@farcaster/snap";
6
- import { PALETTE_DARK_HEX, PALETTE_LIGHT_HEX } from "@farcaster/snap";
7
- import { useColorMode } from "@neynar/ui/color-mode";
8
- import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
9
- import { snapPreviewPrimaryCssProperties } from "../lib/preview-primary-css";
10
- import { useSnapPreviewPageAccent } from "../accent-context";
11
-
12
- /**
13
- * CSS variables so Neynar controls (`bg-primary`, `data-checked:bg-primary`, etc.)
14
- * use the snap `theme.accent` inside json-render catalog components.
15
- */
16
- export function useSnapAccentScopeStyle(): CSSProperties {
17
- const { get } = useStateStore();
18
- const { mode } = useColorMode();
19
- const pageAccent = useSnapPreviewPageAccent();
20
- const fromState = get("/theme/accent");
21
- const accentRaw =
22
- (typeof pageAccent === "string" && pageAccent.length > 0
23
- ? pageAccent
24
- : fromState) ?? undefined;
25
- const accentName =
26
- typeof accentRaw === "string" && accentRaw.length > 0
27
- ? accentRaw
28
- : "purple";
29
- return useMemo(
30
- () => snapPreviewPrimaryCssProperties(accentName, mode),
31
- [accentName, mode],
32
- );
33
- }
34
-
35
- /** Active snap palette table for the current docs shell theme. */
36
- export function useSnapPalette(): {
37
- hex: (name: string) => string;
38
- map: Record<PaletteColor, string>;
39
- theme: "light" | "dark";
40
- } {
41
- const { mode } = useColorMode();
42
- const map = mode === "dark" ? PALETTE_DARK_HEX : PALETTE_LIGHT_HEX;
43
- const hex = (name: string) => resolveSnapPaletteHex(name, mode);
44
- return { hex, map, theme: mode };
45
- }