@swoff/cli 0.0.1

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.
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Swoff Incremental File Generator
3
+ *
4
+ * Generates hooks, components, and utilities based on config features.
5
+ *
6
+ * CLI Usage:
7
+ * node swoff-files-generator.js --project-root <path> --package-dir <path>
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
11
+ import { fileURLToPath } from "url";
12
+ import { join, dirname } from "path";
13
+
14
+ // Parse CLI arguments
15
+ const args = process.argv.slice(2);
16
+ const projectRootArg = args.findIndex(arg => arg === '--project-root');
17
+ const packageDirArg = args.findIndex(arg => arg === '--package-dir');
18
+
19
+ const passedProjectRoot = projectRootArg !== -1 ? args[projectRootArg + 1] : null;
20
+ const passedPackageDir = packageDirArg !== -1 ? args[packageDirArg + 1] : null;
21
+
22
+ const projectRoot = passedProjectRoot || process.cwd();
23
+ const packageDir = passedPackageDir || join(dirname(fileURLToPath(import.meta.url)), '../..');
24
+
25
+ // Load configuration
26
+ function loadConfig() {
27
+ const configPath = join(projectRoot, "swoff.config.json");
28
+
29
+ if (existsSync(configPath)) {
30
+ try {
31
+ return JSON.parse(readFileSync(configPath, "utf8"));
32
+ } catch (e) {
33
+ return null;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ const config = loadConfig() || {
40
+ enabled: true,
41
+ features: {
42
+ offlineReads: true,
43
+ mutationQueue: false,
44
+ pwa: true,
45
+ crossTabSync: true
46
+ }
47
+ };
48
+
49
+ if (!config.enabled) {
50
+ console.log("āš ļø Config generation disabled.");
51
+ process.exit(0);
52
+ }
53
+
54
+ console.log("šŸ”§ Swoff Incremental File Generator");
55
+ console.log("====================================\n");
56
+
57
+ const generatedFiles = [];
58
+
59
+ if (config.features.offlineReads) {
60
+ console.log("šŸ“¦ Generating offline data hooks...");
61
+ generatedFiles.push(...generateOfflineHooks());
62
+ }
63
+
64
+ if (config.features.mutationQueue) {
65
+ console.log("šŸ“¦ Generating mutation queue hooks...");
66
+ generatedFiles.push(...generateMutationQueueHooks());
67
+ }
68
+
69
+ if (config.features.pwa) {
70
+ console.log("šŸ“¦ Generating PWA components...");
71
+ generatedFiles.push(...generatePWAComponents());
72
+ }
73
+
74
+ if (config.features.crossTabSync) {
75
+ console.log("šŸ“¦ Generating cross-tab sync utilities...");
76
+ generatedFiles.push(...generateCrossTabSync());
77
+ }
78
+
79
+ console.log("\nāœ… Generated files:");
80
+ generatedFiles.forEach(file => console.log(` • ${file.path}`));
81
+ console.log(`\n✨ Total: ${generatedFiles.length} files generated`);
82
+
83
+ function generateOfflineHooks() {
84
+ const files = [];
85
+ const outputDir = join(projectRoot, "src", "hooks");
86
+
87
+ if (!existsSync(outputDir)) {
88
+ mkdirSync(outputDir, { recursive: true });
89
+ }
90
+
91
+ const useOfflineCode = `import { useState, useEffect } from 'react';
92
+
93
+ /**
94
+ * useOffline hook
95
+ * Detects online/offline status and provides callback handlers.
96
+ *
97
+ * @example
98
+ * const { isOnline, isOffline } = useOffline();
99
+ */
100
+ export const useOffline = () => {
101
+ const [isOnline, setIsOnline] = useState(() => navigator.onLine);
102
+
103
+ useEffect(() => {
104
+ const handleOnline = () => setIsOnline(true);
105
+ const handleOffline = () => setIsOnline(false);
106
+
107
+ window.addEventListener('online', handleOnline);
108
+ window.addEventListener('offline', handleOffline);
109
+
110
+ return () => {
111
+ window.removeEventListener('online', handleOnline);
112
+ window.removeEventListener('offline', handleOffline);
113
+ };
114
+ }, []);
115
+
116
+ return { isOnline, isOffline: !isOnline };
117
+ };
118
+
119
+ export default useOffline;
120
+ `;
121
+
122
+ writeFileSync(join(outputDir, "useOffline.ts"), useOfflineCode);
123
+ files.push({ path: "src/hooks/useOffline.ts" });
124
+
125
+ const useApiDataCode = `import { useState, useEffect, useCallback } from 'react';
126
+
127
+ /**
128
+ * useApiData hook
129
+ * Fetches and caches API data for offline access.
130
+ *
131
+ * @param endpoint - API endpoint to fetch
132
+ * @param options - Fetch options
133
+ */
134
+ export const useApiData = (endpoint, options = {}) => {
135
+ const [data, setData] = useState(null);
136
+ const [loading, setLoading] = useState(true);
137
+ const [error, setError] = useState(null);
138
+
139
+ const fetchData = useCallback(async (fetchOptions = {}) => {
140
+ try {
141
+ setLoading(true);
142
+ setError(null);
143
+
144
+ const response = await fetch(endpoint, { ...options, ...fetchOptions });
145
+
146
+ if (!response.ok) {
147
+ throw new Error(\`HTTP error! status: \${response.status}\`);
148
+ }
149
+
150
+ const result = await response.json();
151
+ setData(result);
152
+ return result;
153
+ } catch (err) {
154
+ const errorMessage = err instanceof Error ? err.message : 'An error occurred';
155
+ setError(errorMessage);
156
+
157
+ if (!navigator.onLine && data) {
158
+ return data;
159
+ }
160
+
161
+ throw err;
162
+ } finally {
163
+ setLoading(false);
164
+ }
165
+ }, [endpoint, options, data]);
166
+
167
+ useEffect(() => { fetchData(); }, [endpoint]);
168
+
169
+ return { data, loading, error, refetch: fetchData };
170
+ };
171
+
172
+ export default useApiData;
173
+ `;
174
+
175
+ writeFileSync(join(outputDir, "useApiData.ts"), useApiDataCode);
176
+ files.push({ path: "src/hooks/useApiData.ts" });
177
+
178
+ return files;
179
+ }
180
+
181
+ function generateMutationQueueHooks() {
182
+ const files = [];
183
+ const outputDir = join(projectRoot, "src", "hooks");
184
+
185
+ if (!existsSync(outputDir)) {
186
+ mkdirSync(outputDir, { recursive: true });
187
+ }
188
+
189
+ const useMutationQueueCode = `import { useState, useEffect, useCallback } from 'react';
190
+
191
+ /**
192
+ * useMutationQueue hook
193
+ * Queues mutations when offline and syncs when back online.
194
+ */
195
+ export const useMutationQueue = (options = {}) => {
196
+ const { onSync, maxRetries = 3, storageKey = 'swoff-mutation-queue' } = options;
197
+
198
+ const [pendingMutations, setPendingMutations] = useState([]);
199
+ const [isSyncing, setIsSyncing] = useState(false);
200
+
201
+ useEffect(() => {
202
+ try {
203
+ const stored = localStorage.getItem(storageKey);
204
+ if (stored) setPendingMutations(JSON.parse(stored));
205
+ } catch (err) {}
206
+ }, [storageKey]);
207
+
208
+ useEffect(() => {
209
+ try {
210
+ localStorage.setItem(storageKey, JSON.stringify(pendingMutations));
211
+ } catch (err) {}
212
+ }, [pendingMutations, storageKey]);
213
+
214
+ const queueMutation = useCallback(async (mutation) => {
215
+ const newMutation = {
216
+ id: \`\${Date.now()}-\${Math.random().toString(36).substr(2, 9)}\`,
217
+ timestamp: Date.now(),
218
+ status: 'pending',
219
+ retries: 0,
220
+ ...mutation
221
+ };
222
+
223
+ setPendingMutations(prev => [...prev, newMutation]);
224
+
225
+ if (navigator.onLine) {
226
+ await syncMutations();
227
+ }
228
+
229
+ return newMutation.id;
230
+ }, [onSync]);
231
+
232
+ const syncMutations = useCallback(async () => {
233
+ if (isSyncing || !navigator.onLine || pendingMutations.length === 0) return;
234
+
235
+ setIsSyncing(true);
236
+
237
+ for (const mutation of pendingMutations.filter(m => m.status === 'pending')) {
238
+ try {
239
+ await onSync(mutation);
240
+ setPendingMutations(prev => prev.filter(m => m.id !== mutation.id));
241
+ } catch (err) {
242
+ setPendingMutations(prev =>
243
+ prev.map(m => m.id === mutation.id
244
+ ? { ...m, retries: m.retries + 1 }
245
+ : m
246
+ )
247
+ );
248
+ }
249
+ }
250
+
251
+ setIsSyncing(false);
252
+ }, [isSyncing, onSync, pendingMutations]);
253
+
254
+ useEffect(() => {
255
+ const handleOnline = () => syncMutations();
256
+ window.addEventListener('online', handleOnline);
257
+ return () => window.removeEventListener('online', handleOnline);
258
+ }, [syncMutations]);
259
+
260
+ const clearQueue = useCallback(() => {
261
+ setPendingMutations([]);
262
+ localStorage.removeItem(storageKey);
263
+ }, [storageKey]);
264
+
265
+ return { queueMutation, pendingMutations, isSyncing, syncMutations, clearQueue };
266
+ };
267
+
268
+ export default useMutationQueue;
269
+ `;
270
+
271
+ writeFileSync(join(outputDir, "useMutationQueue.ts"), useMutationQueueCode);
272
+ files.push({ path: "src/hooks/useMutationQueue.ts" });
273
+
274
+ return files;
275
+ }
276
+
277
+ function generatePWAComponents() {
278
+ const files = [];
279
+ const outputDir = join(projectRoot, "src", "components");
280
+
281
+ if (!existsSync(outputDir)) {
282
+ mkdirSync(outputDir, { recursive: true });
283
+ }
284
+
285
+ const offlineIndicatorCode = `import { useOffline } from '../hooks/useOffline';
286
+
287
+ /**
288
+ * OfflineIndicator component
289
+ * Shows a banner when the user is offline.
290
+ */
291
+ export const OfflineIndicator = ({
292
+ position = 'bottom-right',
293
+ message = 'You are offline. Some features may be limited.',
294
+ className = ''
295
+ }) => {
296
+ const { isOffline } = useOffline();
297
+
298
+ if (!isOffline) return null;
299
+
300
+ const positionClasses = {
301
+ 'top-left': 'top-4 left-4',
302
+ 'top-right': 'top-4 right-4',
303
+ 'bottom-left': 'bottom-4 left-4',
304
+ 'bottom-right': 'bottom-4 right-4'
305
+ };
306
+
307
+ return (
308
+ <div
309
+ className={\`fixed \${positionClasses[position]} bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50 flex items-center gap-2 \${className}\`}
310
+ role="alert"
311
+ aria-live="polite"
312
+ >
313
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
314
+ <path fillRule="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" clipRule="evenodd"/>
315
+ </svg>
316
+ <span className="text-sm font-medium">{message}</span>
317
+ </div>
318
+ );
319
+ };
320
+
321
+ export default OfflineIndicator;
322
+ `;
323
+
324
+ writeFileSync(join(outputDir, "OfflineIndicator.tsx"), offlineIndicatorCode);
325
+ files.push({ path: "src/components/OfflineIndicator.tsx" });
326
+
327
+ const pwaInstallCode = `import { useState, useEffect } from 'react';
328
+
329
+ /**
330
+ * PWAInstallButton component
331
+ * Shows an install button when the PWA is installable.
332
+ */
333
+ export const PWAInstallButton = ({
334
+ installLabel = 'Install App',
335
+ installedLabel = 'Installed',
336
+ className = ''
337
+ }) => {
338
+ const [isInstallable, setIsInstallable] = useState(false);
339
+ const [isInstalled, setIsInstalled] = useState(false);
340
+ const [deferredPrompt, setDeferredPrompt] = useState(null);
341
+
342
+ useEffect(() => {
343
+ const handleBeforeInstallPrompt = (e) => {
344
+ e.preventDefault();
345
+ setDeferredPrompt(e);
346
+ setIsInstallable(true);
347
+ };
348
+
349
+ const handleAppInstalled = () => {
350
+ setIsInstalled(true);
351
+ setIsInstallable(false);
352
+ };
353
+
354
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
355
+ window.addEventListener('appinstalled', handleAppInstalled);
356
+
357
+ return () => {
358
+ window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
359
+ window.removeEventListener('appinstalled', handleAppInstalled);
360
+ };
361
+ }, []);
362
+
363
+ const handleInstall = async () => {
364
+ if (!deferredPrompt) return;
365
+ await deferredPrompt.prompt();
366
+ const { outcome } = await deferredPrompt.userChoice;
367
+ if (outcome === 'accepted') setIsInstalled(true);
368
+ setIsInstallable(false);
369
+ };
370
+
371
+ if (!isInstallable && !isInstalled) return null;
372
+
373
+ return (
374
+ <button
375
+ onClick={handleInstall}
376
+ disabled={isInstalled}
377
+ className={\`fixed bottom-4 right-4 bg-blue-500 hover:bg-blue-600 disabled:bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg font-medium z-50 \${className}\`}
378
+ >
379
+ {isInstalled ? installedLabel : installLabel}
380
+ </button>
381
+ );
382
+ };
383
+
384
+ export default PWAInstallButton;
385
+ `;
386
+
387
+ writeFileSync(join(outputDir, "PWAInstallButton.tsx"), pwaInstallCode);
388
+ files.push({ path: "src/components/PWAInstallButton.tsx" });
389
+
390
+ return files;
391
+ }
392
+
393
+ function generateCrossTabSync() {
394
+ const files = [];
395
+ const outputDir = join(projectRoot, "src", "utils");
396
+
397
+ if (!existsSync(outputDir)) {
398
+ mkdirSync(outputDir, { recursive: true });
399
+ }
400
+
401
+ const crossTabSyncCode = `/**
402
+ * Cross-Tab Synchronization Utilities
403
+ * Provides utilities for synchronizing state across browser tabs.
404
+ */
405
+
406
+ export class CrossTabSync {
407
+ constructor(channelName = 'swoff-sync') {
408
+ this.channel = new BroadcastChannel(channelName);
409
+ this.listeners = new Map();
410
+
411
+ this.channel.onmessage = (event) => {
412
+ const { type, key, value } = event.data;
413
+ const callbacks = this.listeners.get(key);
414
+ if (callbacks) {
415
+ callbacks.forEach(callback => callback(value, type));
416
+ }
417
+ };
418
+ }
419
+
420
+ subscribe(key, callback) {
421
+ if (!this.listeners.has(key)) {
422
+ this.listeners.set(key, new Set());
423
+ }
424
+ this.listeners.get(key).add(callback);
425
+ return () => this.listeners.get(key)?.delete(callback);
426
+ }
427
+
428
+ set(key, value) {
429
+ try {
430
+ localStorage.setItem(\`swoff-sync-\${key}\`, JSON.stringify(value));
431
+ } catch (err) {}
432
+ this.channel.postMessage({ type: 'set', key, value });
433
+ }
434
+
435
+ get(key) {
436
+ try {
437
+ const stored = localStorage.getItem(\`swoff-sync-\${key}\`);
438
+ return stored ? JSON.parse(stored) : null;
439
+ } catch (err) {
440
+ return null;
441
+ }
442
+ }
443
+
444
+ close() {
445
+ this.channel.close();
446
+ this.listeners.clear();
447
+ }
448
+ }
449
+
450
+ export const createCrossTabSync = (channelName) => new CrossTabSync(channelName);
451
+
452
+ export default CrossTabSync;
453
+ `;
454
+
455
+ writeFileSync(join(outputDir, "crossTabSync.ts"), crossTabSyncCode);
456
+ files.push({ path: "src/utils/crossTabSync.ts" });
457
+
458
+ return files;
459
+ }
460
+
461
+ console.log("\nšŸ“ Note: Generated files are located in src/hooks, src/components, and src/utils");
@@ -0,0 +1,171 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://swoff.netlify.app/schema/v1.json",
4
+ "title": "Swoff Configuration",
5
+ "description": "Configuration for the Swoff offline-first service worker system",
6
+ "type": "object",
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string",
10
+ "description": "Reference to the schema version",
11
+ "example": "https://swoff.netlify.app/schema/v1.json"
12
+ },
13
+ "enabled": {
14
+ "type": "boolean",
15
+ "description": "Enable or disable config-driven generation",
16
+ "default": true
17
+ },
18
+ "version": {
19
+ "type": "string",
20
+ "description": "Service Worker version source",
21
+ "default": "from-package",
22
+ "enum": ["from-package"],
23
+ "pattern": "^\\d+\\.\\d+\\.\\d+$"
24
+ },
25
+ "minSupportedVersion": {
26
+ "type": "string",
27
+ "description": "Minimum supported version for forced updates",
28
+ "default": "0.0.0",
29
+ "pattern": "^\\d+\\.\\d+\\.\\d+$"
30
+ },
31
+ "serviceWorker": {
32
+ "type": "object",
33
+ "description": "Service Worker configuration",
34
+ "properties": {
35
+ "autoUpdate": {
36
+ "type": "boolean",
37
+ "description": "Auto-update behavior - true = silent update, false = show update prompt",
38
+ "default": false
39
+ },
40
+ "defaultStrategy": {
41
+ "type": "string",
42
+ "description": "Default caching strategy for all requests",
43
+ "default": "cache-first",
44
+ "enum": ["cache-first", "network-first", "stale-while-revalidate", "cache-only", "network-only"]
45
+ },
46
+ "strategies": {
47
+ "type": "object",
48
+ "description": "Custom cache strategies for specific URL patterns",
49
+ "additionalProperties": {
50
+ "type": "string",
51
+ "enum": ["cache-first", "network-first", "stale-while-revalidate", "cache-only", "network-only"]
52
+ },
53
+ "patternProperties": {
54
+ ".*": {
55
+ "type": "string"
56
+ }
57
+ }
58
+ },
59
+ "maxCacheEntries": {
60
+ "type": "integer",
61
+ "description": "Maximum number of cached entries",
62
+ "default": 100,
63
+ "minimum": 1
64
+ },
65
+ "maxCacheAge": {
66
+ "type": "integer",
67
+ "description": "Maximum age of cached entries in milliseconds",
68
+ "default": 604800000,
69
+ "minimum": 1000
70
+ },
71
+ "runtimeCacheName": {
72
+ "type": "string",
73
+ "description": "Custom runtime cache name",
74
+ "default": "swoff-runtime",
75
+ "pattern": "^[a-zA-Z0-9-_]+$"
76
+ }
77
+ },
78
+ "required": ["autoUpdate", "defaultStrategy", "maxCacheEntries", "maxCacheAge", "runtimeCacheName"]
79
+ },
80
+ "features": {
81
+ "type": "object",
82
+ "description": "Feature toggles - enable/disable specific functionality",
83
+ "properties": {
84
+ "versionedSw": {
85
+ "type": "boolean",
86
+ "description": "Enable versioned service worker (prevents silent updates)",
87
+ "default": true
88
+ },
89
+ "offlineReads": {
90
+ "type": "boolean",
91
+ "description": "Cache API responses for offline read access",
92
+ "default": true
93
+ },
94
+ "mutationQueue": {
95
+ "type": "boolean",
96
+ "description": "Queue offline writes and sync when back online",
97
+ "default": false
98
+ },
99
+ "backgroundSync": {
100
+ "type": "boolean",
101
+ "description": "Enable Background Sync API (Chrome/Edge only)",
102
+ "default": false
103
+ },
104
+ "pwa": {
105
+ "type": "boolean",
106
+ "description": "Enable PWA installability (manifest handling should be done separately)",
107
+ "default": true
108
+ },
109
+ "auth": {
110
+ "type": "boolean",
111
+ "description": "Enable auth integration",
112
+ "default": false
113
+ },
114
+ "crossTabSync": {
115
+ "type": "boolean",
116
+ "description": "Cross-tab cache invalidation sync",
117
+ "default": true
118
+ },
119
+ "tagInvalidation": {
120
+ "type": "boolean",
121
+ "description": "Tag-based cache invalidation",
122
+ "default": true
123
+ }
124
+ },
125
+ "required": ["versionedSw", "offlineReads", "mutationQueue", "backgroundSync", "pwa", "auth", "crossTabSync", "tagInvalidation"]
126
+ },
127
+ "database": {
128
+ "type": "object",
129
+ "description": "IndexedDB configuration",
130
+ "properties": {
131
+ "name": {
132
+ "type": "string",
133
+ "description": "Database name",
134
+ "default": "app-db",
135
+ "pattern": "^[a-zA-Z0-9-_]+$"
136
+ },
137
+ "stores": {
138
+ "type": "array",
139
+ "description": "Object store names (user defines their own schema)",
140
+ "items": {
141
+ "type": "string",
142
+ "pattern": "^[a-zA-Z0-9-_]+$"
143
+ },
144
+ "default": []
145
+ }
146
+ },
147
+ "required": ["name"]
148
+ },
149
+ "build": {
150
+ "type": "object",
151
+ "description": "Build output configuration",
152
+ "properties": {
153
+ "outputDir": {
154
+ "type": "string",
155
+ "description": "Output directory for generated files",
156
+ "default": "dist",
157
+ "pattern": "^[^/\\\\]+$"
158
+ },
159
+ "swFilename": {
160
+ "type": "string",
161
+ "description": "Service worker output filename (without version)",
162
+ "default": "sw",
163
+ "pattern": "^[a-zA-Z0-9-_]+$"
164
+ }
165
+ },
166
+ "required": ["outputDir", "swFilename"]
167
+ }
168
+ },
169
+ "required": ["enabled", "version", "minSupportedVersion", "serviceWorker", "features", "build"],
170
+ "additionalProperties": false
171
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Swoff Service Worker Template
3
+ *
4
+ * This template is processed by the config generator to create
5
+ * a fully functional service worker based on user configuration.
6
+ */
7
+
8
+ let CACHE_NAME = "";
9
+ let ASSETS_TO_CACHE = [];
10
+
11
+ // [[INSTALL_HANDLER]]
12
+ // [[ACTIVATE_HANDLER]]
13
+ // [[FETCH_HANDLER]]
14
+
15
+ // Utility functions for service worker operations
16
+ const SWOFF = {
17
+ cache: {
18
+ async get(key) {
19
+ const cache = await caches.open(CACHE_NAME);
20
+ return cache.match(key);
21
+ },
22
+ async put(request, response) {
23
+ const cache = await caches.open(CACHE_NAME);
24
+ await cache.put(request, response);
25
+ },
26
+ async delete(request) {
27
+ const cache = await caches.open(CACHE_NAME);
28
+ await cache.delete(request);
29
+ }
30
+ },
31
+ network: {
32
+ async fetch(request, options = {}) {
33
+ try {
34
+ return await fetch(request, options);
35
+ } catch (error) {
36
+ throw new Error(\`Network request failed: \${error.message}\`);
37
+ }
38
+ }
39
+ }
40
+ };
41
+
42
+ if (typeof self !== 'undefined') {
43
+ self.SWOFF = SWOFF;
44
+ }