figma-plugin-utilities 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -0
- package/package.json +53 -0
- package/src/components/EmptyState.svelte +116 -0
- package/src/components/FieldGroup.svelte +34 -0
- package/src/components/Footer.svelte +110 -0
- package/src/components/Header.svelte +81 -0
- package/src/components/ListItem.svelte +172 -0
- package/src/components/LoadingState.svelte +32 -0
- package/src/components/PluginLayout.svelte +43 -0
- package/src/components/StatusBar.svelte +106 -0
- package/src/components/index.js +9 -0
- package/src/index.js +45 -0
- package/src/lib/colors.js +77 -0
- package/src/lib/errorHandling.js +186 -0
- package/src/lib/figma-helpers.ts +129 -0
- package/src/lib/index.js +47 -0
- package/src/lib/messages.js +36 -0
- package/src/lib/resize.js +147 -0
- package/src/lib/validation.js +234 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma API helpers for plugin code (code.ts)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Send a typed message to the UI
|
|
7
|
+
* @param type - Message type identifier
|
|
8
|
+
* @param data - Additional data to send
|
|
9
|
+
*/
|
|
10
|
+
export function sendToUI<T extends Record<string, unknown>>(
|
|
11
|
+
type: string,
|
|
12
|
+
data?: T
|
|
13
|
+
): void {
|
|
14
|
+
if (data) {
|
|
15
|
+
figma.ui.postMessage({ type, ...data });
|
|
16
|
+
} else {
|
|
17
|
+
figma.ui.postMessage({ type });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get all local variable collections
|
|
23
|
+
* @returns Promise resolving to array of variable collections
|
|
24
|
+
*/
|
|
25
|
+
export async function getCollections(): Promise<VariableCollection[]> {
|
|
26
|
+
return figma.variables.getLocalVariableCollectionsAsync();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get all local variables of a specific type
|
|
31
|
+
* @param type - Variable type to filter by
|
|
32
|
+
* @returns Promise resolving to array of variables
|
|
33
|
+
*/
|
|
34
|
+
export async function getVariables(
|
|
35
|
+
type?: VariableResolvedDataType
|
|
36
|
+
): Promise<Variable[]> {
|
|
37
|
+
return figma.variables.getLocalVariablesAsync(type);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Show an error notification to the user
|
|
42
|
+
* @param message - Error message to display
|
|
43
|
+
* @param timeout - How long to show the notification (ms)
|
|
44
|
+
*/
|
|
45
|
+
export function showError(message: string, timeout = 5000): void {
|
|
46
|
+
figma.notify(message, { error: true, timeout });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Show a success notification to the user
|
|
51
|
+
* @param message - Success message to display
|
|
52
|
+
* @param timeout - How long to show the notification (ms)
|
|
53
|
+
*/
|
|
54
|
+
export function showSuccess(message: string, timeout = 3000): void {
|
|
55
|
+
figma.notify(message, { timeout });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the current selection, optionally filtered by type
|
|
60
|
+
* @param nodeType - Optional node type to filter by
|
|
61
|
+
* @returns Array of selected nodes
|
|
62
|
+
*/
|
|
63
|
+
export function getSelection<T extends SceneNode>(
|
|
64
|
+
nodeType?: NodeType
|
|
65
|
+
): readonly T[] {
|
|
66
|
+
const selection = figma.currentPage.selection;
|
|
67
|
+
if (nodeType) {
|
|
68
|
+
return selection.filter((node) => node.type === nodeType) as T[];
|
|
69
|
+
}
|
|
70
|
+
return selection as readonly T[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Focus the viewport on specific nodes
|
|
75
|
+
* @param nodes - Nodes to focus on
|
|
76
|
+
*/
|
|
77
|
+
export function focusNodes(nodes: readonly SceneNode[]): void {
|
|
78
|
+
if (nodes.length > 0) {
|
|
79
|
+
figma.viewport.scrollAndZoomIntoView(nodes);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load a font before using it
|
|
85
|
+
* @param family - Font family name
|
|
86
|
+
* @param style - Font style (e.g., "Regular", "Bold")
|
|
87
|
+
*/
|
|
88
|
+
export async function loadFont(
|
|
89
|
+
family: string,
|
|
90
|
+
style: string
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
await figma.loadFontAsync({ family, style });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Save data to client storage (persists across sessions)
|
|
97
|
+
* @param key - Storage key
|
|
98
|
+
* @param value - Value to store (must be JSON-serializable)
|
|
99
|
+
*/
|
|
100
|
+
export async function saveToStorage<T>(key: string, value: T): Promise<void> {
|
|
101
|
+
await figma.clientStorage.setAsync(key, value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load data from client storage
|
|
106
|
+
* @param key - Storage key
|
|
107
|
+
* @param defaultValue - Default value if key doesn't exist
|
|
108
|
+
* @returns Stored value or default
|
|
109
|
+
*/
|
|
110
|
+
export async function loadFromStorage<T>(
|
|
111
|
+
key: string,
|
|
112
|
+
defaultValue?: T
|
|
113
|
+
): Promise<T | undefined> {
|
|
114
|
+
try {
|
|
115
|
+
const value = await figma.clientStorage.getAsync(key);
|
|
116
|
+
return value !== undefined ? value : defaultValue;
|
|
117
|
+
} catch {
|
|
118
|
+
return defaultValue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Handle resize message from UI
|
|
124
|
+
* Call this in your message handler when msg.type === "resize"
|
|
125
|
+
* @param msg - Message object with width and height
|
|
126
|
+
*/
|
|
127
|
+
export function handleResize(msg: { width: number; height: number }): void {
|
|
128
|
+
figma.ui.resize(msg.width, msg.height);
|
|
129
|
+
}
|
package/src/lib/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Message utilities
|
|
2
|
+
export {
|
|
3
|
+
sendToPlugin,
|
|
4
|
+
createMessageHandler,
|
|
5
|
+
} from "./messages.js";
|
|
6
|
+
|
|
7
|
+
// Color utilities
|
|
8
|
+
export {
|
|
9
|
+
rgbToHex,
|
|
10
|
+
hexToRgb,
|
|
11
|
+
getLuminance,
|
|
12
|
+
getContrastRatio,
|
|
13
|
+
meetsContrastLevel,
|
|
14
|
+
} from "./colors.js";
|
|
15
|
+
|
|
16
|
+
// Validation utilities
|
|
17
|
+
export {
|
|
18
|
+
validateUrl,
|
|
19
|
+
validateJsonString,
|
|
20
|
+
sanitizeInput,
|
|
21
|
+
sanitizeName,
|
|
22
|
+
validateEmail,
|
|
23
|
+
validateNumber,
|
|
24
|
+
isEmpty,
|
|
25
|
+
} from "./validation.js";
|
|
26
|
+
|
|
27
|
+
// Error handling utilities
|
|
28
|
+
export {
|
|
29
|
+
formatErrorMessage,
|
|
30
|
+
handleAsyncError,
|
|
31
|
+
createUserErrorMessage,
|
|
32
|
+
logError,
|
|
33
|
+
withErrorHandling,
|
|
34
|
+
safeAsync,
|
|
35
|
+
parseJsonSafe,
|
|
36
|
+
notifyError,
|
|
37
|
+
notifySuccess,
|
|
38
|
+
notifyWarning,
|
|
39
|
+
} from "./errorHandling.js";
|
|
40
|
+
|
|
41
|
+
// Resize utilities
|
|
42
|
+
export {
|
|
43
|
+
setDefaultWidth,
|
|
44
|
+
getContentHeight,
|
|
45
|
+
resizeToFit,
|
|
46
|
+
autoResize,
|
|
47
|
+
} from "./resize.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message utilities for Figma plugin UI communication
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Send a message to the plugin code
|
|
7
|
+
* @param {string} type - Message type identifier
|
|
8
|
+
* @param {object} data - Additional data to send
|
|
9
|
+
*/
|
|
10
|
+
export function sendToPlugin(type, data = {}) {
|
|
11
|
+
parent.postMessage({ pluginMessage: { type, ...data } }, "*");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a message handler with type-based routing
|
|
16
|
+
* @param {Record<string, (msg: any) => void>} handlers - Object mapping message types to handler functions
|
|
17
|
+
* @returns {(event: MessageEvent) => void} Event handler function
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* window.onmessage = createMessageHandler({
|
|
21
|
+
* populateOptions: (msg) => {
|
|
22
|
+
* collections = msg.options;
|
|
23
|
+
* },
|
|
24
|
+
* generationComplete: () => {
|
|
25
|
+
* isGenerating = false;
|
|
26
|
+
* }
|
|
27
|
+
* });
|
|
28
|
+
*/
|
|
29
|
+
export function createMessageHandler(handlers) {
|
|
30
|
+
return (event) => {
|
|
31
|
+
const msg = event.data && event.data.pluginMessage;
|
|
32
|
+
if (!msg) return;
|
|
33
|
+
const handler = handlers[msg.type];
|
|
34
|
+
if (handler) handler(msg);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-resize utilities for Figma plugin windows
|
|
3
|
+
*
|
|
4
|
+
* Usage in UI:
|
|
5
|
+
* import { resizeToFit, autoResize } from "figma-plugin-utils";
|
|
6
|
+
* resizeToFit(); // One-time resize
|
|
7
|
+
* autoResize(); // Watch for changes and auto-resize
|
|
8
|
+
*
|
|
9
|
+
* Usage in code.ts:
|
|
10
|
+
* import { handleResize } from "figma-plugin-utils/lib/figma-helpers";
|
|
11
|
+
* // In your message handler:
|
|
12
|
+
* if (msg.type === "resize") handleResize(msg);
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { sendToPlugin } from "./messages.js";
|
|
16
|
+
|
|
17
|
+
/** Default width for the plugin window */
|
|
18
|
+
let defaultWidth = 300;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Set the default width used for resize operations
|
|
22
|
+
* @param {number} width - The default width in pixels
|
|
23
|
+
*/
|
|
24
|
+
export function setDefaultWidth(width) {
|
|
25
|
+
defaultWidth = width;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the current content height of the plugin UI
|
|
30
|
+
* Uses scrollHeight which works when container doesn't have fixed height
|
|
31
|
+
* @param {HTMLElement} [container] - Container element to measure (should NOT have height: 100%)
|
|
32
|
+
* @returns {number} The content height in pixels
|
|
33
|
+
*/
|
|
34
|
+
export function getContentHeight(container) {
|
|
35
|
+
if (!container) {
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
return container.scrollHeight;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Request the plugin to resize to fit content
|
|
43
|
+
* @param {object} [options] - Resize options
|
|
44
|
+
* @param {number} [options.width] - Width in pixels (uses default if not specified)
|
|
45
|
+
* @param {number} [options.height] - Height in pixels (auto-calculated if not specified)
|
|
46
|
+
* @param {number} [options.minHeight=100] - Minimum height in pixels
|
|
47
|
+
* @param {number} [options.maxHeight=800] - Maximum height in pixels
|
|
48
|
+
* @param {number} [options.padding=0] - Extra padding to add to calculated height
|
|
49
|
+
* @param {HTMLElement} [options.container] - Container element to measure
|
|
50
|
+
*/
|
|
51
|
+
export function resizeToFit(options = {}) {
|
|
52
|
+
const {
|
|
53
|
+
width = defaultWidth,
|
|
54
|
+
height,
|
|
55
|
+
minHeight = 100,
|
|
56
|
+
maxHeight = 800,
|
|
57
|
+
padding = 0,
|
|
58
|
+
container = document.body,
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
let finalHeight = height;
|
|
62
|
+
|
|
63
|
+
if (finalHeight === undefined) {
|
|
64
|
+
finalHeight = getContentHeight(container) + padding;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Clamp to min/max
|
|
68
|
+
finalHeight = Math.max(minHeight, Math.min(maxHeight, finalHeight));
|
|
69
|
+
|
|
70
|
+
sendToPlugin("resize", { width, height: finalHeight });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Set up automatic resizing when content changes
|
|
75
|
+
* Uses ResizeObserver to watch for size changes
|
|
76
|
+
*
|
|
77
|
+
* IMPORTANT: The container element must NOT have height: 100% or fixed height.
|
|
78
|
+
* Use bind:this on a wrapper element that flows naturally with content.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} options - Auto-resize options
|
|
81
|
+
* @param {HTMLElement} options.container - Container element to observe (required, must not have fixed height)
|
|
82
|
+
* @param {number} [options.width] - Width in pixels (uses default if not specified)
|
|
83
|
+
* @param {number} [options.minHeight=100] - Minimum height in pixels
|
|
84
|
+
* @param {number} [options.maxHeight=800] - Maximum height in pixels
|
|
85
|
+
* @param {number} [options.padding=0] - Extra padding to add to calculated height
|
|
86
|
+
* @param {number} [options.debounce=50] - Debounce delay in milliseconds
|
|
87
|
+
* @param {number} [options.threshold=20] - Minimum height change to trigger resize (prevents position reset)
|
|
88
|
+
* @returns {function} Cleanup function to stop observing
|
|
89
|
+
*/
|
|
90
|
+
export function autoResize(options = {}) {
|
|
91
|
+
const {
|
|
92
|
+
container,
|
|
93
|
+
width = defaultWidth,
|
|
94
|
+
minHeight = 100,
|
|
95
|
+
maxHeight = 800,
|
|
96
|
+
padding = 0,
|
|
97
|
+
debounce = 50,
|
|
98
|
+
threshold = 20,
|
|
99
|
+
} = options;
|
|
100
|
+
|
|
101
|
+
if (!container) {
|
|
102
|
+
console.warn("autoResize: container is required");
|
|
103
|
+
return () => {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let timeoutId = null;
|
|
107
|
+
let lastHeight = 0;
|
|
108
|
+
|
|
109
|
+
const doResize = () => {
|
|
110
|
+
const newHeight = getContentHeight(container) + padding;
|
|
111
|
+
const clampedHeight = Math.max(minHeight, Math.min(maxHeight, newHeight));
|
|
112
|
+
|
|
113
|
+
// Only resize if height changed by more than threshold (prevents position reset on small changes)
|
|
114
|
+
const heightDiff = Math.abs(clampedHeight - lastHeight);
|
|
115
|
+
if (lastHeight === 0 || heightDiff >= threshold) {
|
|
116
|
+
lastHeight = clampedHeight;
|
|
117
|
+
sendToPlugin("resize", { width, height: clampedHeight });
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const debouncedResize = () => {
|
|
122
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
123
|
+
timeoutId = setTimeout(doResize, debounce);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Initial resize
|
|
127
|
+
doResize();
|
|
128
|
+
|
|
129
|
+
// Watch for changes
|
|
130
|
+
const observer = new ResizeObserver(debouncedResize);
|
|
131
|
+
observer.observe(container);
|
|
132
|
+
|
|
133
|
+
// Also watch for DOM mutations (new elements added/removed)
|
|
134
|
+
const mutationObserver = new MutationObserver(debouncedResize);
|
|
135
|
+
mutationObserver.observe(container, {
|
|
136
|
+
childList: true,
|
|
137
|
+
subtree: true,
|
|
138
|
+
attributes: true,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Return cleanup function
|
|
142
|
+
return () => {
|
|
143
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
144
|
+
observer.disconnect();
|
|
145
|
+
mutationObserver.disconnect();
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation and sanitization utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides input validation and sanitization for user-provided data
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate a URL string
|
|
9
|
+
* @param {string} url - The URL to validate
|
|
10
|
+
* @param {Object} [options] - Validation options
|
|
11
|
+
* @param {boolean} [options.required=true] - Whether URL is required
|
|
12
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
13
|
+
*/
|
|
14
|
+
export function validateUrl(url, options = { required: true }) {
|
|
15
|
+
if (!url || typeof url !== "string" || !url.trim()) {
|
|
16
|
+
if (options.required) {
|
|
17
|
+
return { valid: false, error: "URL is required" };
|
|
18
|
+
}
|
|
19
|
+
return { valid: true };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const trimmed = url.trim();
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const urlObj = new URL(trimmed);
|
|
26
|
+
|
|
27
|
+
// Only allow http and https protocols
|
|
28
|
+
if (!["http:", "https:"].includes(urlObj.protocol)) {
|
|
29
|
+
return {
|
|
30
|
+
valid: false,
|
|
31
|
+
error: "URL must use http or https protocol",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Validate hostname
|
|
36
|
+
if (!urlObj.hostname || urlObj.hostname.length === 0) {
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
error: "Invalid URL: missing hostname",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check for suspicious patterns
|
|
44
|
+
const suspiciousPatterns = [
|
|
45
|
+
/javascript:/i,
|
|
46
|
+
/data:/i,
|
|
47
|
+
/vbscript:/i,
|
|
48
|
+
/file:/i,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
for (const pattern of suspiciousPatterns) {
|
|
52
|
+
if (pattern.test(trimmed)) {
|
|
53
|
+
return {
|
|
54
|
+
valid: false,
|
|
55
|
+
error: "URL contains invalid protocol",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { valid: true };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return {
|
|
63
|
+
valid: false,
|
|
64
|
+
error: "Invalid URL format",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate a JSON string
|
|
71
|
+
* @param {string} jsonString - The JSON string to validate
|
|
72
|
+
* @param {Object} [options] - Validation options
|
|
73
|
+
* @param {number} [options.maxSizeKB] - Maximum size in KB
|
|
74
|
+
* @param {boolean} [options.requireObject] - Whether root must be an object
|
|
75
|
+
* @returns {{ valid: boolean, error?: string, parsed?: unknown }}
|
|
76
|
+
*/
|
|
77
|
+
export function validateJsonString(jsonString, options = {}) {
|
|
78
|
+
if (!jsonString || typeof jsonString !== "string") {
|
|
79
|
+
return {
|
|
80
|
+
valid: false,
|
|
81
|
+
error: "JSON string is required",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const trimmed = jsonString.trim();
|
|
86
|
+
if (!trimmed) {
|
|
87
|
+
return {
|
|
88
|
+
valid: false,
|
|
89
|
+
error: "JSON string is empty",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check size if specified
|
|
94
|
+
if (options.maxSizeKB) {
|
|
95
|
+
const sizeKB = Math.round(trimmed.length / 1024);
|
|
96
|
+
if (sizeKB > options.maxSizeKB) {
|
|
97
|
+
return {
|
|
98
|
+
valid: false,
|
|
99
|
+
error: `JSON is too large (${sizeKB} KB). Maximum size: ${options.maxSizeKB} KB`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Try to parse JSON
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(trimmed);
|
|
107
|
+
|
|
108
|
+
// If requireObject is true, ensure it's an object
|
|
109
|
+
if (
|
|
110
|
+
options.requireObject &&
|
|
111
|
+
(typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
112
|
+
) {
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
error: "JSON must be an object",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
valid: true,
|
|
121
|
+
parsed,
|
|
122
|
+
};
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const message = err instanceof Error ? err.message : "Invalid JSON format";
|
|
125
|
+
return {
|
|
126
|
+
valid: false,
|
|
127
|
+
error: message,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Sanitize user input string
|
|
134
|
+
* @param {unknown} input - The input to sanitize
|
|
135
|
+
* @param {number} [maxLength] - Maximum length
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
export function sanitizeInput(input, maxLength) {
|
|
139
|
+
if (input === null || input === undefined) {
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let str = String(input);
|
|
144
|
+
|
|
145
|
+
// Apply length limit if provided
|
|
146
|
+
if (maxLength && str.length > maxLength) {
|
|
147
|
+
str = str.slice(0, maxLength);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Remove null bytes and control characters (except newlines and tabs)
|
|
151
|
+
str = str.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, "");
|
|
152
|
+
|
|
153
|
+
// Trim whitespace
|
|
154
|
+
str = str.trim();
|
|
155
|
+
|
|
156
|
+
return str;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sanitize a name/title for storage
|
|
161
|
+
* @param {unknown} name - The name to sanitize
|
|
162
|
+
* @param {number} [maxLength=200] - Maximum length
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function sanitizeName(name, maxLength = 200) {
|
|
166
|
+
const sanitized = sanitizeInput(name, maxLength);
|
|
167
|
+
|
|
168
|
+
// Remove problematic characters but keep basic punctuation
|
|
169
|
+
// Allow: letters, numbers, spaces, hyphens, underscores, dots
|
|
170
|
+
return sanitized.replace(/[^a-zA-Z0-9\s\-_.]/g, "") || "Untitled";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validate an email address
|
|
175
|
+
* @param {string} email - The email to validate
|
|
176
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
177
|
+
*/
|
|
178
|
+
export function validateEmail(email) {
|
|
179
|
+
if (!email || typeof email !== "string" || !email.trim()) {
|
|
180
|
+
return { valid: false, error: "Email is required" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Basic email regex
|
|
184
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
185
|
+
if (!emailRegex.test(email.trim())) {
|
|
186
|
+
return { valid: false, error: "Invalid email format" };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { valid: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validate a number within range
|
|
194
|
+
* @param {unknown} value - The value to validate
|
|
195
|
+
* @param {Object} [options] - Validation options
|
|
196
|
+
* @param {number} [options.min] - Minimum value
|
|
197
|
+
* @param {number} [options.max] - Maximum value
|
|
198
|
+
* @param {boolean} [options.integer] - Must be an integer
|
|
199
|
+
* @returns {{ valid: boolean, error?: string, value?: number }}
|
|
200
|
+
*/
|
|
201
|
+
export function validateNumber(value, options = {}) {
|
|
202
|
+
const num = Number(value);
|
|
203
|
+
|
|
204
|
+
if (isNaN(num)) {
|
|
205
|
+
return { valid: false, error: "Must be a number" };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (options.integer && !Number.isInteger(num)) {
|
|
209
|
+
return { valid: false, error: "Must be an integer" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (options.min !== undefined && num < options.min) {
|
|
213
|
+
return { valid: false, error: `Must be at least ${options.min}` };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (options.max !== undefined && num > options.max) {
|
|
217
|
+
return { valid: false, error: `Must be at most ${options.max}` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { valid: true, value: num };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check if a value is empty (null, undefined, empty string, empty array)
|
|
225
|
+
* @param {unknown} value - The value to check
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
export function isEmpty(value) {
|
|
229
|
+
if (value === null || value === undefined) return true;
|
|
230
|
+
if (typeof value === "string" && value.trim() === "") return true;
|
|
231
|
+
if (Array.isArray(value) && value.length === 0) return true;
|
|
232
|
+
if (typeof value === "object" && Object.keys(value).length === 0) return true;
|
|
233
|
+
return false;
|
|
234
|
+
}
|