@nuasite/components 0.0.36 → 0.0.38

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@nuasite/components",
3
3
  "description": "Nua Site astro components.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.0.36",
5
+ "version": "0.0.38",
6
6
  "files": [
7
7
  "dist/**",
8
8
  "src/**",
@@ -23,7 +23,8 @@
23
23
  "types": "src/index.ts",
24
24
  "exports": {
25
25
  ".": "./src/index.ts",
26
- "./Form.astro": "./src/form/index.astro"
26
+ "./Form.astro": "./src/form/index.astro",
27
+ "./Image.astro": "./src/image/index.astro"
27
28
  },
28
29
  "peerDependencies": {
29
30
  "typescript": "^5"
package/src/astro.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /// <reference types="astro/client" />
2
2
 
3
- declare module "*.astro" {
4
- type Props = any;
5
- const Component: (props: Props) => any;
3
+ declare module '*.astro' {
4
+ type Props = any
5
+ const Component: (props: Props) => any
6
6
 
7
- export default Component;
8
- export type { Props };
7
+ export default Component
8
+ export type { Props }
9
9
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
- import type { FormProps } from './types';
3
- import { submitButtonRegex } from './utils';
2
+ import type { FormProps } from './types'
3
+ import { submitButtonRegex, honeypotConflictRegex, honeypotFields } from './utils'
4
4
 
5
5
  export type Props = FormProps
6
6
 
@@ -15,19 +15,23 @@ const {
15
15
  tryAgainMessage = "Try again",
16
16
  fastSubmitMessage = "Please wait a moment before submitting.",
17
17
  ...rest
18
- } = Astro.props;
18
+ } = Astro.props
19
19
 
20
- const slotContent = await Astro.slots.render("default");
20
+ const slotContent = await Astro.slots.render("default")
21
21
 
22
22
  if (!submitButtonRegex.test(slotContent)) {
23
- console.error(`❌ No submit button found in form "${formId}".`);
24
- throw new Error(`Form "${formId}" is missing a submit button`);
23
+ console.error(`❌ No submit button found in form "${formId}".`)
24
+ throw new Error(`Form "${formId}" is missing a submit button`)
25
25
  }
26
26
 
27
- const honeypotFieldName = `contact_required`;
28
- const honeypotId = `${honeypotFieldName}_${formId}`;
29
- const tokenFieldName = `token_${crypto.randomUUID().substring(0, 8)}`;
30
- const tokenId = `${tokenFieldName}_${formId}`;
27
+ if (honeypotConflictRegex.test(slotContent)) {
28
+ console.error(`❌ Form "${formId}" contains field names ending with "_required" which conflict with honeypot fields.`)
29
+ throw new Error(`Form "${formId}" has conflicting field names. Avoid using field names ending with "_required" as they are reserved for honeypot fields.`)
30
+ }
31
+
32
+ const cssHoneypotField = `css_trap_${crypto.randomUUID().substring(0, 8)}`
33
+ const tokenFieldName = `token_${crypto.randomUUID().substring(0, 8)}`
34
+ const tokenId = `${tokenFieldName}_${formId}`
31
35
  ---
32
36
 
33
37
  <astro-form>
@@ -35,7 +39,6 @@ const tokenId = `${tokenFieldName}_${formId}`;
35
39
  action={action}
36
40
  class="astro-from"
37
41
  data-form-id={formId}
38
- data-honeypot-field={honeypotFieldName}
39
42
  data-token-field={tokenFieldName}
40
43
  data-success-message={successMessage}
41
44
  data-error-message={errorMessage}
@@ -43,40 +46,52 @@ const tokenId = `${tokenFieldName}_${formId}`;
43
46
  data-network-error-message={networkErrorMessage}
44
47
  data-try-again-message={tryAgainMessage}
45
48
  data-fast-submit-message={fastSubmitMessage}
49
+ data-required-fields={honeypotFields.map(field => field.name).join(',')}
46
50
  method={method}
47
51
  {...rest}
48
52
  >
49
53
  <div class="relative index-1">
50
54
  <div class="sr-only">
51
- <label for={honeypotId}>Leave this field empty</label>
52
- <input
53
- type="text"
54
- id={honeypotId}
55
- name={honeypotFieldName}
56
- tabindex="-1"
57
- autocomplete="off"
58
- aria-hidden="true"
59
- />
55
+ {honeypotFields.map(field => (
56
+ <label for={`${field.name}_${formId}`}>Leave this {field.name} empty</label>
57
+ <input
58
+ type={field.type}
59
+ id={`${field.name}_${formId}`}
60
+ name={field.name}
61
+ tabindex="-1"
62
+ autocomplete="off"
63
+ aria-hidden="true"
64
+ />
65
+ ))}
60
66
  </div>
67
+
68
+ <input
69
+ type="text"
70
+ name={cssHoneypotField}
71
+ style="position: absolute; left: -9999px; opacity: 0;"
72
+ tabindex="-1"
73
+ autocomplete="nope"
74
+ />
75
+
61
76
  <slot />
62
77
  </div>
63
78
 
64
79
  <input type="hidden" id={tokenId} name={tokenFieldName} value="" />
65
80
 
66
81
  <div id={`${formId}-status`} class="mt-4 hidden" role="alert" aria-live="polite">
67
- <div class="status-success hidden text-green-600 bg-green-50 border border-green-200 rounded-md p-3 mb-4">
82
+ <div data-status="success" class="hidden text-green-600 bg-green-50 border border-green-200 rounded-md p-3 mb-4">
68
83
  <svg class="w-5 h-5 inline mr-2" fill="currentColor" viewBox="0 0 20 20">
69
84
  <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
70
85
  </svg>
71
86
  <span>{successMessage}</span>
72
87
  </div>
73
- <div class="status-error hidden text-red-600 bg-red-50 border border-red-200 rounded-md p-3 mb-4">
88
+ <div data-status="error" class="hidden text-red-600 bg-red-50 border border-red-200 rounded-md p-3 mb-4">
74
89
  <svg class="w-5 h-5 inline mr-2" fill="currentColor" viewBox="0 0 20 20">
75
90
  <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
76
91
  </svg>
77
92
  <span>{errorMessage}</span>
78
93
  </div>
79
- <div class="status-loading hidden text-blue-600 bg-blue-50 border border-blue-200 rounded-md p-3 mb-4">
94
+ <div data-status="loading" class="hidden text-blue-600 bg-blue-50 border border-blue-200 rounded-md p-3 mb-4">
80
95
  <svg class="w-5 h-5 inline mr-2 animate-spin" fill="none" viewBox="0 0 24 24">
81
96
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
82
97
  <path class="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@@ -89,6 +104,14 @@ const tokenId = `${tokenFieldName}_${formId}`;
89
104
 
90
105
  <script>
91
106
  class AstroForm extends HTMLElement {
107
+ private interactionScore = 0;
108
+ private fieldInteractions = new Set<string>();
109
+ private mouseMovements = 0;
110
+ private keyboardEvents = 0;
111
+ private focusEvents = 0;
112
+ private startTime = Date.now();
113
+ private honeypotFieldNames: string[] = []
114
+
92
115
  connectedCallback() {
93
116
  const form = this.querySelector('form')
94
117
 
@@ -97,9 +120,10 @@ const tokenId = `${tokenFieldName}_${formId}`;
97
120
  return
98
121
  }
99
122
 
123
+ this.setupInteractionTracking(form)
124
+
100
125
  const minSubmitDelay = 1200
101
126
  const formId = form?.dataset.formId
102
- const honeypotFieldName = form.dataset.honeypotField
103
127
  const tokenFieldName = form.dataset.tokenField
104
128
  const successMessage = form.dataset.successMessage
105
129
  const errorMessage = form.dataset.errorMessage
@@ -107,11 +131,12 @@ const tokenId = `${tokenFieldName}_${formId}`;
107
131
  const networkErrorMessage = form.dataset.networkErrorMessage
108
132
  const tryAgainMessage = form.dataset.tryAgainMessage
109
133
  const fastSubmitMessage = form.dataset.fastSubmitMessage
134
+ this.honeypotFieldNames = form.dataset.requiredFields?.split(',') || []
110
135
 
111
136
  const statusContainer = document.getElementById(`${formId}-status`)
112
- const successStatus = statusContainer?.querySelector('.status-success')
113
- const errorStatus = statusContainer?.querySelector('.status-error')
114
- const loadingStatus = statusContainer?.querySelector('.status-loading')
137
+ const successStatus = statusContainer?.querySelector('[data-status="success"]')
138
+ const errorStatus = statusContainer?.querySelector('[data-status="error"]')
139
+ const loadingStatus = statusContainer?.querySelector('[data-status="loading"]')
115
140
 
116
141
  const hideAllStatus = () => {
117
142
  statusContainer?.classList.add('hidden')
@@ -187,8 +212,22 @@ const tokenId = `${tokenFieldName}_${formId}`;
187
212
  form.addEventListener('submit', async (e) => {
188
213
  e.preventDefault()
189
214
 
190
- const honey = honeypotFieldName && form.querySelector(`[name="${honeypotFieldName}"]`) as HTMLInputElement
191
- if (honey && honey.value) {
215
+ if (!this.passesHumanityChecks(form)) {
216
+ showError(tryAgainMessage)
217
+ return
218
+ }
219
+
220
+ for (const fieldName of this.honeypotFieldNames) {
221
+ const field = form.querySelector(`[name="${fieldName}"]`) as HTMLInputElement;
222
+ if (field && field.value.trim()) {
223
+ showError(tryAgainMessage)
224
+ return
225
+ }
226
+ }
227
+
228
+ // Check CSS honeypot
229
+ const cssHoneypot = form.querySelector('input[style*="position: absolute; left: -9999px"]') as HTMLInputElement;
230
+ if (cssHoneypot && cssHoneypot.value.trim()) {
192
231
  showError(tryAgainMessage)
193
232
  return
194
233
  }
@@ -231,6 +270,83 @@ const tokenId = `${tokenFieldName}_${formId}`;
231
270
  }
232
271
  })
233
272
  }
273
+
274
+ private setupInteractionTracking(form: HTMLFormElement) {
275
+ let mouseMoveTimeout: NodeJS.Timeout;
276
+ document.addEventListener('mousemove', () => {
277
+ clearTimeout(mouseMoveTimeout);
278
+ mouseMoveTimeout = setTimeout(() => {
279
+ this.mouseMovements++;
280
+ this.updateInteractionScore();
281
+ }, 100);
282
+ });
283
+
284
+ form.addEventListener('keydown', (e) => {
285
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
286
+ this.keyboardEvents++;
287
+ this.updateInteractionScore();
288
+ }
289
+ });
290
+
291
+ const inputs = form.querySelectorAll('input[type="text"], input[type="email"], textarea');
292
+ inputs.forEach(input => {
293
+ input.addEventListener('focus', () => {
294
+ this.focusEvents++;
295
+ this.fieldInteractions.add((input as HTMLInputElement).name);
296
+ this.updateInteractionScore();
297
+ });
298
+ });
299
+ }
300
+
301
+ private updateInteractionScore() {
302
+ this.interactionScore =
303
+ (this.mouseMovements > 0 ? 1 : 0) +
304
+ (this.keyboardEvents > 5 ? 1 : 0) +
305
+ (this.focusEvents > 1 ? 1 : 0) +
306
+ (this.fieldInteractions.size > 1 ? 1 : 0);
307
+ }
308
+
309
+ private detectSuspiciousBrowser(): boolean {
310
+ // Check for headless browser indicators
311
+ const suspiciousIndicators = [
312
+ // Missing expected browser features
313
+ !window.navigator.webdriver === undefined,
314
+ !window.navigator.plugins.length,
315
+ !window.navigator.languages.length,
316
+
317
+ // Headless Chrome indicators
318
+ window.navigator.userAgent.includes('HeadlessChrome'),
319
+ window.outerWidth === 0,
320
+ window.outerHeight === 0,
321
+
322
+ // Puppeteer indicators
323
+ (window as any).__nightmare,
324
+ (window as any)._phantom,
325
+ (window as any).callPhantom,
326
+ ];
327
+
328
+ return suspiciousIndicators.some(indicator => indicator);
329
+ }
330
+
331
+ private passesHumanityChecks(form: HTMLFormElement): boolean {
332
+ const timeSpent = Date.now() - this.startTime;
333
+
334
+ if (timeSpent < 3000) return false;
335
+
336
+ if (this.interactionScore < 2) return false;
337
+
338
+ for (const fieldName of this.honeypotFieldNames) {
339
+ const field = form.querySelector(`[name="${fieldName}"]`) as HTMLInputElement
340
+ if (field && field.value.trim()) return false
341
+ }
342
+
343
+ const cssHoneypot = form.querySelector('input[style*="position: absolute; left: -9999px"]') as HTMLInputElement
344
+ if (cssHoneypot && cssHoneypot.value.trim()) return false
345
+
346
+ if (this.detectSuspiciousBrowser()) return false
347
+
348
+ return true
349
+ }
234
350
  }
235
351
 
236
352
  customElements.define('astro-form', AstroForm)
package/src/form/types.ts CHANGED
@@ -1,25 +1,27 @@
1
1
  /**
2
- * Base properties for form components containing optional message strings
3
- */
4
- export interface BaseProps extends astroHTML.JSX.FormHTMLAttributes {
2
+ * Base properties for form components containing optional message strings
3
+ */
4
+ interface BaseProps extends astroHTML.JSX.FormHTMLAttributes {
5
5
  /** Message to display when form submission is successful */
6
- successMessage?: string;
6
+ successMessage?: string
7
7
  /** Message to display when form submission encounters an error */
8
- errorMessage?: string;
8
+ errorMessage?: string
9
9
  /** Message to display while form is being submitted */
10
- submittingMessage?: string;
10
+ submittingMessage?: string
11
11
  /** Message to display when a network error occurs */
12
- networkErrorMessage?: string;
12
+ networkErrorMessage?: string
13
13
  /** Message to display for retry prompt */
14
- tryAgainMessage?: string;
14
+ tryAgainMessage?: string
15
15
  /** Message to display when form submission is fast */
16
- fastSubmitMessage?: string;
16
+ fastSubmitMessage?: string
17
17
  }
18
18
 
19
19
  /**
20
- * Form properties that extend BaseProps and require either a formId or action (or both)
21
- */
22
- export type FormProps = BaseProps & (
23
- | { formId: string; action?: string }
24
- | { formId?: string; action: string }
25
- );
20
+ * Form properties that extend BaseProps and require either a formId or action (or both)
21
+ */
22
+ export type FormProps =
23
+ & BaseProps
24
+ & (
25
+ | { formId: string; action?: string }
26
+ | { formId?: string; action: string }
27
+ )
package/src/form/utils.ts CHANGED
@@ -1 +1,9 @@
1
- export const submitButtonRegex = /<button(?![^>]*type\s*=\s*["']button["'])[^>]*>|<input[^>]*type\s*=\s*["']submit["'][^>]*>/i;
1
+ export const submitButtonRegex = /<button(?![^>]*type\s*=\s*["']button["'])[^>]*>|<input[^>]*type\s*=\s*["']submit["'][^>]*>/i
2
+ export const honeypotConflictRegex = /<input[^>]*name\s*=\s*["'][^"']*_required["'][^>]*>/i
3
+
4
+ export const honeypotFields = [
5
+ { name: `contact_required`, type: 'text' },
6
+ { name: `website_url_required`, type: 'url' },
7
+ { name: `phone_number_required`, type: 'tel' },
8
+ { name: `company_name_required`, type: 'text' },
9
+ ] as const
@@ -0,0 +1,62 @@
1
+ ---
2
+ import type { CloudflareImageTransformOptions, ImageProps } from './types'
3
+ import { serializeOptions } from './utils'
4
+
5
+ export type Props = ImageProps
6
+
7
+ const {
8
+ src,
9
+ alt,
10
+ widths = [480, 768, 1024, 1400],
11
+ sizes = '(min-width: 1024px) 50vw, 100vw',
12
+ quality = 85,
13
+ format = 'auto',
14
+ transformOptions = {},
15
+ deliveryBase = '/cdn-cgi/image',
16
+ loading = 'lazy',
17
+ decoding = 'async',
18
+ ...rest
19
+ } = Astro.props
20
+
21
+ const isAbsoluteSrc = typeof src === 'string' && (src.startsWith('http://') || src.startsWith('https://'))
22
+ const parsedSrc = isAbsoluteSrc ? new URL(src) : null
23
+ const baseFromSrc = parsedSrc ? parsedSrc.origin : ''
24
+ const normalizeBaseRaw = deliveryBase.endsWith('/') ? deliveryBase.slice(0, -1) : deliveryBase
25
+ const normalizeBase =
26
+ isAbsoluteSrc && normalizeBaseRaw.startsWith('/')
27
+ ? `${baseFromSrc}${normalizeBaseRaw}`
28
+ : normalizeBaseRaw
29
+ const normalizeSource = parsedSrc
30
+ ? `${parsedSrc.pathname}${parsedSrc.search}`
31
+ : src?.replace(/^\/+/, '')
32
+
33
+ const baseOptions: CloudflareImageTransformOptions = {
34
+ quality,
35
+ format,
36
+ ...transformOptions,
37
+ }
38
+
39
+ const buildUrl = (width: number) => {
40
+ const optionsWithWidth: CloudflareImageTransformOptions = {
41
+ ...baseOptions,
42
+ width,
43
+ }
44
+
45
+ const optionsString = serializeOptions(optionsWithWidth)
46
+ return `${normalizeBase}/${optionsString}${normalizeSource}`
47
+ }
48
+
49
+ const effectiveWidths = widths.length ? widths : [1400]
50
+ const srcset = effectiveWidths.map((width) => `${buildUrl(width)} ${width}w`).join(', ')
51
+ const fallbackSrc = buildUrl(effectiveWidths[effectiveWidths.length - 1]!)
52
+ ---
53
+
54
+ <img
55
+ src={fallbackSrc}
56
+ srcset={srcset}
57
+ sizes={sizes}
58
+ alt={alt}
59
+ loading={loading}
60
+ decoding={decoding}
61
+ {...rest}
62
+ />
@@ -0,0 +1,103 @@
1
+ /** Passthrough `<img>` props accepted by the Astro component. */
2
+ interface BaseProps extends astroHTML.JSX.ImgHTMLAttributes {}
3
+
4
+ type CloudflareImageFormat = 'auto' | 'webp' | 'avif' | 'jpeg' | 'baseline-jpeg' | 'json'
5
+ type CloudflareImageQuality =
6
+ | number
7
+ | 'high'
8
+ | 'medium-high'
9
+ | 'medium-low'
10
+ | 'low'
11
+ type CloudflareImageFit = 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad' | 'squeeze'
12
+ type CloudflareImageGravity =
13
+ | 'auto'
14
+ | 'face'
15
+ | 'left'
16
+ | 'right'
17
+ | 'top'
18
+ | 'bottom'
19
+ | `${number}x${number}`
20
+
21
+ /** Options mapped to Cloudflare Image Transform `/cdn-cgi/image/<options>/<src>` parameters. */
22
+ export type CloudflareImageTransformOptions = {
23
+ /** Preserve animation frames (set false to flatten GIFs). */
24
+ anim?: boolean
25
+ /** Background color used for transparent images or fit=pad. */
26
+ background?: string
27
+ /** Blur radius 1-250. */
28
+ blur?: number
29
+ /** Border CSS color and widths (comma-joined string). */
30
+ border?: string
31
+ /** Brightness multiplier, 0.5-2.0. */
32
+ brightness?: number
33
+ /** Compression strategy override. */
34
+ compression?: 'fast'
35
+ /** Contrast multiplier, 0.5-2.0. */
36
+ contrast?: number
37
+ /** Device pixel ratio multiplier. */
38
+ dpr?: number
39
+ /** Resize fit mode. */
40
+ fit?: CloudflareImageFit
41
+ /** Flip image horizontally/vertically. */
42
+ flip?: 'h' | 'v' | 'hv'
43
+ /** Output format; defaults to auto negotiation. */
44
+ format?: CloudflareImageFormat
45
+ /** Exposure adjustment. */
46
+ gamma?: number
47
+ /** Crop focal point (auto, face, sides, or coordinates). */
48
+ gravity?: CloudflareImageGravity
49
+ /** Target height. */
50
+ height?: number
51
+ /** Metadata preservation strategy. */
52
+ metadata?: 'copyright' | 'keep' | 'none'
53
+ /** Redirect to origin on fatal resize error. */
54
+ onError?: 'redirect'
55
+ /** Quality scalar or perceptual presets. */
56
+ quality?: CloudflareImageQuality
57
+ /** Rotate degrees. */
58
+ rotate?: 90 | 180 | 270
59
+ /** Saturation multiplier (0 = grayscale). */
60
+ saturation?: number
61
+ /** Background segmentation toggle. */
62
+ segment?: 'foreground'
63
+ /** Sharpen strength 0-10. */
64
+ sharpen?: number
65
+ /** Override quality for slow connections. */
66
+ slowConnectionQuality?: CloudflareImageQuality
67
+ /** Trim pixels or border detection. */
68
+ trim?: 'border' | string | number
69
+ /** Trim border color (CSS syntax). */
70
+ trimBorderColor?: string
71
+ /** Trim border tolerance (0-255). */
72
+ trimBorderTolerance?: number
73
+ /** Pixels of original border to keep. */
74
+ trimBorderKeep?: number
75
+ /** Trim rectangle width. */
76
+ trimWidth?: number
77
+ /** Trim rectangle height. */
78
+ trimHeight?: number
79
+ /** Trim offset from left. */
80
+ trimLeft?: number
81
+ /** Trim offset from top. */
82
+ trimTop?: number
83
+ /** Target width or auto width negotiation. */
84
+ width?: number | 'auto'
85
+ /** Face zoom when gravity=face. */
86
+ zoom?: number
87
+ }
88
+
89
+ /** Props for the responsive Cloudflare image component. */
90
+ export type ImageProps = BaseProps & {
91
+ /** Width breakpoints used to build `srcset` (defaults applied). */
92
+ widths?: number[]
93
+ /** `sizes` attribute string. */
94
+ sizes?: string
95
+ /** Default quality applied to all generated URLs. */
96
+ quality?: CloudflareImageQuality
97
+ /** Default format applied to all generated URLs. */
98
+ format?: CloudflareImageFormat
99
+ /** Additional Cloudflare transform params applied to every variant. */
100
+ transformOptions?: CloudflareImageTransformOptions
101
+ /** Prefix for transform delivery path, defaults to `/cdn-cgi/image`. */
102
+ deliveryBase?: string
103
+ }
@@ -0,0 +1,54 @@
1
+ import type { CloudflareImageTransformOptions } from './types'
2
+
3
+ export const optionKeyMap: Record<keyof CloudflareImageTransformOptions, string> = {
4
+ anim: 'anim',
5
+ background: 'background',
6
+ blur: 'blur',
7
+ border: 'border',
8
+ brightness: 'brightness',
9
+ compression: 'compression',
10
+ contrast: 'contrast',
11
+ dpr: 'dpr',
12
+ fit: 'fit',
13
+ flip: 'flip',
14
+ format: 'format',
15
+ gamma: 'gamma',
16
+ gravity: 'gravity',
17
+ height: 'height',
18
+ metadata: 'metadata',
19
+ onError: 'onerror',
20
+ quality: 'quality',
21
+ rotate: 'rotate',
22
+ saturation: 'saturation',
23
+ segment: 'segment',
24
+ sharpen: 'sharpen',
25
+ slowConnectionQuality: 'slow-connection-quality',
26
+ trim: 'trim',
27
+ trimBorderColor: 'trim.border.color',
28
+ trimBorderTolerance: 'trim.border.tolerance',
29
+ trimBorderKeep: 'trim.border.keep',
30
+ trimWidth: 'trim.width',
31
+ trimHeight: 'trim.height',
32
+ trimLeft: 'trim.left',
33
+ trimTop: 'trim.top',
34
+ width: 'width',
35
+ zoom: 'zoom',
36
+ }
37
+
38
+ export const serializeOptions = (options: CloudflareImageTransformOptions) =>
39
+ Object.entries(options)
40
+ .filter(([, value]) => value !== undefined && value !== null)
41
+ .map(([rawKey, value]) => {
42
+ const key = optionKeyMap[rawKey as keyof CloudflareImageTransformOptions] ?? rawKey
43
+ const stringValue = typeof value === 'string'
44
+ ? value
45
+ : typeof value === 'boolean'
46
+ ? String(value)
47
+ : Number.isFinite(value)
48
+ ? String(value)
49
+ : ''
50
+
51
+ return stringValue ? `${key}=${encodeURIComponent(stringValue)}` : ''
52
+ })
53
+ .filter(Boolean)
54
+ .join(',')
package/src/index.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export { default as Form } from './form/index.astro'
2
- export type { BaseProps, FormProps } from './form/types'
2
+ export type { FormProps } from './form/types'
3
+ export { default as Image } from './image/index.astro'
4
+ export type { CloudflareImageTransformOptions, ImageProps } from './image/types'