@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @shipstatic/drop
2
2
 
3
- Headless file processing toolkit for Ship SDK deployments.
3
+ Headless file processing for Ship SDK deployments.
4
4
 
5
- A focused React hook for preparing files for deployment with [@shipstatic/ship](https://github.com/shipstatic/ship). Handles ZIP extraction, path normalization, folder structure preservation, and validation.
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({ deployToken: 'token-xxxx' });
21
+ const ship = new Ship({ token: 'deploy-...' });
20
22
 
21
23
  function Uploader() {
22
24
  const drop = useDrop({ ship });
23
25
 
24
- const handleUpload = async () => {
25
- const files = drop.getFilesForUpload();
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: '40px',
37
+ padding: 40,
37
38
  textAlign: 'center',
38
39
  }}
39
40
  >
40
41
  <input {...drop.getInputProps()} />
41
- {drop.isDragging ? 'Drop here' : 'Click or drag files/folders'}
42
+ {drop.isDragging ? 'Drop here' : 'Click or drag a folder'}
42
43
  </div>
43
44
 
44
- {drop.status && <p>{drop.status.title}: {drop.status.details}</p>}
45
+ {drop.status && (
46
+ <p>
47
+ {drop.status.title}: {drop.status.details}
48
+ </p>
49
+ )}
45
50
 
46
- <button onClick={handleUpload} disabled={!drop.validFiles.length}>
47
- Upload {drop.validFiles.length} files
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
- ## Features
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
- ## State Machine
61
+ Ship's SDK deploys files. It doesn't do the browser-side work of *getting* them:
64
62
 
65
- ```
66
- idle dragging processing ready/error
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
- Use semantic booleans for clean rendering:
69
+ ## `useDrop(options)`
70
70
 
71
- ```tsx
72
- {drop.isProcessing && <Spinner />}
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
- Or use `phase` for switch-case logic:
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
- ```tsx
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
- ## API
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
- ### `useDrop(options)`
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
- ```typescript
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
- ### Return Value
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
- ```typescript
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
- // Prop getters
118
- getDropzoneProps: (options?: { clickable?: boolean }) => {...};
119
- getInputProps: () => {...};
112
+ ```
113
+ idle processing ready (deployable)
114
+ error (see status)
115
+ ```
120
116
 
121
- // Actions
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
- // Helpers
127
- getFilesForUpload: () => File[]; // Get raw File objects for SDK
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
- ### Prop Getter Options
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
- // Drag-only dropzone (no click behavior)
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
- ## Ship SDK Integration
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
- Drop uses Ship SDK's validation automatically:
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
- ```tsx
148
- const drop = useDrop({ ship });
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
- // Behind the scenes: ship.getLimits() validateFiles()
151
- // Client validation matches server limits
176
+ const ready = drop.files.filter(f => f.status === FileValidationStatus.READY);
152
177
  ```
153
178
 
154
- Pass files to Ship SDK:
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
- const files = drop.getFilesForUpload();
158
- await ship.deployments.upload(files);
186
+ await ship.deployments.upload(drop.getFilesForUpload(), {
187
+ build: drop.needsBuild,
188
+ prerender: drop.needsBuild,
189
+ });
159
190
  ```
160
191
 
161
- ## Testing
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
- The `/testing` subpath provides mock utilities for testing components that use `useDrop`:
204
+ const outcome = await processFiles(files, { limits: await ship.getLimits() });
164
205
 
165
- ```typescript
166
- import {
167
- createMockDrop,
168
- createMockDropWithSpies,
169
- createMockProcessedFile,
170
- } from '@shipstatic/drop/testing';
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
- ### Testing Component States
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('shows file count when ready', () => {
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(<MyDropzone drop={drop} />);
189
- expect(screen.getByText('2 files ready')).toBeInTheDocument();
229
+ render(<Dropzone drop={drop} />);
230
+ expect(screen.getByText('1 file')).toBeInTheDocument();
190
231
  });
191
232
  ```
192
233
 
193
- ### Testing Interactions
234
+ Override any field — including with your own spies, which is how you assert on interactions:
194
235
 
195
236
  ```tsx
196
- import userEvent from '@testing-library/user-event';
197
- import { createMockDropWithSpies, createMockProcessedFile } from '@shipstatic/drop/testing';
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
- render(<MyDropzone drop={drop} />);
206
- await userEvent.click(screen.getByText('Clear'));
240
+ render(<Dropzone drop={drop} />);
241
+ await userEvent.click(screen.getByText('Clear'));
207
242
 
208
- expect(spies.reset.toHaveBeenCalled()).toBe(true);
209
- });
243
+ expect(reset).toHaveBeenCalled();
210
244
  ```
211
245
 
212
- ### Available Utilities
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
- | Function | Purpose |
215
- |----------|---------|
216
- | `createMockDrop(options?)` | Mock `DropReturn` for rendering tests |
217
- | `createMockDropWithSpies(options?)` | Mock with call tracking for interaction tests |
218
- | `createMockProcessedFile(name, options?)` | Mock `ProcessedFile` |
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
- ## Requirements
264
+ ## Gotchas
226
265
 
227
- - React 18+ or 19+
228
- - Modern browsers (Chrome, Edge, Safari 11.1+, Firefox 50+)
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