@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/README.md +2 -2
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/src/astro.d.ts +5 -5
- package/src/form/index.astro +145 -29
- package/src/form/types.ts +17 -15
- package/src/form/utils.ts +9 -1
- package/src/image/index.astro +62 -0
- package/src/image/types.ts +103 -0
- package/src/image/utils.ts +54 -0
- package/src/index.ts +3 -1
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.
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
declare module '*.astro' {
|
|
4
|
+
type Props = any
|
|
5
|
+
const Component: (props: Props) => any
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
export default Component
|
|
8
|
+
export type { Props }
|
|
9
9
|
}
|
package/src/form/index.astro
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
113
|
-
const errorStatus = statusContainer?.querySelector('
|
|
114
|
-
const loadingStatus = statusContainer?.querySelector('
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
export type FormProps =
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
2
|
+
export type { FormProps } from './form/types'
|
|
3
|
+
export { default as Image } from './image/index.astro'
|
|
4
|
+
export type { CloudflareImageTransformOptions, ImageProps } from './image/types'
|