@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.
- package/dist/colors.d.ts +4 -4
- package/dist/colors.js +20 -20
- package/dist/constants.d.ts +17 -1
- package/dist/constants.js +19 -1
- package/dist/index.d.ts +4 -6
- package/dist/index.js +2 -4
- package/dist/react/accent-context.d.ts +3 -1
- package/dist/react/accent-context.js +7 -4
- package/dist/react/catalog-renderer.js +4 -0
- package/dist/react/components/action-button.d.ts +2 -1
- package/dist/react/components/action-button.js +35 -13
- package/dist/react/components/badge.js +8 -8
- package/dist/react/components/bar-chart.d.ts +5 -0
- package/dist/react/components/bar-chart.js +26 -0
- package/dist/react/components/cell-grid.d.ts +5 -0
- package/dist/react/components/cell-grid.js +87 -0
- package/dist/react/components/icon.js +4 -10
- package/dist/react/components/input.js +12 -6
- package/dist/react/components/item-group.js +3 -1
- package/dist/react/components/item.d.ts +3 -3
- package/dist/react/components/item.js +4 -3
- package/dist/react/components/progress.js +3 -3
- package/dist/react/components/separator.js +3 -1
- package/dist/react/components/slider.js +15 -10
- package/dist/react/components/switch.js +10 -12
- package/dist/react/components/text.js +6 -14
- package/dist/react/components/toggle-group.js +20 -6
- package/dist/react/hooks/use-snap-colors.d.ts +38 -0
- package/dist/react/hooks/use-snap-colors.js +81 -0
- package/dist/react/index.d.ts +13 -1
- package/dist/react/index.js +9 -188
- package/dist/react/snap-view-core.d.ts +11 -0
- package/dist/react/snap-view-core.js +227 -0
- package/dist/react/v1/snap-view.d.ts +16 -0
- package/dist/react/v1/snap-view.js +90 -0
- package/dist/react/v2/snap-view.d.ts +23 -0
- package/dist/react/v2/snap-view.js +91 -0
- package/dist/react-native/catalog-renderer.d.ts +5 -0
- package/dist/react-native/catalog-renderer.js +40 -0
- package/dist/react-native/components/snap-action-button.d.ts +2 -0
- package/dist/react-native/components/snap-action-button.js +69 -0
- package/dist/react-native/components/snap-badge.d.ts +2 -0
- package/dist/react-native/components/snap-badge.js +41 -0
- package/dist/react-native/components/snap-bar-chart.d.ts +2 -0
- package/dist/react-native/components/snap-bar-chart.js +39 -0
- package/dist/react-native/components/snap-cell-grid.d.ts +2 -0
- package/dist/react-native/components/snap-cell-grid.js +94 -0
- package/dist/react-native/components/snap-icon.d.ts +5 -0
- package/dist/react-native/components/snap-icon.js +56 -0
- package/dist/react-native/components/snap-image.d.ts +2 -0
- package/dist/react-native/components/snap-image.js +23 -0
- package/dist/react-native/components/snap-input.d.ts +2 -0
- package/dist/react-native/components/snap-input.js +37 -0
- package/dist/react-native/components/snap-item-group.d.ts +5 -0
- package/dist/react-native/components/snap-item-group.js +23 -0
- package/dist/react-native/components/snap-item.d.ts +5 -0
- package/dist/react-native/components/snap-item.js +42 -0
- package/dist/react-native/components/snap-progress.d.ts +2 -0
- package/dist/react-native/components/snap-progress.js +26 -0
- package/dist/react-native/components/snap-separator.d.ts +2 -0
- package/dist/react-native/components/snap-separator.js +23 -0
- package/dist/react-native/components/snap-slider.d.ts +2 -0
- package/dist/react-native/components/snap-slider.js +43 -0
- package/dist/react-native/components/snap-stack.d.ts +5 -0
- package/dist/react-native/components/snap-stack.js +49 -0
- package/dist/react-native/components/snap-switch.d.ts +2 -0
- package/dist/react-native/components/snap-switch.js +31 -0
- package/dist/react-native/components/snap-text.d.ts +2 -0
- package/dist/react-native/components/snap-text.js +35 -0
- package/dist/react-native/components/snap-toggle-group.d.ts +2 -0
- package/dist/react-native/components/snap-toggle-group.js +99 -0
- package/dist/react-native/confetti-overlay.d.ts +1 -0
- package/dist/react-native/confetti-overlay.js +106 -0
- package/dist/react-native/index.d.ts +28 -0
- package/dist/react-native/index.js +15 -0
- package/dist/react-native/snap-view-core.d.ts +11 -0
- package/dist/react-native/snap-view-core.js +156 -0
- package/dist/react-native/theme.d.ts +27 -0
- package/dist/react-native/theme.js +43 -0
- package/dist/react-native/types.d.ts +42 -0
- package/dist/react-native/types.js +1 -0
- package/dist/react-native/use-snap-palette.d.ts +13 -0
- package/dist/react-native/use-snap-palette.js +48 -0
- package/dist/react-native/v1/snap-view.d.ts +24 -0
- package/dist/react-native/v1/snap-view.js +96 -0
- package/dist/react-native/v2/snap-view.d.ts +33 -0
- package/dist/react-native/v2/snap-view.js +114 -0
- package/dist/schemas.d.ts +100 -13
- package/dist/schemas.js +28 -10
- package/dist/server/parseRequest.d.ts +10 -0
- package/dist/server/parseRequest.js +48 -7
- package/dist/server/verify.d.ts +1 -0
- package/dist/server/verify.js +1 -0
- package/dist/ui/badge.d.ts +7 -2
- package/dist/ui/badge.js +2 -0
- package/dist/ui/bar-chart.d.ts +30 -0
- package/dist/ui/bar-chart.js +30 -0
- package/dist/ui/button.d.ts +4 -6
- package/dist/ui/button.js +1 -1
- package/dist/ui/catalog.d.ts +90 -16
- package/dist/ui/catalog.js +17 -3
- package/dist/ui/cell-grid.d.ts +34 -0
- package/dist/ui/cell-grid.js +39 -0
- package/dist/ui/icon.d.ts +2 -2
- package/dist/ui/image.d.ts +1 -2
- package/dist/ui/image.js +1 -1
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/item.d.ts +1 -3
- package/dist/ui/item.js +1 -1
- package/dist/ui/schema.d.ts +6 -2
- package/dist/ui/schema.js +2 -2
- package/dist/ui/slider.d.ts +1 -0
- package/dist/ui/slider.js +2 -0
- package/dist/ui/text.d.ts +2 -4
- package/dist/ui/text.js +2 -2
- package/dist/validator.d.ts +3 -2
- package/dist/validator.js +203 -2
- package/llms.txt +199 -0
- package/package.json +9 -3
- package/src/colors.ts +20 -20
- package/src/constants.ts +23 -1
- package/src/index.ts +16 -13
- package/src/react/accent-context.tsx +13 -6
- package/src/react/catalog-renderer.tsx +4 -0
- package/src/react/components/action-button.tsx +50 -20
- package/src/react/components/badge.tsx +14 -18
- package/src/react/components/bar-chart.tsx +69 -0
- package/src/react/components/cell-grid.tsx +128 -0
- package/src/react/components/icon.tsx +5 -18
- package/src/react/components/input.tsx +20 -9
- package/src/react/components/item-group.tsx +4 -1
- package/src/react/components/item.tsx +13 -10
- package/src/react/components/progress.tsx +12 -7
- package/src/react/components/separator.tsx +8 -1
- package/src/react/components/slider.tsx +28 -15
- package/src/react/components/switch.tsx +12 -16
- package/src/react/components/text.tsx +14 -23
- package/src/react/components/toggle-group.tsx +26 -9
- package/src/react/hooks/use-snap-colors.ts +128 -0
- package/src/react/index.tsx +49 -265
- package/src/react/snap-view-core.tsx +343 -0
- package/src/react/v1/snap-view.tsx +176 -0
- package/src/react/v2/snap-view.tsx +199 -0
- package/src/react-native/catalog-renderer.tsx +41 -0
- package/src/react-native/components/snap-action-button.tsx +96 -0
- package/src/react-native/components/snap-badge.tsx +60 -0
- package/src/react-native/components/snap-bar-chart.tsx +73 -0
- package/src/react-native/components/snap-cell-grid.tsx +150 -0
- package/src/react-native/components/snap-icon.tsx +102 -0
- package/src/react-native/components/snap-image.tsx +37 -0
- package/src/react-native/components/snap-input.tsx +58 -0
- package/src/react-native/components/snap-item-group.tsx +43 -0
- package/src/react-native/components/snap-item.tsx +66 -0
- package/src/react-native/components/snap-progress.tsx +40 -0
- package/src/react-native/components/snap-separator.tsx +32 -0
- package/src/react-native/components/snap-slider.tsx +85 -0
- package/src/react-native/components/snap-stack.tsx +66 -0
- package/src/react-native/components/snap-switch.tsx +46 -0
- package/src/react-native/components/snap-text.tsx +51 -0
- package/src/react-native/components/snap-toggle-group.tsx +127 -0
- package/src/react-native/confetti-overlay.tsx +134 -0
- package/src/react-native/index.tsx +83 -0
- package/src/react-native/snap-view-core.tsx +212 -0
- package/src/react-native/theme.tsx +85 -0
- package/src/react-native/types.ts +38 -0
- package/src/react-native/use-snap-palette.ts +64 -0
- package/src/react-native/v1/snap-view.tsx +229 -0
- package/src/react-native/v2/snap-view.tsx +283 -0
- package/src/schemas.ts +68 -17
- package/src/server/parseRequest.ts +68 -9
- package/src/server/verify.ts +2 -0
- package/src/ui/README.md +8 -8
- package/src/ui/badge.ts +2 -0
- package/src/ui/bar-chart.ts +38 -0
- package/src/ui/button.ts +1 -1
- package/src/ui/catalog.ts +19 -3
- package/src/ui/cell-grid.ts +49 -0
- package/src/ui/image.ts +1 -1
- package/src/ui/index.ts +6 -0
- package/src/ui/item.ts +1 -1
- package/src/ui/schema.ts +2 -2
- package/src/ui/slider.ts +2 -0
- package/src/ui/text.ts +2 -2
- package/src/validator.ts +251 -2
- package/dist/dataStore.d.ts +0 -12
- package/dist/dataStore.js +0 -35
- package/dist/middleware.d.ts +0 -3
- package/dist/middleware.js +0 -3
- package/dist/react/hooks/use-snap-accent.d.ts +0 -13
- package/dist/react/hooks/use-snap-accent.js +0 -32
- package/src/dataStore.ts +0 -62
- package/src/middleware.ts +0 -7
- 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
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
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
|
|
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 = ["
|
|
4
|
-
export const TEXT_WEIGHTS = ["bold", "
|
|
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
|
-
*
|
|
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
|
}
|
package/dist/dataStore.d.ts
DELETED
|
@@ -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
|
-
}
|
package/dist/middleware.d.ts
DELETED
package/dist/middleware.js
DELETED
|
@@ -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,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
|
-
}
|