@shipstatic/drop 1.0.3 → 2.0.0-beta.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 +166 -124
- package/dist/index.cjs +534 -9828
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -66
- package/dist/index.d.ts +27 -66
- package/dist/index.js +534 -9817
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +65 -96
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +47 -67
- package/dist/testing.d.ts +47 -67
- package/dist/testing.js +65 -92
- package/dist/testing.js.map +1 -1
- package/dist/useDrop-Bmjfjp_o.d.cts +140 -0
- package/dist/useDrop-Bmjfjp_o.d.ts +140 -0
- package/package.json +45 -26
- package/dist/useDrop-BAYclQ_x.d.cts +0 -147
- package/dist/useDrop-BAYclQ_x.d.ts +0 -147
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @shipstatic/drop
|
|
2
2
|
|
|
3
|
-
Headless file processing
|
|
3
|
+
Headless file processing for Ship SDK deployments.
|
|
4
4
|
|
|
5
|
-
A
|
|
5
|
+
A React hook that prepares files for deployment with [@shipstatic/ship](https://www.npmjs.com/package/@shipstatic/ship): drag & drop with folder support, ZIP extraction, path normalization, and validation against your account's real platform limits. No UI, full styling control.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -10,20 +10,21 @@ A focused React hook for preparing files for deployment with [@shipstatic/ship](
|
|
|
10
10
|
npm install @shipstatic/drop @shipstatic/ship
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
React 18 or 19 is a peer dependency.
|
|
14
|
+
|
|
13
15
|
## Quick Start
|
|
14
16
|
|
|
15
17
|
```tsx
|
|
16
18
|
import { useDrop } from '@shipstatic/drop';
|
|
17
19
|
import Ship from '@shipstatic/ship';
|
|
18
20
|
|
|
19
|
-
const ship = new Ship({
|
|
21
|
+
const ship = new Ship({ token: 'deploy-...' });
|
|
20
22
|
|
|
21
23
|
function Uploader() {
|
|
22
24
|
const drop = useDrop({ ship });
|
|
23
25
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
await ship.deployments.upload(files);
|
|
26
|
+
const upload = async () => {
|
|
27
|
+
await ship.deployments.upload(drop.getFilesForUpload());
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
return (
|
|
@@ -33,199 +34,240 @@ function Uploader() {
|
|
|
33
34
|
style={{
|
|
34
35
|
border: '2px dashed',
|
|
35
36
|
borderColor: drop.isDragging ? 'blue' : 'gray',
|
|
36
|
-
padding:
|
|
37
|
+
padding: 40,
|
|
37
38
|
textAlign: 'center',
|
|
38
39
|
}}
|
|
39
40
|
>
|
|
40
41
|
<input {...drop.getInputProps()} />
|
|
41
|
-
{drop.isDragging ? 'Drop here' : 'Click or drag
|
|
42
|
+
{drop.isDragging ? 'Drop here' : 'Click or drag a folder'}
|
|
42
43
|
</div>
|
|
43
44
|
|
|
44
|
-
{drop.status &&
|
|
45
|
+
{drop.status && (
|
|
46
|
+
<p>
|
|
47
|
+
{drop.status.title}: {drop.status.details}
|
|
48
|
+
</p>
|
|
49
|
+
)}
|
|
45
50
|
|
|
46
|
-
<button onClick={
|
|
47
|
-
|
|
51
|
+
<button onClick={upload} disabled={drop.validFiles.length === 0}>
|
|
52
|
+
Deploy {drop.validFiles.length} files
|
|
48
53
|
</button>
|
|
49
54
|
</div>
|
|
50
55
|
);
|
|
51
56
|
}
|
|
52
57
|
```
|
|
53
58
|
|
|
54
|
-
##
|
|
55
|
-
|
|
56
|
-
- **Prop Getters API** - Spread props on your elements (like `react-dropzone`)
|
|
57
|
-
- **Built-in Drag & Drop** - Folder support with `webkitGetAsEntry` API
|
|
58
|
-
- **ZIP Support** - Automatic extraction and processing
|
|
59
|
-
- **Ship SDK Integration** - Validation via `ship.getLimits()`
|
|
60
|
-
- **Headless** - No visual components, full styling control
|
|
61
|
-
- **TypeScript** - Complete type definitions
|
|
59
|
+
## Why it exists
|
|
62
60
|
|
|
63
|
-
|
|
61
|
+
Ship's SDK deploys files. It doesn't do the browser-side work of *getting* them:
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
- **Folder drag & drop** via `webkitGetAsEntry`, traversed to exhaustion (`readEntries` returns at most 100 entries per call, so a naive reader truncates large folders)
|
|
64
|
+
- **ZIP extraction**, off the main thread
|
|
65
|
+
- **Path normalization** — the common directory prefix is stripped so `my-site/index.html` deploys as `index.html`
|
|
66
|
+
- **Validation** against your live limits from `ship.getLimits()`, using Ship's own validator so client and server can never disagree
|
|
67
|
+
- **React state** for the whole lifecycle
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
## `useDrop(options)`
|
|
70
70
|
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
{drop.hasError && <Error message={drop.status?.details} onRetry={drop.reset} />}
|
|
74
|
-
{drop.isInteractive && <DropZone />}
|
|
71
|
+
```ts
|
|
72
|
+
const drop = useDrop({ ship });
|
|
75
73
|
```
|
|
76
74
|
|
|
77
|
-
|
|
75
|
+
| Option | Type | Purpose |
|
|
76
|
+
|--------|------|---------|
|
|
77
|
+
| `ship` | `Pick<Ship, 'getLimits'>` | Your Ship client — used for platform limits. A real `Ship` satisfies it. |
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
switch (drop.phase) {
|
|
81
|
-
case 'idle': return 'Drop files here';
|
|
82
|
-
case 'dragging': return 'Drop now!';
|
|
83
|
-
case 'processing': return 'Processing...';
|
|
84
|
-
case 'ready': return `${drop.validFiles.length} files ready`;
|
|
85
|
-
case 'error': return drop.status?.details;
|
|
86
|
-
}
|
|
87
|
-
```
|
|
79
|
+
### What it returns
|
|
88
80
|
|
|
89
|
-
|
|
81
|
+
```ts
|
|
82
|
+
interface DropReturn {
|
|
83
|
+
// State
|
|
84
|
+
phase: 'idle' | 'processing' | 'ready' | 'error';
|
|
85
|
+
isProcessing: boolean; // phase === 'processing'
|
|
86
|
+
isDragging: boolean; // pointer is over the dropzone
|
|
87
|
+
isInteractive: boolean; // idle or ready
|
|
88
|
+
hasError: boolean; // phase === 'error'
|
|
89
|
+
files: ProcessedFile[];
|
|
90
|
+
validFiles: ProcessedFile[]; // only those that passed validation
|
|
91
|
+
sourceName: string; // ZIP name, folder name, or filename
|
|
92
|
+
status: DropStatus | null;
|
|
93
|
+
needsBuild: boolean;
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
// Prop getters
|
|
96
|
+
getDropzoneProps: (options?: { clickable?: boolean }) => { ... };
|
|
97
|
+
getInputProps: () => { ... };
|
|
98
|
+
|
|
99
|
+
// Actions
|
|
100
|
+
open: () => void; // trigger the file picker
|
|
101
|
+
processFiles: (files: File[]) => Promise<void>; // advanced — see below
|
|
102
|
+
reset: () => void;
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
interface DropOptions {
|
|
95
|
-
ship: Ship; // Ship SDK instance (required)
|
|
96
|
-
onFilesReady?: (files: ProcessedFile[]) => void;
|
|
97
|
-
onValidationError?: (error: ClientError) => void;
|
|
98
|
-
stripPrefix?: boolean; // Strip common path prefix (default: true)
|
|
104
|
+
getFilesForUpload: () => File[]; // raw Files for ship.deployments.upload()
|
|
99
105
|
}
|
|
100
106
|
```
|
|
101
107
|
|
|
102
|
-
|
|
108
|
+
**`isDragging` is not a phase.** It's a pointer state that can occur over any phase, so a ready set stays ready while a new folder is dragged over it. Switch on `phase`; style on `isDragging`.
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
interface DropReturn {
|
|
106
|
-
// State
|
|
107
|
-
phase: 'idle' | 'dragging' | 'processing' | 'ready' | 'error';
|
|
108
|
-
isProcessing: boolean;
|
|
109
|
-
isDragging: boolean;
|
|
110
|
-
isInteractive: boolean; // true when idle, dragging, or ready
|
|
111
|
-
hasError: boolean; // true when in error state
|
|
112
|
-
files: ProcessedFile[];
|
|
113
|
-
validFiles: ProcessedFile[];
|
|
114
|
-
sourceName: string;
|
|
115
|
-
status: { title: string; details: string; errors?: string[]; warnings?: string[] } | null;
|
|
110
|
+
### Phases
|
|
116
111
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
```
|
|
113
|
+
idle → processing → ready (deployable)
|
|
114
|
+
→ error (see status)
|
|
115
|
+
```
|
|
120
116
|
|
|
121
|
-
|
|
122
|
-
open: () => void; // Trigger file picker
|
|
123
|
-
processFiles: (files: File[]) => Promise<void>;
|
|
124
|
-
reset: () => void; // Clear all files and reset state
|
|
117
|
+
`status` carries what to show the user:
|
|
125
118
|
|
|
126
|
-
|
|
127
|
-
|
|
119
|
+
```ts
|
|
120
|
+
interface DropStatus {
|
|
121
|
+
title: string;
|
|
122
|
+
details: string;
|
|
123
|
+
errors?: string[]; // per-file breakdown, on multi-error failures
|
|
124
|
+
warnings?: string[]; // non-blocking, e.g. excluded empty files
|
|
128
125
|
}
|
|
129
126
|
```
|
|
130
127
|
|
|
131
|
-
|
|
128
|
+
To react to a phase change, use the state — that's what it's for:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (drop.phase === 'ready') track('files_ready', drop.files.length);
|
|
133
|
+
}, [drop.phase]);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Prop getters
|
|
132
137
|
|
|
133
138
|
```tsx
|
|
134
|
-
// Default: clickable dropzone (click opens file picker)
|
|
135
139
|
<div {...drop.getDropzoneProps()}>
|
|
140
|
+
<input {...drop.getInputProps()} />
|
|
141
|
+
</div>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Drag-only, with your own trigger:
|
|
136
145
|
|
|
137
|
-
|
|
146
|
+
```tsx
|
|
138
147
|
<div {...drop.getDropzoneProps({ clickable: false })}>
|
|
148
|
+
<input {...drop.getInputProps()} />
|
|
139
149
|
<button onClick={drop.open}>Select folder</button>
|
|
140
150
|
</div>
|
|
141
151
|
```
|
|
142
152
|
|
|
143
|
-
|
|
153
|
+
`getDropzoneProps()` handles `webkitGetAsEntry` internally, which is what preserves folder structure. Calling `processFiles()` yourself loses it — the browser invalidates `dataTransfer.items` at the first `await`, so entries must be captured synchronously.
|
|
144
154
|
|
|
145
|
-
|
|
155
|
+
**The hidden input is a folder picker.** It always carries `webkitdirectory`, so clicking opens a directory chooser. Individual files arrive by drag & drop.
|
|
146
156
|
|
|
147
|
-
|
|
148
|
-
|
|
157
|
+
## Validation
|
|
158
|
+
|
|
159
|
+
Validation is **atomic**: if any file fails, every non-excluded file is marked `validation_failed` and nothing is deployable. Call `reset()` and start over.
|
|
160
|
+
|
|
161
|
+
Empty files (0 bytes) are `excluded` with a warning rather than failing the deploy.
|
|
162
|
+
|
|
163
|
+
| Status | Meaning |
|
|
164
|
+
|--------|---------|
|
|
165
|
+
| `pending` | Awaiting validation |
|
|
166
|
+
| `processing_error` | Failed during processing |
|
|
167
|
+
| `excluded` | Excluded with a warning — not an error |
|
|
168
|
+
| `validation_failed` | Failed validation; blocks deployment |
|
|
169
|
+
| `ready` | Deployable |
|
|
170
|
+
|
|
171
|
+
These are Ship's own values. Drop adds none of its own, so a `ProcessedFile` is directly expressible as Ship's `ValidatableFile` — and you compare against `FileValidationStatus`, imported from `@shipstatic/ship`, rather than a drop-specific alias:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { FileValidationStatus } from '@shipstatic/ship';
|
|
149
175
|
|
|
150
|
-
|
|
151
|
-
// Client validation matches server limits
|
|
176
|
+
const ready = drop.files.filter(f => f.status === FileValidationStatus.READY);
|
|
152
177
|
```
|
|
153
178
|
|
|
154
|
-
|
|
179
|
+
## Build on upload
|
|
180
|
+
|
|
181
|
+
Drop recognises an unbuilt project (source files with `package.json` / `node_modules`) and sets `needsBuild`. `node_modules` is skipped during traversal and stripped from folder-picker selections, deploy validation is skipped (source files aren't build output), and every file goes straight to `ready`.
|
|
182
|
+
|
|
183
|
+
Pass the signal through to the SDK:
|
|
155
184
|
|
|
156
185
|
```tsx
|
|
157
|
-
|
|
158
|
-
|
|
186
|
+
await ship.deployments.upload(drop.getFilesForUpload(), {
|
|
187
|
+
build: drop.needsBuild,
|
|
188
|
+
prerender: drop.needsBuild,
|
|
189
|
+
});
|
|
159
190
|
```
|
|
160
191
|
|
|
161
|
-
##
|
|
192
|
+
## ZIP handling
|
|
193
|
+
|
|
194
|
+
A **single** dropped ZIP is extracted and its contents deployed. ZIPs among several files are treated as ordinary files. Archive paths are sanitized against directory traversal (`../../etc/passwd` → `etc/passwd`).
|
|
195
|
+
|
|
196
|
+
## Without React
|
|
197
|
+
|
|
198
|
+
The pipeline is a plain function, so any UI layer can use it:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { processFiles } from '@shipstatic/drop';
|
|
202
|
+
import { FileValidationStatus } from '@shipstatic/ship';
|
|
162
203
|
|
|
163
|
-
|
|
204
|
+
const outcome = await processFiles(files, { limits: await ship.getLimits() });
|
|
164
205
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
206
|
+
if (outcome.phase === 'ready') {
|
|
207
|
+
const ready = outcome.files.filter(f => f.status === FileValidationStatus.READY);
|
|
208
|
+
await ship.deployments.upload(ready.map(f => f.file));
|
|
209
|
+
} else {
|
|
210
|
+
console.error(outcome.status.title, outcome.status.details);
|
|
211
|
+
}
|
|
171
212
|
```
|
|
172
213
|
|
|
173
|
-
|
|
214
|
+
It never throws — a missing entry point, an oversized file, an unbuilt project, and an unexpected failure all come back as an `error` outcome. Pass `onStatus` to report progress during extraction.
|
|
215
|
+
|
|
216
|
+
## Testing your components
|
|
217
|
+
|
|
218
|
+
`@shipstatic/drop/testing` builds the fixtures so your tests don't have to:
|
|
174
219
|
|
|
175
220
|
```tsx
|
|
176
|
-
import { render, screen } from '@testing-library/react';
|
|
177
221
|
import { createMockDrop, createMockProcessedFile } from '@shipstatic/drop/testing';
|
|
178
222
|
|
|
179
|
-
it('
|
|
223
|
+
it('renders the file count', () => {
|
|
180
224
|
const drop = createMockDrop({
|
|
181
225
|
phase: 'ready',
|
|
182
|
-
files: [
|
|
183
|
-
createMockProcessedFile('index.html'),
|
|
184
|
-
createMockProcessedFile('style.css'),
|
|
185
|
-
],
|
|
226
|
+
files: [createMockProcessedFile('index.html')],
|
|
186
227
|
});
|
|
187
228
|
|
|
188
|
-
render(<
|
|
189
|
-
expect(screen.getByText('
|
|
229
|
+
render(<Dropzone drop={drop} />);
|
|
230
|
+
expect(screen.getByText('1 file')).toBeInTheDocument();
|
|
190
231
|
});
|
|
191
232
|
```
|
|
192
233
|
|
|
193
|
-
|
|
234
|
+
Override any field — including with your own spies, which is how you assert on interactions:
|
|
194
235
|
|
|
195
236
|
```tsx
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
it('calls reset when Clear is clicked', async () => {
|
|
200
|
-
const { drop, spies } = createMockDropWithSpies({
|
|
201
|
-
phase: 'ready',
|
|
202
|
-
files: [createMockProcessedFile('index.html')],
|
|
203
|
-
});
|
|
237
|
+
const reset = vi.fn();
|
|
238
|
+
const drop = createMockDrop({ phase: 'ready', reset });
|
|
204
239
|
|
|
205
|
-
|
|
206
|
-
|
|
240
|
+
render(<Dropzone drop={drop} />);
|
|
241
|
+
await userEvent.click(screen.getByText('Clear'));
|
|
207
242
|
|
|
208
|
-
|
|
209
|
-
});
|
|
243
|
+
expect(reset).toHaveBeenCalled();
|
|
210
244
|
```
|
|
211
245
|
|
|
212
|
-
|
|
246
|
+
The subpath deliberately ships no spy or matcher helpers of its own — your test framework already has better ones.
|
|
247
|
+
|
|
248
|
+
| Export | Purpose |
|
|
249
|
+
|--------|---------|
|
|
250
|
+
| `createMockDrop(overrides?)` | A complete `DropReturn`; convenience booleans and `validFiles` derive from `phase` and `files` unless overridden |
|
|
251
|
+
| `createMockProcessedFile(name, options?)` | A `ProcessedFile` backed by a real `File` |
|
|
252
|
+
| `createMockFileWithPath(name, path, content?, type?)` | A real `File` carrying a folder-relative path |
|
|
253
|
+
| `mockUseDrop(overrides?)` | A `useDrop` replacement, for components that call the hook themselves |
|
|
254
|
+
|
|
255
|
+
If your component **receives** `drop` as a prop, you need nothing else — pass it a
|
|
256
|
+
`createMockDrop()`. If it calls `useDrop` internally, replace the module:
|
|
213
257
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
| `createMockFile(name, content?, type?)` | Mock `File` object |
|
|
220
|
-
| `createMockFileWithPath(name, path, ...)` | Mock `File` with `webkitRelativePath` |
|
|
221
|
-
| `createMockErrorStatus(title?, details?, errors?)` | Mock error status |
|
|
222
|
-
| `createMockProcessingStatus(title?, details?)` | Mock processing status |
|
|
223
|
-
| `createMockReadyStatus(count)` | Mock ready status (`"N file(s) are ready."`) |
|
|
258
|
+
```tsx
|
|
259
|
+
import { mockUseDrop } from '@shipstatic/drop/testing';
|
|
260
|
+
|
|
261
|
+
vi.mock('@shipstatic/drop', () => ({ useDrop: mockUseDrop({ phase: 'ready' }) }));
|
|
262
|
+
```
|
|
224
263
|
|
|
225
|
-
##
|
|
264
|
+
## Gotchas
|
|
226
265
|
|
|
227
|
-
-
|
|
228
|
-
-
|
|
266
|
+
- **`webkitRelativePath` is the handoff.** Drop writes each file's deploy path there, and the Ship SDK reads it. Don't modify it in between.
|
|
267
|
+
- **`stripCommonPrefix` mutates File objects.** It returns new `ProcessedFile`s but rewrites `webkitRelativePath` on the underlying `File` — deliberately, because that's what the SDK reads.
|
|
268
|
+
- **Unreadable entries are skipped silently.** A folder with permission-denied files still deploys; the failures are logged to the console with no programmatic signal.
|
|
269
|
+
- **No MD5 here.** Ship computes checksums during upload.
|
|
270
|
+
- **`type` is the browser's report.** The platform derives `Content-Type` server-side from the path, so drop bundles no MIME database.
|
|
229
271
|
|
|
230
272
|
## License
|
|
231
273
|
|