@swoff/cli 0.0.1 → 0.0.2

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,1060 @@
1
+ /**
2
+ * Swoff Files Generator
3
+ *
4
+ * Generates all pattern files (sw-injector, hooks, components, utils, build scripts, manifest)
5
+ * based on swoff.config.json features and project language.
6
+ *
7
+ * CLI Usage:
8
+ * node swoff-files-generator.js --project-root <path> --package-dir <path> --language <ts|js> --config-path <path>
9
+ */
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
11
+ import { fileURLToPath } from "url";
12
+ import { join, dirname } from "path";
13
+ const args = process.argv.slice(2);
14
+ function getArg(name) {
15
+ const idx = args.findIndex((arg) => arg === `--${name}`);
16
+ return idx !== -1 ? args[idx + 1] : null;
17
+ }
18
+ const projectRoot = getArg("project-root") || process.cwd();
19
+ const packageDir = getArg("package-dir") || join(dirname(fileURLToPath(import.meta.url)), "../..");
20
+ const language = getArg("language") || "ts";
21
+ const configPath = getArg("config-path") || join(projectRoot, "swoff.config.json");
22
+ const defaultConfig = {
23
+ enabled: true,
24
+ version: "from-package",
25
+ minSupportedVersion: "0.0.0",
26
+ serviceWorker: {
27
+ autoUpdate: false,
28
+ defaultStrategy: "cache-first",
29
+ strategies: {},
30
+ },
31
+ features: {
32
+ versionedSw: true,
33
+ offlineReads: true,
34
+ mutationQueue: false,
35
+ backgroundSync: false,
36
+ pwa: true,
37
+ auth: false,
38
+ crossTabSync: true,
39
+ tagInvalidation: true,
40
+ clientRegistration: true,
41
+ },
42
+ build: {
43
+ outputDir: "dist",
44
+ swFilename: "sw",
45
+ },
46
+ };
47
+ let config = defaultConfig;
48
+ if (existsSync(configPath)) {
49
+ try {
50
+ config = { ...defaultConfig, ...JSON.parse(readFileSync(configPath, "utf8")) };
51
+ }
52
+ catch {
53
+ console.warn("Could not parse config, using defaults");
54
+ }
55
+ }
56
+ else {
57
+ console.log("No config found, using defaults");
58
+ }
59
+ const ext = language === "ts" ? "ts" : "js";
60
+ const generatedFiles = [];
61
+ function generateSwInjector() {
62
+ const outputDir = join(projectRoot, "src");
63
+ if (!existsSync(outputDir))
64
+ mkdirSync(outputDir, { recursive: true });
65
+ if (language === "ts") {
66
+ const code = `import { useEffect, useState } from 'react';
67
+
68
+ export function shouldRegister(): boolean {
69
+ return typeof window !== 'undefined' && 'serviceWorker' in navigator;
70
+ }
71
+
72
+ export function useServiceWorkerRegistration(options: {
73
+ onSuccess?: (registration: ServiceWorkerRegistration) => void;
74
+ onUpdate?: (registration: ServiceWorkerRegistration) => void;
75
+ onError?: (error: Error) => void;
76
+ swPath?: string;
77
+ } = {}) {
78
+ const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
79
+ const [isReady, setIsReady] = useState(false);
80
+
81
+ useEffect(() => {
82
+ if (!shouldRegister()) return;
83
+
84
+ const register = async () => {
85
+ try {
86
+ const swPath = options.swPath || '/sw.js';
87
+ const reg = await navigator.serviceWorker.register(swPath);
88
+
89
+ reg.addEventListener('updatefound', () => {
90
+ const newWorker = reg.installing;
91
+ if (newWorker) {
92
+ newWorker.addEventListener('statechange', () => {
93
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
94
+ options.onUpdate?.(reg);
95
+ }
96
+ });
97
+ }
98
+ });
99
+
100
+ if (reg.active) {
101
+ options.onSuccess?.(reg);
102
+ } else {
103
+ reg.addEventListener('statechange', (e) => {
104
+ if ((e.target as ServiceWorker).state === 'activated') {
105
+ options.onSuccess?.(reg);
106
+ }
107
+ });
108
+ }
109
+
110
+ setRegistration(reg);
111
+ setIsReady(true);
112
+ } catch (err) {
113
+ options.onError?.(err instanceof Error ? err : new Error(String(err)));
114
+ }
115
+ };
116
+
117
+ register();
118
+ }, []);
119
+
120
+ return { registration, isReady };
121
+ }
122
+
123
+ export function registerServiceWorker(swPath?: string): Promise<ServiceWorkerRegistration> {
124
+ if (!shouldRegister()) {
125
+ return Promise.reject(new Error('Service workers not supported'));
126
+ }
127
+ return navigator.serviceWorker.register(swPath || '/sw.js');
128
+ }
129
+
130
+ export function unregisterServiceWorker(): Promise<void> {
131
+ return navigator.serviceWorker.ready.then((reg) => {
132
+ return reg.unregister();
133
+ });
134
+ }
135
+ `;
136
+ writeFileSync(join(outputDir, `sw-injector.${ext}`), code);
137
+ generatedFiles.push(`src/sw-injector.${ext}`);
138
+ }
139
+ else {
140
+ const code = `export function shouldRegister() {
141
+ return typeof window !== 'undefined' && 'serviceWorker' in navigator;
142
+ }
143
+
144
+ export async function registerServiceWorker(swPath) {
145
+ if (!shouldRegister()) {
146
+ throw new Error('Service workers not supported');
147
+ }
148
+ return navigator.serviceWorker.register(swPath || '/sw.js');
149
+ }
150
+
151
+ export async function unregisterServiceWorker() {
152
+ const reg = await navigator.serviceWorker.ready;
153
+ return reg.unregister();
154
+ }
155
+
156
+ export function useServiceWorkerRegistration(options = {}) {
157
+ const [registration, setRegistration] = useState(null);
158
+ const [isReady, setIsReady] = useState(false);
159
+
160
+ useEffect(() => {
161
+ if (!shouldRegister()) return;
162
+
163
+ registerServiceWorker(options.swPath)
164
+ .then((reg) => {
165
+ setRegistration(reg);
166
+ setIsReady(true);
167
+ options.onSuccess?.(reg);
168
+ })
169
+ .catch((err) => {
170
+ options.onError?.(err);
171
+ });
172
+ }, []);
173
+
174
+ return { registration, isReady };
175
+ }
176
+ `;
177
+ writeFileSync(join(outputDir, `sw-injector.${ext}`), code);
178
+ generatedFiles.push(`src/sw-injector.${ext}`);
179
+ }
180
+ }
181
+ function generateSwGeneratorBuildScript() {
182
+ const outputDir = join(projectRoot, "swoff");
183
+ if (!existsSync(outputDir))
184
+ mkdirSync(outputDir, { recursive: true });
185
+ if (language === "ts") {
186
+ const code = `#!/usr/bin/env node
187
+
188
+ import { spawn } from 'child_process';
189
+ import { existsSync } from 'fs';
190
+ import { join } from 'path';
191
+
192
+ const SWOFF_CONFIG = 'swoff.config.json';
193
+ const DIST_DIR = 'dist';
194
+
195
+ if (!existsSync(SWOFF_CONFIG)) {
196
+ console.error('Error: swoff.config.json not found');
197
+ console.log('Run "npx @swoff/cli init" to create one');
198
+ process.exit(1);
199
+ }
200
+
201
+ console.log('šŸ”§ Building service worker...');
202
+
203
+ const proc = spawn(
204
+ 'npx',
205
+ ['@swoff/cli', 'generate', '--sw-only'],
206
+ { stdio: 'inherit', shell: true }
207
+ );
208
+
209
+ proc.on('close', (code) => {
210
+ if (code === 0) {
211
+ console.log('āœ… Service worker build complete');
212
+ } else {
213
+ console.error('āŒ Build failed');
214
+ process.exit(code || 1);
215
+ }
216
+ });
217
+ `;
218
+ writeFileSync(join(outputDir, `sw-generator.${ext}`), code);
219
+ generatedFiles.push(`swoff/sw-generator.${ext}`);
220
+ }
221
+ else {
222
+ const code = `#!/usr/bin/env node
223
+
224
+ const { spawn } = require('child_process');
225
+ const { existsSync } = require('fs');
226
+ const path = require('path');
227
+
228
+ const SWOFF_CONFIG = 'swoff.config.json';
229
+
230
+ if (!existsSync(SWOFF_CONFIG)) {
231
+ console.error('Error: swoff.config.json not found');
232
+ console.log('Run "npx @swoff/cli init" to create one');
233
+ process.exit(1);
234
+ }
235
+
236
+ console.log('šŸ”§ Building service worker...');
237
+
238
+ const proc = spawn(
239
+ 'npx',
240
+ ['@swoff/cli', 'generate', '--sw-only'],
241
+ { stdio: 'inherit', shell: true }
242
+ );
243
+
244
+ proc.on('close', (code) => {
245
+ if (code === 0) {
246
+ console.log('āœ… Service worker build complete');
247
+ } else {
248
+ console.error('āŒ Build failed');
249
+ process.exit(code || 1);
250
+ }
251
+ });
252
+ `;
253
+ writeFileSync(join(outputDir, `sw-generator.${ext}`), code);
254
+ generatedFiles.push(`swoff/sw-generator.${ext}`);
255
+ }
256
+ }
257
+ function generateSwTemplate() {
258
+ const outputDir = join(projectRoot, "swoff");
259
+ if (!existsSync(outputDir))
260
+ mkdirSync(outputDir, { recursive: true });
261
+ const template = `let CACHE_NAME = "";
262
+ let ASSETS_TO_CACHE = [];
263
+
264
+ // [[INSTALL_HANDLER]]
265
+ // [[ACTIVATE_HANDLER]]
266
+ // [[FETCH_HANDLER]]
267
+
268
+ const SWOFF = {
269
+ cache: {
270
+ async get(key) {
271
+ const cache = await caches.open(CACHE_NAME);
272
+ return cache.match(key);
273
+ },
274
+ async put(request, response) {
275
+ const cache = await caches.open(CACHE_NAME);
276
+ await cache.put(request, response.clone());
277
+ return response;
278
+ },
279
+ async delete(key) {
280
+ const cache = await caches.open(CACHE_NAME);
281
+ return cache.delete(key);
282
+ },
283
+ async keys() {
284
+ const cache = await caches.open(CACHE_NAME);
285
+ return cache.keys();
286
+ }
287
+ }
288
+ };
289
+
290
+ if (typeof self !== 'undefined') {
291
+ self.SWOFF = SWOFF;
292
+ }
293
+ `;
294
+ writeFileSync(join(outputDir, "sw-template.js"), template);
295
+ generatedFiles.push("swoff/sw-template.js");
296
+ }
297
+ function generateTypeDefinitions() {
298
+ if (language !== "ts")
299
+ return;
300
+ const outputDir = join(projectRoot, "src");
301
+ if (!existsSync(outputDir))
302
+ mkdirSync(outputDir, { recursive: true });
303
+ const code = `declare module '*.service-worker' {
304
+ const serviceWorker: ServiceWorkerGlobalScope;
305
+ export default serviceWorker;
306
+ }
307
+
308
+ interface SWOFFCache {
309
+ get(key: Request | string): Promise<Response | undefined>;
310
+ put(request: Request | string, response: Response): Promise<Response>;
311
+ delete(key: Request | string): Promise<boolean>;
312
+ keys(): Promise<Request[]>;
313
+ }
314
+
315
+ interface SWOFF {
316
+ cache: SWOFFCache;
317
+ }
318
+
319
+ declare const SWOFF: SWOFF | undefined;
320
+
321
+ declare let CACHE_NAME: string;
322
+ declare let ASSETS_TO_CACHE: string[];
323
+ `;
324
+ writeFileSync(join(outputDir, "swoff.d.ts"), code);
325
+ generatedFiles.push("src/swoff.d.ts");
326
+ }
327
+ function generateOfflineHooks() {
328
+ const outputDir = join(projectRoot, "src", "hooks");
329
+ if (!existsSync(outputDir))
330
+ mkdirSync(outputDir, { recursive: true });
331
+ const useOffline = language === "ts"
332
+ ? `import { useState, useEffect } from 'react';
333
+
334
+ export interface UseOfflineResult {
335
+ isOnline: boolean;
336
+ isOffline: boolean;
337
+ }
338
+
339
+ export function useOffline(): UseOfflineResult {
340
+ const [isOnline, setIsOnline] = useState(() => navigator.onLine);
341
+
342
+ useEffect(() => {
343
+ const handleOnline = () => setIsOnline(true);
344
+ const handleOffline = () => setIsOnline(false);
345
+
346
+ window.addEventListener('online', handleOnline);
347
+ window.addEventListener('offline', handleOffline);
348
+
349
+ return () => {
350
+ window.removeEventListener('online', handleOnline);
351
+ window.removeEventListener('offline', handleOffline);
352
+ };
353
+ }, []);
354
+
355
+ return { isOnline, isOffline: !isOnline };
356
+ }
357
+ `
358
+ : `import { useState, useEffect } from 'react';
359
+
360
+ export function useOffline() {
361
+ const [isOnline, setIsOnline] = useState(() => navigator.onLine);
362
+
363
+ useEffect(() => {
364
+ const handleOnline = () => setIsOnline(true);
365
+ const handleOffline = () => setIsOnline(false);
366
+
367
+ window.addEventListener('online', handleOnline);
368
+ window.addEventListener('offline', handleOffline);
369
+
370
+ return () => {
371
+ window.removeEventListener('online', handleOnline);
372
+ window.removeEventListener('offline', handleOffline);
373
+ };
374
+ }, []);
375
+
376
+ return { isOnline, isOffline: !isOnline };
377
+ }
378
+ `;
379
+ writeFileSync(join(outputDir, `useOffline.${ext}`), useOffline);
380
+ generatedFiles.push(`src/hooks/useOffline.${ext}`);
381
+ const useApiData = language === "ts"
382
+ ? `import { useState, useEffect, useCallback } from 'react';
383
+
384
+ export interface UseApiDataOptions extends RequestInit {
385
+ skip?: boolean;
386
+ }
387
+
388
+ export interface UseApiDataResult<T> {
389
+ data: T | null;
390
+ loading: boolean;
391
+ error: Error | null;
392
+ refetch: () => Promise<T | null>;
393
+ }
394
+
395
+ export function useApiData<T = unknown>(
396
+ endpoint: string,
397
+ options: UseApiDataOptions = {}
398
+ ): UseApiDataResult<T> {
399
+ const [data, setData] = useState<T | null>(null);
400
+ const [loading, setLoading] = useState(true);
401
+ const [error, setError] = useState<Error | null>(null);
402
+
403
+ const fetchData = useCallback(async (fetchOptions: RequestInit = {}) => {
404
+ try {
405
+ setLoading(true);
406
+ setError(null);
407
+
408
+ const response = await fetch(endpoint, { ...options, ...fetchOptions });
409
+
410
+ if (!response.ok) {
411
+ throw new Error(\`HTTP error! status: \${response.status}\`);
412
+ }
413
+
414
+ const result: T = await response.json();
415
+ setData(result);
416
+ return result;
417
+ } catch (err) {
418
+ const errorMessage = err instanceof Error ? err.message : 'An error occurred';
419
+ setError(new Error(errorMessage));
420
+
421
+ if (!navigator.onLine && data) {
422
+ return data;
423
+ }
424
+
425
+ return null;
426
+ } finally {
427
+ setLoading(false);
428
+ }
429
+ }, [endpoint, JSON.stringify(options), data]);
430
+
431
+ useEffect(() => {
432
+ if (!options.skip) {
433
+ fetchData();
434
+ }
435
+ }, [endpoint]);
436
+
437
+ return { data, loading, error, refetch: fetchData };
438
+ }
439
+ `
440
+ : `import { useState, useEffect, useCallback } from 'react';
441
+
442
+ export function useApiData(endpoint, options = {}) {
443
+ const [data, setData] = useState(null);
444
+ const [loading, setLoading] = useState(true);
445
+ const [error, setError] = useState(null);
446
+
447
+ const fetchData = useCallback(async (fetchOptions = {}) => {
448
+ try {
449
+ setLoading(true);
450
+ setError(null);
451
+
452
+ const response = await fetch(endpoint, { ...options, ...fetchOptions });
453
+
454
+ if (!response.ok) {
455
+ throw new Error(\`HTTP error! status: \${response.status}\`);
456
+ }
457
+
458
+ const result = await response.json();
459
+ setData(result);
460
+ return result;
461
+ } catch (err) {
462
+ const errorMessage = err instanceof Error ? err.message : 'An error occurred';
463
+ setError(new Error(errorMessage));
464
+
465
+ if (!navigator.onLine && data) {
466
+ return data;
467
+ }
468
+
469
+ return null;
470
+ } finally {
471
+ setLoading(false);
472
+ }
473
+ }, [endpoint, JSON.stringify(options), data]);
474
+
475
+ useEffect(() => {
476
+ if (!options.skip) {
477
+ fetchData();
478
+ }
479
+ }, [endpoint]);
480
+
481
+ return { data, loading, error, refetch: fetchData };
482
+ }
483
+ `;
484
+ writeFileSync(join(outputDir, `useApiData.${ext}`), useApiData);
485
+ generatedFiles.push(`src/hooks/useApiData.${ext}`);
486
+ }
487
+ function generateMutationQueueHooks() {
488
+ const outputDir = join(projectRoot, "src", "hooks");
489
+ if (!existsSync(outputDir))
490
+ mkdirSync(outputDir, { recursive: true });
491
+ const useMutationQueue = language === "ts"
492
+ ? `import { useState, useEffect, useCallback } from 'react';
493
+
494
+ export interface Mutation {
495
+ id: string;
496
+ timestamp: number;
497
+ status: 'pending' | 'synced' | 'failed';
498
+ retries: number;
499
+ data: unknown;
500
+ endpoint: string;
501
+ method?: string;
502
+ }
503
+
504
+ export interface UseMutationQueueOptions {
505
+ onSync?: (mutation: Mutation) => Promise<void>;
506
+ maxRetries?: number;
507
+ storageKey?: string;
508
+ autoSync?: boolean;
509
+ }
510
+
511
+ export interface UseMutationQueueResult {
512
+ queueMutation: (mutation: Omit<Mutation, 'id' | 'timestamp' | 'status' | 'retries'>) => string;
513
+ pendingMutations: Mutation[];
514
+ isSyncing: boolean;
515
+ syncMutations: () => Promise<void>;
516
+ clearQueue: () => void;
517
+ retryMutation: (id: string) => Promise<void>;
518
+ }
519
+
520
+ export function useMutationQueue(options: UseMutationQueueOptions = {}): UseMutationQueueResult {
521
+ const {
522
+ onSync,
523
+ maxRetries = 3,
524
+ storageKey = 'swoff-mutation-queue',
525
+ autoSync = true
526
+ } = options;
527
+
528
+ const [pendingMutations, setPendingMutations] = useState<Mutation[]>([]);
529
+ const [isSyncing, setIsSyncing] = useState(false);
530
+
531
+ useEffect(() => {
532
+ try {
533
+ const stored = localStorage.getItem(storageKey);
534
+ if (stored) {
535
+ const parsed = JSON.parse(stored);
536
+ if (Array.isArray(parsed)) {
537
+ setPendingMutations(parsed);
538
+ }
539
+ }
540
+ } catch {
541
+ // ignore
542
+ }
543
+ }, [storageKey]);
544
+
545
+ useEffect(() => {
546
+ try {
547
+ localStorage.setItem(storageKey, JSON.stringify(pendingMutations));
548
+ } catch {
549
+ // ignore
550
+ }
551
+ }, [pendingMutations, storageKey]);
552
+
553
+ const queueMutation = useCallback((
554
+ mutation: Omit<Mutation, 'id' | 'timestamp' | 'status' | 'retries'>
555
+ ): string => {
556
+ const newMutation: Mutation = {
557
+ id: \`\${Date.now()}-\${Math.random().toString(36).substr(2, 9)}\`,
558
+ timestamp: Date.now(),
559
+ status: 'pending',
560
+ retries: 0,
561
+ ...mutation
562
+ };
563
+
564
+ setPendingMutations((prev) => [...prev, newMutation]);
565
+
566
+ if (autoSync && navigator.onLine) {
567
+ syncMutations();
568
+ }
569
+
570
+ return newMutation.id;
571
+ }, [autoSync]);
572
+
573
+ const syncMutations = useCallback(async () => {
574
+ if (isSyncing || !navigator.onLine) return;
575
+
576
+ const pending = pendingMutations.filter((m) => m.status === 'pending');
577
+ if (pending.length === 0) return;
578
+
579
+ setIsSyncing(true);
580
+
581
+ for (const mutation of pending) {
582
+ try {
583
+ if (onSync) {
584
+ await onSync(mutation);
585
+ } else {
586
+ await fetch(mutation.endpoint, {
587
+ method: mutation.method || 'POST',
588
+ headers: { 'Content-Type': 'application/json' },
589
+ body: JSON.stringify(mutation.data),
590
+ });
591
+ }
592
+ setPendingMutations((prev) =>
593
+ prev.map((m) => (m.id === mutation.id ? { ...m, status: 'synced' } : m))
594
+ );
595
+ } catch {
596
+ setPendingMutations((prev) =>
597
+ prev.map((m) =>
598
+ m.id === mutation.id
599
+ ? { ...m, retries: m.retries + 1, status: m.retries >= maxRetries ? 'failed' : 'pending' }
600
+ : m
601
+ )
602
+ );
603
+ }
604
+ }
605
+
606
+ setIsSyncing(false);
607
+ }, [isSyncing, onSync, maxRetries, pendingMutations]);
608
+
609
+ useEffect(() => {
610
+ if (!autoSync) return;
611
+
612
+ const handleOnline = () => syncMutations();
613
+ window.addEventListener('online', handleOnline);
614
+ return () => window.removeEventListener('online', handleOnline);
615
+ }, [syncMutations, autoSync]);
616
+
617
+ const clearQueue = useCallback(() => {
618
+ setPendingMutations([]);
619
+ localStorage.removeItem(storageKey);
620
+ }, [storageKey]);
621
+
622
+ const retryMutation = useCallback(async (id: string) => {
623
+ setPendingMutations((prev) =>
624
+ prev.map((m) => (m.id === id ? { ...m, status: 'pending', retries: 0 } : m))
625
+ );
626
+ await syncMutations();
627
+ }, [syncMutations]);
628
+
629
+ return { queueMutation, pendingMutations, isSyncing, syncMutations, clearQueue, retryMutation };
630
+ }
631
+ `
632
+ : `import { useState, useEffect, useCallback } from 'react';
633
+
634
+ export function useMutationQueue(options = {}) {
635
+ const {
636
+ onSync,
637
+ maxRetries = 3,
638
+ storageKey = 'swoff-mutation-queue',
639
+ autoSync = true
640
+ } = options;
641
+
642
+ const [pendingMutations, setPendingMutations] = useState([]);
643
+ const [isSyncing, setIsSyncing] = useState(false);
644
+
645
+ useEffect(() => {
646
+ try {
647
+ const stored = localStorage.getItem(storageKey);
648
+ if (stored) {
649
+ const parsed = JSON.parse(stored);
650
+ if (Array.isArray(parsed)) {
651
+ setPendingMutations(parsed);
652
+ }
653
+ }
654
+ } catch {
655
+ // ignore
656
+ }
657
+ }, [storageKey]);
658
+
659
+ useEffect(() => {
660
+ try {
661
+ localStorage.setItem(storageKey, JSON.stringify(pendingMutations));
662
+ } catch {
663
+ // ignore
664
+ }
665
+ }, [pendingMutations, storageKey]);
666
+
667
+ const queueMutation = useCallback((mutation) => {
668
+ const newMutation = {
669
+ id: \`\${Date.now()}-\${Math.random().toString(36).substr(2, 9)}\`,
670
+ timestamp: Date.now(),
671
+ status: 'pending',
672
+ retries: 0,
673
+ ...mutation
674
+ };
675
+
676
+ setPendingMutations((prev) => [...prev, newMutation]);
677
+
678
+ if (autoSync && navigator.onLine) {
679
+ syncMutations();
680
+ }
681
+
682
+ return newMutation.id;
683
+ }, [autoSync]);
684
+
685
+ const syncMutations = useCallback(async () => {
686
+ if (isSyncing || !navigator.onLine) return;
687
+
688
+ const pending = pendingMutations.filter((m) => m.status === 'pending');
689
+ if (pending.length === 0) return;
690
+
691
+ setIsSyncing(true);
692
+
693
+ for (const mutation of pending) {
694
+ try {
695
+ if (onSync) {
696
+ await onSync(mutation);
697
+ } else {
698
+ await fetch(mutation.endpoint, {
699
+ method: mutation.method || 'POST',
700
+ headers: { 'Content-Type': 'application/json' },
701
+ body: JSON.stringify(mutation.data),
702
+ });
703
+ }
704
+ setPendingMutations((prev) =>
705
+ prev.map((m) => (m.id === mutation.id ? { ...m, status: 'synced' } : m))
706
+ );
707
+ } catch {
708
+ setPendingMutations((prev) =>
709
+ prev.map((m) =>
710
+ m.id === mutation.id
711
+ ? { ...m, retries: m.retries + 1, status: m.retries >= maxRetries ? 'failed' : 'pending' }
712
+ : m
713
+ )
714
+ );
715
+ }
716
+ }
717
+
718
+ setIsSyncing(false);
719
+ }, [isSyncing, onSync, maxRetries, pendingMutations]);
720
+
721
+ useEffect(() => {
722
+ if (!autoSync) return;
723
+
724
+ const handleOnline = () => syncMutations();
725
+ window.addEventListener('online', handleOnline);
726
+ return () => window.removeEventListener('online', handleOnline);
727
+ }, [syncMutations, autoSync]);
728
+
729
+ const clearQueue = useCallback(() => {
730
+ setPendingMutations([]);
731
+ localStorage.removeItem(storageKey);
732
+ }, [storageKey]);
733
+
734
+ const retryMutation = useCallback(async (id) => {
735
+ setPendingMutations((prev) =>
736
+ prev.map((m) => (m.id === id ? { ...m, status: 'pending', retries: 0 } : m))
737
+ );
738
+ await syncMutations();
739
+ }, [syncMutations]);
740
+
741
+ return { queueMutation, pendingMutations, isSyncing, syncMutations, clearQueue, retryMutation };
742
+ }
743
+ `;
744
+ writeFileSync(join(outputDir, `useMutationQueue.${ext}`), useMutationQueue);
745
+ generatedFiles.push(`src/hooks/useMutationQueue.${ext}`);
746
+ }
747
+ function generatePWAComponents() {
748
+ const outputDir = join(projectRoot, "src", "components");
749
+ if (!existsSync(outputDir))
750
+ mkdirSync(outputDir, { recursive: true });
751
+ const offlineIndicator = `import { useOffline } from '../hooks/useOffline';
752
+
753
+ export interface OfflineIndicatorProps {
754
+ position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
755
+ message?: string;
756
+ className?: string;
757
+ }
758
+
759
+ const positionClasses = {
760
+ 'top-left': 'top-4 left-4',
761
+ 'top-right': 'top-4 right-4',
762
+ 'bottom-left': 'bottom-4 left-4',
763
+ 'bottom-right': 'bottom-4 right-4'
764
+ };
765
+
766
+ export function OfflineIndicator({
767
+ position = 'bottom-right',
768
+ message = 'You are offline. Some features may be limited.',
769
+ className = ''
770
+ }: OfflineIndicatorProps) {
771
+ const { isOffline } = useOffline();
772
+
773
+ if (!isOffline) return null;
774
+
775
+ return (
776
+ <div
777
+ 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}\`}
778
+ role="alert"
779
+ aria-live="polite"
780
+ >
781
+ <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
782
+ <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"/>
783
+ </svg>
784
+ <span className="text-sm font-medium">{message}</span>
785
+ </div>
786
+ );
787
+ }
788
+ `;
789
+ writeFileSync(join(outputDir, "OfflineIndicator.tsx"), offlineIndicator);
790
+ generatedFiles.push("src/components/OfflineIndicator.tsx");
791
+ const pwaInstallButton = `import { useState, useEffect } from 'react';
792
+
793
+ export interface PWAInstallButtonProps {
794
+ installLabel?: string;
795
+ installedLabel?: string;
796
+ className?: string;
797
+ }
798
+
799
+ export function PWAInstallButton({
800
+ installLabel = 'Install App',
801
+ installedLabel = 'Installed',
802
+ className = ''
803
+ }: PWAInstallButtonProps) {
804
+ const [isInstallable, setIsInstallable] = useState(false);
805
+ const [isInstalled, setIsInstalled] = useState(false);
806
+ const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
807
+
808
+ useEffect(() => {
809
+ const handleBeforeInstallPrompt = (e: Event) => {
810
+ e.preventDefault();
811
+ setDeferredPrompt(e as BeforeInstallPromptEvent);
812
+ setIsInstallable(true);
813
+ };
814
+
815
+ const handleAppInstalled = () => {
816
+ setIsInstalled(true);
817
+ setIsInstallable(false);
818
+ };
819
+
820
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
821
+ window.addEventListener('appinstalled', handleAppInstalled);
822
+
823
+ return () => {
824
+ window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
825
+ window.removeEventListener('appinstalled', handleAppInstalled);
826
+ };
827
+ }, []);
828
+
829
+ const handleInstall = async () => {
830
+ if (!deferredPrompt) return;
831
+
832
+ await deferredPrompt.prompt();
833
+ const { outcome } = await deferredPrompt.userChoice;
834
+
835
+ if (outcome === 'accepted') {
836
+ setIsInstalled(true);
837
+ }
838
+ setIsInstallable(false);
839
+ setDeferredPrompt(null);
840
+ };
841
+
842
+ if (!isInstallable && !isInstalled) return null;
843
+
844
+ return (
845
+ <button
846
+ onClick={handleInstall}
847
+ disabled={isInstalled}
848
+ 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}\`}
849
+ >
850
+ {isInstalled ? installedLabel : installLabel}
851
+ </button>
852
+ );
853
+ }
854
+
855
+ interface BeforeInstallPromptEvent extends Event {
856
+ prompt(): Promise<{ outcome: 'accepted' | 'dismissed' }>;
857
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
858
+ }
859
+ `;
860
+ writeFileSync(join(outputDir, "PWAInstallButton.tsx"), pwaInstallButton);
861
+ generatedFiles.push("src/components/PWAInstallButton.tsx");
862
+ }
863
+ function generateCrossTabSync() {
864
+ const outputDir = join(projectRoot, "src", "utils");
865
+ if (!existsSync(outputDir))
866
+ mkdirSync(outputDir, { recursive: true });
867
+ const crossTabSync = language === "ts"
868
+ ? `export class CrossTabSync {
869
+ private channel: BroadcastChannel;
870
+ private listeners: Map<string, Set<(value: unknown, type: string) => void>>;
871
+
872
+ constructor(channelName = 'swoff-sync') {
873
+ this.channel = new BroadcastChannel(channelName);
874
+ this.listeners = new Map();
875
+
876
+ this.channel.onmessage = (event: MessageEvent) => {
877
+ const { key, value, type } = event.data;
878
+ const callbacks = this.listeners.get(key);
879
+ if (callbacks) {
880
+ callbacks.forEach((callback) => callback(value, type));
881
+ }
882
+ };
883
+ }
884
+
885
+ subscribe(
886
+ key: string,
887
+ callback: (value: unknown, type: string) => void
888
+ ): () => void {
889
+ if (!this.listeners.has(key)) {
890
+ this.listeners.set(key, new Set());
891
+ }
892
+ this.listeners.get(key)!.add(callback);
893
+
894
+ return () => {
895
+ this.listeners.get(key)?.delete(callback);
896
+ };
897
+ }
898
+
899
+ set(key: string, value: unknown): void {
900
+ try {
901
+ localStorage.setItem(\`swoff-sync-\${key}\`, JSON.stringify(value));
902
+ } catch {
903
+ // ignore
904
+ }
905
+ this.channel.postMessage({ type: 'set', key, value });
906
+ }
907
+
908
+ get(key: string): unknown {
909
+ try {
910
+ const stored = localStorage.getItem(\`swoff-sync-\${key}\`);
911
+ return stored ? JSON.parse(stored) : null;
912
+ } catch {
913
+ return null;
914
+ }
915
+ }
916
+
917
+ delete(key: string): void {
918
+ localStorage.removeItem(\`swoff-sync-\${key}\`);
919
+ this.channel.postMessage({ type: 'delete', key, value: null });
920
+ }
921
+
922
+ close(): void {
923
+ this.channel.close();
924
+ this.listeners.clear();
925
+ }
926
+ }
927
+
928
+ export function createCrossTabSync(channelName = 'swoff-sync'): CrossTabSync {
929
+ return new CrossTabSync(channelName);
930
+ }
931
+ `
932
+ : `export class CrossTabSync {
933
+ constructor(channelName = 'swoff-sync') {
934
+ this.channel = new BroadcastChannel(channelName);
935
+ this.listeners = new Map();
936
+
937
+ this.channel.onmessage = (event) => {
938
+ const { key, value, type } = event.data;
939
+ const callbacks = this.listeners.get(key);
940
+ if (callbacks) {
941
+ callbacks.forEach((callback) => callback(value, type));
942
+ }
943
+ };
944
+ }
945
+
946
+ subscribe(key, callback) {
947
+ if (!this.listeners.has(key)) {
948
+ this.listeners.set(key, new Set());
949
+ }
950
+ this.listeners.get(key).add(callback);
951
+
952
+ return () => {
953
+ this.listeners.get(key)?.delete(callback);
954
+ };
955
+ }
956
+
957
+ set(key, value) {
958
+ try {
959
+ localStorage.setItem(\`swoff-sync-\${key}\`, JSON.stringify(value));
960
+ } catch {
961
+ // ignore
962
+ }
963
+ this.channel.postMessage({ type: 'set', key, value });
964
+ }
965
+
966
+ get(key) {
967
+ try {
968
+ const stored = localStorage.getItem(\`swoff-sync-\${key}\`);
969
+ return stored ? JSON.parse(stored) : null;
970
+ } catch {
971
+ return null;
972
+ }
973
+ }
974
+
975
+ delete(key) {
976
+ localStorage.removeItem(\`swoff-sync-\${key}\`);
977
+ this.channel.postMessage({ type: 'delete', key, value: null });
978
+ }
979
+
980
+ close() {
981
+ this.channel.close();
982
+ this.listeners.clear();
983
+ }
984
+ }
985
+
986
+ export function createCrossTabSync(channelName = 'swoff-sync') {
987
+ return new CrossTabSync(channelName);
988
+ }
989
+ `;
990
+ writeFileSync(join(outputDir, `crossTabSync.${ext}`), crossTabSync);
991
+ generatedFiles.push(`src/utils/crossTabSync.${ext}`);
992
+ }
993
+ function generateManifest() {
994
+ const outputDir = join(projectRoot, "public");
995
+ if (!existsSync(outputDir))
996
+ mkdirSync(outputDir, { recursive: true });
997
+ const manifest = {
998
+ name: 'Swoff App',
999
+ short_name: 'Swoff',
1000
+ description: 'Offline-first web application',
1001
+ start_url: '/',
1002
+ display: 'standalone',
1003
+ background_color: '#ffffff',
1004
+ theme_color: '#000000',
1005
+ icons: [
1006
+ {
1007
+ src: '/icon-192.png',
1008
+ sizes: '192x192',
1009
+ type: 'image/png',
1010
+ },
1011
+ {
1012
+ src: '/icon-512.png',
1013
+ sizes: '512x512',
1014
+ type: 'image/png',
1015
+ },
1016
+ ],
1017
+ };
1018
+ writeFileSync(join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
1019
+ generatedFiles.push("public/manifest.json");
1020
+ }
1021
+ console.log('šŸ”§ Swoff Files Generator');
1022
+ console.log('========================\n');
1023
+ console.log(`Language: ${language}`);
1024
+ console.log(`Project: ${projectRoot}`);
1025
+ console.log('');
1026
+ if (!config.enabled) {
1027
+ console.log('āš ļø Config generation disabled');
1028
+ process.exit(0);
1029
+ }
1030
+ console.log('šŸ“¦ Generating pattern files...');
1031
+ generateSwInjector();
1032
+ console.log(' • sw-injector');
1033
+ generateSwGeneratorBuildScript();
1034
+ console.log(' • sw-generator build script');
1035
+ generateSwTemplate();
1036
+ console.log(' • sw-template.js');
1037
+ generateTypeDefinitions();
1038
+ console.log(' • swoff.d.ts');
1039
+ if (config.features.offlineReads) {
1040
+ generateOfflineHooks();
1041
+ console.log(' • offline hooks (useOffline, useApiData)');
1042
+ }
1043
+ if (config.features.mutationQueue) {
1044
+ generateMutationQueueHooks();
1045
+ console.log(' • mutation queue hook (useMutationQueue)');
1046
+ }
1047
+ if (config.features.pwa) {
1048
+ generatePWAComponents();
1049
+ console.log(' • PWA components (OfflineIndicator, PWAInstallButton)');
1050
+ generateManifest();
1051
+ console.log(' • manifest.json');
1052
+ }
1053
+ if (config.features.crossTabSync) {
1054
+ generateCrossTabSync();
1055
+ console.log(' • cross-tab sync (crossTabSync)');
1056
+ }
1057
+ console.log('\nāœ… Generated files:');
1058
+ generatedFiles.forEach((file) => console.log(` • ${file}`));
1059
+ console.log("\n✨ Total: " + generatedFiles.length + " files generated");
1060
+ //# sourceMappingURL=swoff-files-generator.js.map