znode-plugin 0.1.4 → 0.1.5
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 +3 -1
- package/lib/next-imports.mjs +105 -41
- package/lib/znode-package.mjs +873 -680
- package/package.json +1 -1
- package/types/extension-api.d.ts +3 -0
package/lib/znode-package.mjs
CHANGED
|
@@ -1,680 +1,873 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// znode-plugin: build, pack and install Znode runtime packages (themes, plugins, widgets, content).
|
|
3
|
-
//
|
|
4
|
-
// znode build <packageDir|manifest.json> build into <packageDir>/dist
|
|
5
|
-
// znode pack <packageDir|manifest.json> build, then zip it as
|
|
6
|
-
// <packageDir>/releases/<name>-<version>.zpkg.zip
|
|
7
|
-
// znode install <zip|packageDir> [storeDir] validate and install into the store's plugin
|
|
8
|
-
// directory (default: RUNTIME_PLUGINS_DIR);
|
|
9
|
-
// the registry entry keeps its settings and stores
|
|
10
|
-
// znode create <theme|plugin|widget|content> <name> [dir]
|
|
11
|
-
// a starter package that builds as it is
|
|
12
|
-
// (default dir ./<name>)
|
|
13
|
-
// znode check <packageDir> marketplace review: metadata, permissions against
|
|
14
|
-
// the code, settings, size, server-render format
|
|
15
|
-
// znode deploy <packageDir> [storeDir] pack, then install into the store's plugin directory
|
|
16
|
-
// znode dev <packageDir> [storeDir] deploy, then again on every change under src/ or to the manifest
|
|
17
|
-
// znode link-store <storeRepoDir> [dir] the store's real types: writes tsconfig.store.json (see README)
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// "
|
|
24
|
-
// "
|
|
25
|
-
// "
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// "
|
|
31
|
-
// "
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// "
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
import
|
|
43
|
-
import
|
|
44
|
-
import {
|
|
45
|
-
import
|
|
46
|
-
import {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// a
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (!
|
|
101
|
-
if (manifest.
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
if (manifest.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
]
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
.
|
|
291
|
-
|
|
292
|
-
files
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
function
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
.
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function
|
|
387
|
-
|
|
388
|
-
if (fs.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if (
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
pack
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// znode-plugin: build, pack and install Znode runtime packages (themes, plugins, widgets, content).
|
|
3
|
+
//
|
|
4
|
+
// znode build <packageDir|manifest.json> build into <packageDir>/dist
|
|
5
|
+
// znode pack <packageDir|manifest.json> build, then zip it as
|
|
6
|
+
// <packageDir>/releases/<name>-<version>.zpkg.zip
|
|
7
|
+
// znode install <zip|packageDir> [storeDir] validate and install into the store's plugin
|
|
8
|
+
// directory (default: RUNTIME_PLUGINS_DIR);
|
|
9
|
+
// the registry entry keeps its settings and stores
|
|
10
|
+
// znode create <theme|plugin|widget|content> <name> [dir]
|
|
11
|
+
// a starter package that builds as it is
|
|
12
|
+
// (default dir ./<name>)
|
|
13
|
+
// znode check <packageDir> marketplace review: metadata, permissions against
|
|
14
|
+
// the code, settings, size, server-render format
|
|
15
|
+
// znode deploy <packageDir> [storeDir] pack, then install into the store's plugin directory
|
|
16
|
+
// znode dev <packageDir> [storeDir] deploy, then again on every change under src/ or to the manifest
|
|
17
|
+
// znode link-store <storeRepoDir> [dir] the store's real types: writes tsconfig.store.json (see README)
|
|
18
|
+
// znode eject [<Name>] [--force] in a package folder: list the store components that can be ejected, or
|
|
19
|
+
// copy one into src/components/ and register it in its slot
|
|
20
|
+
//
|
|
21
|
+
// A package is a folder with znode-package.json (README.md shows a whole project, as create-znode-plugin makes it):
|
|
22
|
+
// {
|
|
23
|
+
// "name": "artifi", "version": "1.0.0", "kind": "plugin" | "theme" | "widget" | "content", "apiVersion": 1,
|
|
24
|
+
// "label": "...", "publisher": "...", "description": "...", "parent": "<theme>", (parent: themes only)
|
|
25
|
+
// "build": { "server": "src/server.ts", "browser": "src/browser.tsx" }, (TypeScript entry points)
|
|
26
|
+
// "styles": ["src/styles/*.css"], "assets": ["src/assets/fonts/*.woff2"], "messages": "src/i18n/labels.json",
|
|
27
|
+
// "pages": { "/": "src/pages/home.json" }, "widgets": ["HeroBanner"],
|
|
28
|
+
// code imports Next.js as an app does: next/image, next/link, next/navigation, next/font/google, react
|
|
29
|
+
// (next-imports.mjs: the store's own modules at runtime; Google fonts downloaded into the package when it builds)
|
|
30
|
+
// "overrides": { "/api/place-order": "place-order" }, (store BFF routes it takes over, by one of its routes)
|
|
31
|
+
// "serverPages": ["/my-page"], (whole pages at new URLs, rendered by its server part's serverPages)
|
|
32
|
+
// "dedupeStyles": true, (styles: drop the rules the store already has; STORE_URL = a page of a running store)
|
|
33
|
+
// "settingsSchema": { "websiteId": { "type": "string", "required": true, "browser": true, "label": "..." },
|
|
34
|
+
// "banner": { "type": "string", "browser": true, "attribute": "StoreBannerText" } },
|
|
35
|
+
// (attribute: the value comes from a Znode global attribute, "Code" for the store's, or
|
|
36
|
+
// { "code": "...", "levels": ["store", "account", "user"] }; the most specific level wins)
|
|
37
|
+
// marketplace: "license", "keywords", "homepage", "compatibility": { "webstore": ">=10.0.0" },
|
|
38
|
+
// "permissions": ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads"] (what it does beyond UI;
|
|
39
|
+
// "uploads": its routes take files, multipart/form-data up to RUNTIME_PLUGIN_UPLOAD_BYTES)
|
|
40
|
+
// "sandbox": true (its server part runs in a separate, permission-limited Node process: routes and overrides only)
|
|
41
|
+
// }
|
|
42
|
+
import fs from "node:fs";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
45
|
+
import { createRequire } from "node:module";
|
|
46
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
47
|
+
import vm from "node:vm";
|
|
48
|
+
import { TEMPLATES } from "./templates.mjs";
|
|
49
|
+
|
|
50
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
const MANIFEST = "znode-package.json";
|
|
52
|
+
// the build tools (TypeScript, rollup, adm-zip, postcss): this package's own dependencies, wherever npm put them
|
|
53
|
+
// (ZNODE_TOOLS: a node_modules folder to use instead)
|
|
54
|
+
const require = process.env.ZNODE_TOOLS ? createRequire(path.join(process.env.ZNODE_TOOLS, "noop.js")) : createRequire(import.meta.url);
|
|
55
|
+
// a dependency's folder (its package.json's), found from what it resolves to
|
|
56
|
+
function packageDir(name) {
|
|
57
|
+
for (let dir = path.dirname(require.resolve(name)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
|
|
58
|
+
const file = path.join(dir, "package.json");
|
|
59
|
+
if (fs.existsSync(file) && JSON.parse(fs.readFileSync(file, "utf8")).name === name) return dir;
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`znode-plugin: ${name} is not installed`);
|
|
62
|
+
}
|
|
63
|
+
const TSC = () => path.join(packageDir("typescript"), "bin", "tsc");
|
|
64
|
+
const ROLLUP = () => path.join(packageDir("rollup"), "dist", "bin", "rollup");
|
|
65
|
+
const AdmZip = require("adm-zip");
|
|
66
|
+
|
|
67
|
+
const API_VERSION = 1;
|
|
68
|
+
const KINDS = ["theme", "plugin", "widget", "content"];
|
|
69
|
+
const NAME = /^[a-z0-9][a-z0-9-]{1,48}$/;
|
|
70
|
+
const VERSION = /^\d+\.\d+\.\d+(-[0-9a-z.-]+)?$/i;
|
|
71
|
+
const SERVED = new Set([".mjs", ".map", ".css", ".woff2", ".woff", ".ttf", ".otf", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".json"]);
|
|
72
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
73
|
+
const SETTING_TYPES = ["string", "number", "boolean", "list", "json", "object", "array"];
|
|
74
|
+
const ATTRIBUTE_LEVELS = ["store", "account", "user"];
|
|
75
|
+
const ATTRIBUTE_CODE = /^[A-Za-z][A-Za-z0-9_]{1,99}$/;
|
|
76
|
+
const PERMISSIONS = ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads"];
|
|
77
|
+
|
|
78
|
+
const fail = (message) => {
|
|
79
|
+
console.error(`znode-package: ${message}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
};
|
|
82
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
83
|
+
const glob = (dir, pattern) => {
|
|
84
|
+
const folder = path.join(dir, path.dirname(pattern));
|
|
85
|
+
const re = new RegExp(
|
|
86
|
+
`^${path
|
|
87
|
+
.basename(pattern)
|
|
88
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
89
|
+
.replace(/\*/g, ".*")}$`
|
|
90
|
+
);
|
|
91
|
+
return fs.existsSync(folder)
|
|
92
|
+
? fs
|
|
93
|
+
.readdirSync(folder)
|
|
94
|
+
.filter((name) => re.test(name))
|
|
95
|
+
.map((name) => path.join(folder, name))
|
|
96
|
+
: [];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function validateManifest(manifest, where) {
|
|
100
|
+
if (!NAME.test(manifest.name || "")) fail(`${where}: name must be lowercase letters, digits and dashes`);
|
|
101
|
+
if (!VERSION.test(manifest.version || "")) fail(`${where}: version must be semver (1.2.3)`);
|
|
102
|
+
if (!KINDS.includes(manifest.kind)) fail(`${where}: kind must be one of ${KINDS.join(", ")}`);
|
|
103
|
+
if (manifest.apiVersion !== API_VERSION) fail(`${where}: apiVersion ${manifest.apiVersion} isn't supported (this store runs ${API_VERSION})`);
|
|
104
|
+
if (manifest.parent && manifest.kind !== "theme") fail(`${where}: only a theme has a parent`);
|
|
105
|
+
// whole pages its server part renders (IRuntimePlugin.serverPages), at new lowercase URLs the store doesn't use
|
|
106
|
+
if (manifest.serverPages !== undefined) {
|
|
107
|
+
if (!Array.isArray(manifest.serverPages)) fail(`${where}: serverPages must be a list of paths ("/my-page")`);
|
|
108
|
+
// the source manifest names the entry (build.server), the built one the file (files.server)
|
|
109
|
+
if (manifest.serverPages.length && !manifest.build?.server && !manifest.files?.server) fail(`${where}: serverPages need a server part (build.server)`);
|
|
110
|
+
for (const page of manifest.serverPages) {
|
|
111
|
+
if (!/^\/[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/.test(String(page))) fail(`${where}: serverPages "${page}" must be a lowercase path like /my-page`);
|
|
112
|
+
if (/^\/(api|runtime-page|_next|login|signup|account|cart|checkout|search|product|category|404|error|maintenance)(\/|$)/.test(page)) fail(`${where}: serverPages "${page}" is a store route`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// store BFF routes the package takes over: "/api/<path>" -> one of its own routes
|
|
116
|
+
for (const [route, target] of Object.entries(manifest.overrides || {})) {
|
|
117
|
+
if (!/^\/api\/[A-Za-z0-9/_-]+$/.test(route) || /^\/api\/extensions(\/|$)/.test(route)) fail(`${where}: override "${route}" must be a store route under /api/`);
|
|
118
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(String(target))) fail(`${where}: override "${route}" must name one of the package's routes`);
|
|
119
|
+
}
|
|
120
|
+
for (const [key, field] of Object.entries(manifest.settingsSchema || {})) {
|
|
121
|
+
if (field?.type !== undefined && !SETTING_TYPES.includes(field.type)) fail(`${where}: setting "${key}" has type "${field.type}" (one of ${SETTING_TYPES.join(", ")})`);
|
|
122
|
+
const attribute = field?.attribute;
|
|
123
|
+
if (attribute === undefined) continue;
|
|
124
|
+
const code = typeof attribute === "string" ? attribute : attribute?.code;
|
|
125
|
+
if (!ATTRIBUTE_CODE.test(String(code || ""))) fail(`${where}: setting "${key}" names attribute "${code}" (letters, digits and _, as Znode attribute codes)`);
|
|
126
|
+
const levels = typeof attribute === "string" ? ["store"] : attribute.levels || ["store"];
|
|
127
|
+
if (!Array.isArray(levels) || !levels.length || levels.some((level) => !ATTRIBUTE_LEVELS.includes(level))) {
|
|
128
|
+
fail(`${where}: setting "${key}" attribute levels must be some of ${ATTRIBUTE_LEVELS.join(", ")}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (manifest.sandbox !== undefined && typeof manifest.sandbox !== "boolean") fail(`${where}: sandbox must be true or false`);
|
|
132
|
+
if (manifest.permissions !== undefined && (!Array.isArray(manifest.permissions) || manifest.permissions.some((item) => !PERMISSIONS.includes(item)))) {
|
|
133
|
+
fail(`${where}: permissions must be some of ${PERMISSIONS.join(", ")}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- build: TypeScript entry points -> one ES module each (tsc + rollup), plus styles, assets, labels, pages ----
|
|
138
|
+
// a package folder (its znode-package.json) or one of several manifests in a folder (<name>.znode-package.json)
|
|
139
|
+
function resolvePackage(target) {
|
|
140
|
+
const isFile = fs.statSync(target).isFile();
|
|
141
|
+
const dir = isFile ? path.dirname(target) : target;
|
|
142
|
+
const manifestFile = isFile ? target : path.join(dir, MANIFEST);
|
|
143
|
+
return { dir, manifestFile, outName: path.basename(manifestFile) === MANIFEST ? "dist" : `dist-${path.basename(manifestFile, `.${MANIFEST}`)}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The package's tsconfig check. tsc also checks the store files the package imports; their errors are the store's, not
|
|
147
|
+
// the package's: listed as warnings. An error in the package's own files stops the build.
|
|
148
|
+
function typeCheck(dir, tsconfigFile) {
|
|
149
|
+
let output = "";
|
|
150
|
+
try {
|
|
151
|
+
execFileSync(process.execPath, [TSC(), "-p", tsconfigFile, "--noEmit", "--pretty", "false"], {
|
|
152
|
+
cwd: dir,
|
|
153
|
+
encoding: "utf8",
|
|
154
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
output = `${error.stdout || ""}${error.stderr || ""}`;
|
|
159
|
+
}
|
|
160
|
+
// one entry per error: "file(line,col): error TSnnnn: message" and its indented continuation lines
|
|
161
|
+
const errors = output.split(/\r?\n(?=\S)/).filter((entry) => /^.+?\(\d+,\d+\): error TS\d+:/.test(entry));
|
|
162
|
+
if (!errors.length) fail(`tsconfig check failed:\n${output}`);
|
|
163
|
+
// inside the package folder: a relative path that does not climb out (on Windows a file on another drive comes back
|
|
164
|
+
// absolute from path.relative, which is outside too)
|
|
165
|
+
const inPackage = (entry) => {
|
|
166
|
+
const relative = path.relative(dir, path.resolve(dir, entry.slice(0, entry.indexOf("("))));
|
|
167
|
+
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
168
|
+
};
|
|
169
|
+
const own = errors.filter(inPackage);
|
|
170
|
+
const store = errors.filter((entry) => !inPackage(entry));
|
|
171
|
+
if (store.length) console.log(`warning ${store.length} type error(s) in store files the package imports (the store's to fix):\n ${store.join("\n ")}`);
|
|
172
|
+
if (own.length) fail(`type errors in the package:\n ${own.join("\n ")}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// every .ts / .tsx under srcRoot compiled to .js under outRoot, one file at a time and without resolving imports (the
|
|
176
|
+
// tsconfig check has done the types): types removed, JSX as Next.js compiles it (react/jsx-runtime)
|
|
177
|
+
function transpileSources(srcRoot, outRoot) {
|
|
178
|
+
const ts = require("typescript");
|
|
179
|
+
const walk = (folder) =>
|
|
180
|
+
fs.readdirSync(folder, { withFileTypes: true }).flatMap((item) => {
|
|
181
|
+
const file = path.join(folder, item.name);
|
|
182
|
+
if (item.isDirectory()) return walk(file);
|
|
183
|
+
return /\.tsx?$/.test(item.name) && !item.name.endsWith(".d.ts") ? [file] : [];
|
|
184
|
+
});
|
|
185
|
+
for (const file of walk(srcRoot)) {
|
|
186
|
+
const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
|
187
|
+
fileName: file,
|
|
188
|
+
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022, jsx: ts.JsxEmit.ReactJSX, jsxImportSource: "react" },
|
|
189
|
+
});
|
|
190
|
+
const target = path.join(outRoot, path.relative(srcRoot, file)).replace(/\.tsx?$/, ".js");
|
|
191
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
192
|
+
fs.writeFileSync(target, outputText);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The store the package is linked to (tsconfig.store.json, written by link-store): its root, its @znode paths and its
|
|
197
|
+
// Generated Store Module Registry (packages/extensions/store-module-registry.json), for the package's store imports
|
|
198
|
+
function linkedStore(dir) {
|
|
199
|
+
const file = path.join(dir, "tsconfig.store.json");
|
|
200
|
+
if (!fs.existsSync(file)) return null;
|
|
201
|
+
const { compilerOptions = {} } = readJson(file);
|
|
202
|
+
if (!compilerOptions.baseUrl) return null;
|
|
203
|
+
const root = path.resolve(dir, compilerOptions.baseUrl);
|
|
204
|
+
const registryFile = path.join(root, "packages/extensions/store-module-registry.json");
|
|
205
|
+
const registry = fs.existsSync(registryFile) ? readJson(registryFile).modules : null;
|
|
206
|
+
return { root, paths: compilerOptions.paths || {}, registry };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function build(target) {
|
|
210
|
+
const { dir, manifestFile, outName } = resolvePackage(target);
|
|
211
|
+
const manifest = readJson(manifestFile);
|
|
212
|
+
validateManifest(manifest, path.basename(manifestFile));
|
|
213
|
+
const out = path.join(dir, outName);
|
|
214
|
+
fs.rmSync(out, { recursive: true, force: true });
|
|
215
|
+
fs.mkdirSync(out, { recursive: true });
|
|
216
|
+
const tmp = path.join(dir, ".build");
|
|
217
|
+
const files = {};
|
|
218
|
+
const prefix = `${manifest.name}-${manifest.version}`;
|
|
219
|
+
// next/font/google fonts built into the package (next-imports.mjs): their files and @font-face CSS
|
|
220
|
+
const fonts = [];
|
|
221
|
+
// the store modules its browser part imports (Generated Store Module Registry keys), for the store to load first
|
|
222
|
+
const storeImports = [];
|
|
223
|
+
const store = linkedStore(dir);
|
|
224
|
+
// A package with its own tsconfig.json (extending the store's tsconfig.base.json) is checked against the store's real
|
|
225
|
+
// types (every @znode alias, no declarations to write), then compiled file by file without type information, JSX as
|
|
226
|
+
// Next.js does (react/jsx-runtime: the store's). Without one, the package compiles as before (tsc, JSX factory h).
|
|
227
|
+
const tsconfigFile = path.join(dir, "tsconfig.json");
|
|
228
|
+
const projectMode = fs.existsSync(tsconfigFile);
|
|
229
|
+
if (projectMode && Object.values(manifest.build || {}).length) typeCheck(dir, tsconfigFile);
|
|
230
|
+
|
|
231
|
+
for (const part of ["server", "browser"]) {
|
|
232
|
+
const entry = manifest.build?.[part];
|
|
233
|
+
if (!entry) continue;
|
|
234
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
235
|
+
if (projectMode) transpileSources(path.dirname(path.join(dir, entry)), tmp);
|
|
236
|
+
else execFileSync(
|
|
237
|
+
process.execPath,
|
|
238
|
+
[
|
|
239
|
+
TSC(),
|
|
240
|
+
"--target",
|
|
241
|
+
"es2022",
|
|
242
|
+
"--module",
|
|
243
|
+
"es2022",
|
|
244
|
+
"--moduleResolution",
|
|
245
|
+
"bundler",
|
|
246
|
+
"--lib",
|
|
247
|
+
"es2022,dom",
|
|
248
|
+
"--skipLibCheck",
|
|
249
|
+
// import React from "react", as the store's own components do
|
|
250
|
+
"--allowSyntheticDefaultImports",
|
|
251
|
+
"--jsx",
|
|
252
|
+
"react",
|
|
253
|
+
"--jsxFactory",
|
|
254
|
+
"h",
|
|
255
|
+
"--jsxFragmentFactory",
|
|
256
|
+
"Fragment",
|
|
257
|
+
"--outDir",
|
|
258
|
+
tmp,
|
|
259
|
+
path.join(dir, entry),
|
|
260
|
+
],
|
|
261
|
+
{ stdio: "inherit" }
|
|
262
|
+
);
|
|
263
|
+
const compiled = path.join(tmp, path.basename(entry).replace(/\.tsx?$/, ".js"));
|
|
264
|
+
const target = `${prefix}.${part}.mjs`;
|
|
265
|
+
// rollup with the Next.js imports plugin (next-imports.mjs), from a config file: the plugin downloads fonts, which
|
|
266
|
+
// is async, in rollup's own process; what it built comes back in fonts.json
|
|
267
|
+
const config = path.join(tmp, "rollup.config.mjs");
|
|
268
|
+
const fontsFile = path.join(tmp, "fonts.json");
|
|
269
|
+
const storeImportsFile = path.join(tmp, "store-imports.json");
|
|
270
|
+
fs.writeFileSync(
|
|
271
|
+
config,
|
|
272
|
+
[
|
|
273
|
+
`import fs from "node:fs";`,
|
|
274
|
+
`import { znodeNext } from ${JSON.stringify(pathToFileURL(path.join(HERE, "next-imports.mjs")).href)};`,
|
|
275
|
+
`const fonts = [];`,
|
|
276
|
+
`const storeImports = [];`,
|
|
277
|
+
`const store = ${JSON.stringify(store)};`,
|
|
278
|
+
`if (store?.registry) store.registry = new Set(store.registry);`,
|
|
279
|
+
`export default {`,
|
|
280
|
+
` input: ${JSON.stringify(compiled)},`,
|
|
281
|
+
` output: { file: ${JSON.stringify(path.join(out, target))}, format: "es", sourcemap: true },`,
|
|
282
|
+
` plugins: [`,
|
|
283
|
+
` znodeNext({ part: ${JSON.stringify(part)}, prefix: ${JSON.stringify(prefix)}, outDir: ${JSON.stringify(out)}, fonts, store, storeImports }),`,
|
|
284
|
+
` { name: "fonts-out", writeBundle() { fs.writeFileSync(${JSON.stringify(fontsFile)}, JSON.stringify(fonts)); fs.writeFileSync(${JSON.stringify(storeImportsFile)}, JSON.stringify(storeImports)); } },`,
|
|
285
|
+
` ],`,
|
|
286
|
+
`};`,
|
|
287
|
+
].join("\n")
|
|
288
|
+
);
|
|
289
|
+
execFileSync(process.execPath, [ROLLUP(), "--config", config, "--silent"], { stdio: "inherit" });
|
|
290
|
+
if (fs.existsSync(fontsFile)) fonts.push(...JSON.parse(fs.readFileSync(fontsFile, "utf8")));
|
|
291
|
+
if (fs.existsSync(storeImportsFile)) storeImports.push(...JSON.parse(fs.readFileSync(storeImportsFile, "utf8")));
|
|
292
|
+
files[part] = target;
|
|
293
|
+
}
|
|
294
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
295
|
+
|
|
296
|
+
// styles: concatenated into one stylesheet, after the fonts next/font/google built; assets (fonts, images) keep their
|
|
297
|
+
// names, which the stylesheet refers to
|
|
298
|
+
if (manifest.styles?.length || fonts.length) {
|
|
299
|
+
const css = [
|
|
300
|
+
...fonts.map((font) => font.css),
|
|
301
|
+
...(manifest.styles || []).flatMap((pattern) => glob(dir, pattern)).map((file) => fs.readFileSync(file, "utf8")),
|
|
302
|
+
].join("\n");
|
|
303
|
+
files.styles = [`${prefix}.css`];
|
|
304
|
+
fs.writeFileSync(path.join(out, files.styles[0]), css);
|
|
305
|
+
// rules the store's own stylesheets already have, loaded again later, would beat its responsive variants
|
|
306
|
+
if (manifest.dedupeStyles) {
|
|
307
|
+
const target = path.join(out, files.styles[0]);
|
|
308
|
+
execFileSync(process.execPath, [path.join(HERE, "css-dedupe.mjs"), target, process.env.STORE_URL || "http://staplespromo.localhost:3000/", target], { stdio: "inherit" });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
files.assets = (manifest.assets || [])
|
|
312
|
+
.flatMap((pattern) => glob(dir, pattern))
|
|
313
|
+
.map((file) => {
|
|
314
|
+
fs.copyFileSync(file, path.join(out, path.basename(file)));
|
|
315
|
+
return path.basename(file);
|
|
316
|
+
})
|
|
317
|
+
// the font files next/font/google downloaded, already in dist
|
|
318
|
+
.concat(fonts.flatMap((font) => font.files));
|
|
319
|
+
if (manifest.messages) {
|
|
320
|
+
files.messages = `${prefix}.labels.json`;
|
|
321
|
+
fs.copyFileSync(path.join(dir, manifest.messages), path.join(out, files.messages));
|
|
322
|
+
}
|
|
323
|
+
if (manifest.pages) {
|
|
324
|
+
files.pages = Object.fromEntries(
|
|
325
|
+
Object.entries(manifest.pages).map(([route, file], index) => {
|
|
326
|
+
const target = `${prefix}.page${index}.json`;
|
|
327
|
+
fs.copyFileSync(path.join(dir, file), path.join(out, target));
|
|
328
|
+
return [route, target];
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const { build: _build, styles: _styles, assets: _assets, dedupeStyles: _dedupe, ...rest } = manifest;
|
|
333
|
+
fs.writeFileSync(path.join(out, MANIFEST), JSON.stringify({ ...rest, files, ...(storeImports.length ? { storeImports: [...new Set(storeImports)].sort() } : {}) }, null, 2) + "\n");
|
|
334
|
+
console.log(`built ${manifest.kind} ${manifest.name}@${manifest.version}: ${fs.readdirSync(out).length} files`);
|
|
335
|
+
return { manifest, out };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function pack(target) {
|
|
339
|
+
const { manifest, out } = build(target);
|
|
340
|
+
const { dir } = resolvePackage(target);
|
|
341
|
+
const zip = new AdmZip();
|
|
342
|
+
for (const name of fs.readdirSync(out)) zip.addLocalFile(path.join(out, name));
|
|
343
|
+
fs.mkdirSync(path.join(dir, "releases"), { recursive: true });
|
|
344
|
+
const file = path.join(dir, "releases", `${manifest.name}-${manifest.version}.zpkg.zip`);
|
|
345
|
+
zip.writeZip(file);
|
|
346
|
+
console.log(`packed ${path.basename(file)} (${fs.statSync(file).size} bytes)`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---- install: validate, copy into the plugin directory, upsert the registry entry -------------------------------
|
|
350
|
+
// Installs into one folder can run at the same time (several pipelines, an admin upload): each takes the folder's lock
|
|
351
|
+
// (an atomic mkdir) for the read-change-write of plugins.json, writes it atomically (a temp file renamed over it, so the
|
|
352
|
+
// running webstore never reads half a file) and keeps the previous registry in .history/ (the last HISTORY_KEEP).
|
|
353
|
+
const LOCK_WAIT_MS = 60000;
|
|
354
|
+
const LOCK_STALE_MS = 120000;
|
|
355
|
+
const HISTORY_KEEP = 50;
|
|
356
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
357
|
+
|
|
358
|
+
function lockRegistry(storeDir) {
|
|
359
|
+
fs.mkdirSync(storeDir, { recursive: true });
|
|
360
|
+
const lock = path.join(storeDir, ".plugins.lock");
|
|
361
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
362
|
+
for (;;) {
|
|
363
|
+
try {
|
|
364
|
+
fs.mkdirSync(lock);
|
|
365
|
+
break;
|
|
366
|
+
} catch (error) {
|
|
367
|
+
if (error.code !== "EEXIST") throw error;
|
|
368
|
+
// a lock left by a crashed install
|
|
369
|
+
try {
|
|
370
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.rmSync(lock, { recursive: true, force: true });
|
|
371
|
+
} catch {
|
|
372
|
+
// removed meanwhile
|
|
373
|
+
}
|
|
374
|
+
if (Date.now() > deadline) fail(`the registry in ${storeDir} is locked by another install (remove ${lock} if none is running)`);
|
|
375
|
+
sleep(25 + Math.floor(Math.random() * 50));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const release = () => fs.rmSync(lock, { recursive: true, force: true });
|
|
379
|
+
process.on("exit", release);
|
|
380
|
+
return () => {
|
|
381
|
+
release();
|
|
382
|
+
process.off("exit", release);
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function writeRegistry(registryFile, registry) {
|
|
387
|
+
const dir = path.dirname(registryFile);
|
|
388
|
+
if (fs.existsSync(registryFile)) {
|
|
389
|
+
const history = path.join(dir, ".history");
|
|
390
|
+
fs.mkdirSync(history, { recursive: true });
|
|
391
|
+
fs.copyFileSync(registryFile, path.join(history, `plugins-${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}.json`));
|
|
392
|
+
const old = fs
|
|
393
|
+
.readdirSync(history)
|
|
394
|
+
.filter((name) => name.startsWith("plugins-"))
|
|
395
|
+
.sort();
|
|
396
|
+
for (const name of old.slice(0, Math.max(0, old.length - HISTORY_KEEP))) fs.rmSync(path.join(history, name), { force: true });
|
|
397
|
+
}
|
|
398
|
+
const temp = `${registryFile}.tmp-${process.pid}`;
|
|
399
|
+
fs.writeFileSync(temp, JSON.stringify(registry, null, 2) + "\n");
|
|
400
|
+
fs.renameSync(temp, registryFile);
|
|
401
|
+
}
|
|
402
|
+
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
403
|
+
|
|
404
|
+
// the store's settings over the package defaults; an object setting (labels, panels) keeps the store's values and
|
|
405
|
+
// gains the keys a new version adds
|
|
406
|
+
function mergeSettings(defaults, previous) {
|
|
407
|
+
const merged = { ...defaults, ...previous };
|
|
408
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
409
|
+
if (isPlainObject(value) && isPlainObject(previous[key])) merged[key] = { ...value, ...previous[key] };
|
|
410
|
+
}
|
|
411
|
+
return merged;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function install(source, storeDir) {
|
|
415
|
+
let entries;
|
|
416
|
+
if (fs.statSync(source).isDirectory()) {
|
|
417
|
+
// a package folder's build output (dist; package in packages built before the dist folder)
|
|
418
|
+
const built = ["dist", "package"].map((name) => path.join(source, name)).find((dir) => fs.existsSync(path.join(dir, MANIFEST)));
|
|
419
|
+
const dir = built || source;
|
|
420
|
+
entries = fs.readdirSync(dir).map((name) => ({ name, data: fs.readFileSync(path.join(dir, name)) }));
|
|
421
|
+
} else {
|
|
422
|
+
entries = new AdmZip(source)
|
|
423
|
+
.getEntries()
|
|
424
|
+
.filter((entry) => !entry.isDirectory)
|
|
425
|
+
.map((entry) => ({ name: entry.entryName, data: entry.getData() }));
|
|
426
|
+
}
|
|
427
|
+
const manifestEntry = entries.find((entry) => entry.name === "znode-package.json");
|
|
428
|
+
if (!manifestEntry) fail("no znode-package.json in the package");
|
|
429
|
+
const manifest = JSON.parse(manifestEntry.data.toString("utf8"));
|
|
430
|
+
validateManifest(manifest, "package");
|
|
431
|
+
|
|
432
|
+
const total = entries.reduce((sum, entry) => sum + entry.data.length, 0);
|
|
433
|
+
if (total > MAX_BYTES) fail(`package is ${total} bytes; the limit is ${MAX_BYTES}`);
|
|
434
|
+
const files = entries.filter((entry) => entry.name !== "znode-package.json");
|
|
435
|
+
for (const entry of files) {
|
|
436
|
+
if (entry.name !== path.basename(entry.name)) fail(`"${entry.name}": a package is flat, no folders`);
|
|
437
|
+
if (!SERVED.has(path.extname(entry.name).toLowerCase())) fail(`"${entry.name}": file type not allowed`);
|
|
438
|
+
}
|
|
439
|
+
const has = (name) => files.some((entry) => entry.name === name);
|
|
440
|
+
const referenced = [
|
|
441
|
+
manifest.files?.server,
|
|
442
|
+
manifest.files?.browser,
|
|
443
|
+
manifest.files?.messages,
|
|
444
|
+
...(manifest.files?.styles || []),
|
|
445
|
+
...Object.values(manifest.files?.pages || {}),
|
|
446
|
+
].filter(Boolean);
|
|
447
|
+
for (const name of referenced) if (!has(name)) fail(`znode-package.json names "${name}", which isn't in the package`);
|
|
448
|
+
|
|
449
|
+
const registryFile = path.join(storeDir, "plugins.json");
|
|
450
|
+
const unlock = lockRegistry(storeDir);
|
|
451
|
+
const registry = fs.existsSync(registryFile) ? readJson(registryFile) : { plugins: [] };
|
|
452
|
+
// a package never replaces a file another installed package uses
|
|
453
|
+
const owners = new Map();
|
|
454
|
+
for (const item of registry.plugins) {
|
|
455
|
+
if (item.name === manifest.name) continue;
|
|
456
|
+
for (const name of [item.file, item.browser, item.messages, ...(item.styles || []), ...Object.values(item.pages || {})]) if (name) owners.set(name, item.name);
|
|
457
|
+
}
|
|
458
|
+
for (const entry of files) if (owners.has(entry.name)) fail(`"${entry.name}" belongs to package ${owners.get(entry.name)}`);
|
|
459
|
+
|
|
460
|
+
fs.mkdirSync(storeDir, { recursive: true });
|
|
461
|
+
for (const entry of files) fs.writeFileSync(path.join(storeDir, entry.name), entry.data);
|
|
462
|
+
|
|
463
|
+
const previous = registry.plugins.find((item) => item.name === manifest.name);
|
|
464
|
+
const defaults = Object.fromEntries(
|
|
465
|
+
Object.entries(manifest.settingsSchema || {})
|
|
466
|
+
.filter(([, field]) => field.default !== undefined)
|
|
467
|
+
.map(([key, field]) => [key, field.default])
|
|
468
|
+
);
|
|
469
|
+
const entry = {
|
|
470
|
+
name: manifest.name,
|
|
471
|
+
kind: manifest.kind === "widget" ? "plugin" : manifest.kind,
|
|
472
|
+
version: manifest.version,
|
|
473
|
+
label: manifest.label,
|
|
474
|
+
publisher: manifest.publisher,
|
|
475
|
+
...(manifest.parent ? { parent: manifest.parent } : {}),
|
|
476
|
+
...(manifest.files?.server ? { file: manifest.files.server } : {}),
|
|
477
|
+
...(manifest.files?.browser ? { browser: manifest.files.browser } : {}),
|
|
478
|
+
...(manifest.files?.styles?.length ? { styles: manifest.files.styles } : {}),
|
|
479
|
+
...(manifest.files?.messages ? { messages: manifest.files.messages } : {}),
|
|
480
|
+
...(manifest.files?.pages ? { pages: manifest.files.pages } : {}),
|
|
481
|
+
...(manifest.widgets?.length ? { widgets: manifest.widgets } : {}),
|
|
482
|
+
// the store modules its browser bundle imports: the store loads them first (Generated Store Module Registry)
|
|
483
|
+
...(Array.isArray(manifest.storeImports) && manifest.storeImports.length ? { storeImports: manifest.storeImports.map(String) } : {}),
|
|
484
|
+
...(manifest.overrides ? { overrides: manifest.overrides } : {}),
|
|
485
|
+
...(manifest.serverPages?.length ? { serverPages: manifest.serverPages } : {}),
|
|
486
|
+
...(manifest.sandbox === true ? { sandbox: true } : {}),
|
|
487
|
+
// the webstore accepts files (multipart) only for a package that declares them
|
|
488
|
+
...((manifest.permissions || []).includes("uploads") ? { uploads: true } : {}),
|
|
489
|
+
...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}),
|
|
490
|
+
// where it runs and its settings are the store owner's: an upgrade keeps them; a new package starts switched off
|
|
491
|
+
enabled: previous ? previous.enabled : false,
|
|
492
|
+
stores: previous ? previous.stores : [],
|
|
493
|
+
settings: mergeSettings(defaults, previous?.settings || {}),
|
|
494
|
+
};
|
|
495
|
+
if (previous) registry.plugins[registry.plugins.indexOf(previous)] = entry;
|
|
496
|
+
else registry.plugins.push(entry);
|
|
497
|
+
writeRegistry(registryFile, registry);
|
|
498
|
+
// the replaced version's files (an upgrade's old bundles and stylesheets) leave the plugin directory
|
|
499
|
+
const kept = new Set(files.map((file) => file.name));
|
|
500
|
+
for (const name of [previous?.file, previous?.browser, previous?.messages, ...(previous?.styles || []), ...Object.values(previous?.pages || {})]) {
|
|
501
|
+
if (name && !kept.has(name) && name === path.basename(name)) fs.rmSync(path.join(storeDir, name), { force: true });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
unlock();
|
|
505
|
+
|
|
506
|
+
const missing = Object.entries(manifest.settingsSchema || {}).filter(([key, field]) => field.required && (entry.settings[key] === undefined || entry.settings[key] === ""));
|
|
507
|
+
console.log(
|
|
508
|
+
`installed ${manifest.kind} ${manifest.name}@${manifest.version}${previous ? ` (was ${previous.version || "unversioned"})` : ""}: ${files.length} files, ${entry.enabled ? "enabled" : "disabled"} for ${JSON.stringify(entry.stores)}`
|
|
509
|
+
);
|
|
510
|
+
if (missing.length) console.log(` settings to fill in before enabling: ${missing.map(([key]) => key).join(", ")}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---- create: a starter package of a kind (templates.mjs), with the API typings next to its sources ---------------
|
|
514
|
+
function create(kind, name, dir) {
|
|
515
|
+
if (!TEMPLATES[kind]) fail(`kind must be one of ${Object.keys(TEMPLATES).join(", ")}`);
|
|
516
|
+
if (!NAME.test(name || "")) fail("name must be lowercase letters, digits and dashes (2 to 49)");
|
|
517
|
+
const target = path.resolve(dir || path.join(process.cwd(), name));
|
|
518
|
+
if (fs.existsSync(target) && fs.readdirSync(target).length) fail(`${target} exists and is not empty`);
|
|
519
|
+
for (const [file, content] of Object.entries(TEMPLATES[kind](name))) {
|
|
520
|
+
const full = path.join(target, file);
|
|
521
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
522
|
+
fs.writeFileSync(full, typeof content === "string" ? content : JSON.stringify(content, null, 2) + "\n");
|
|
523
|
+
}
|
|
524
|
+
if (kind !== "content") fs.copyFileSync(path.join(HERE, "..", "types", "extension-api.d.ts"), path.join(target, "src", "extension-api.d.ts"));
|
|
525
|
+
console.log(`created ${kind} ${name} in ${target}`);
|
|
526
|
+
console.log(" next: fill in label, publisher and description in znode-package.json, then run its build-and-upload.sh");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---- check: what a marketplace review looks at, from the package's own files ------------------------------------
|
|
530
|
+
const HOOK_NAMES = ["onCartChanged", "beforeAddToCart", "afterAddToCart", "mapCartItems"];
|
|
531
|
+
|
|
532
|
+
// the code an entry pulls in: the entry and the files it imports by relative path, followed (a package's server and
|
|
533
|
+
// browser parts share a folder, so a folder read would mix them); type-only imports are skipped
|
|
534
|
+
function sourceOf(dir, entry) {
|
|
535
|
+
if (!entry) return "";
|
|
536
|
+
const seen = new Set();
|
|
537
|
+
const sources = [];
|
|
538
|
+
const visit = (file) => {
|
|
539
|
+
if (seen.has(file) || !fs.existsSync(file)) return;
|
|
540
|
+
seen.add(file);
|
|
541
|
+
const code = fs.readFileSync(file, "utf8");
|
|
542
|
+
sources.push(code);
|
|
543
|
+
for (const [, typeOnly, spec] of code.matchAll(/\bimport\s+(type\s+)?[^;]*?from\s+["'](\.[^"']+)["']/g)) {
|
|
544
|
+
if (typeOnly) continue;
|
|
545
|
+
const base = path.resolve(path.dirname(file), spec).replace(/\.(m?js)$/, "");
|
|
546
|
+
const found = [".ts", ".tsx", ".js", ".mjs", "/index.ts", "/index.tsx"].map((ext) => base + ext).find((candidate) => fs.existsSync(candidate));
|
|
547
|
+
if (found) visit(found);
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
visit(path.join(dir, entry));
|
|
551
|
+
return sources.join("\n");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// the server renders a browser bundle in this format from one compiled copy for all stores
|
|
555
|
+
// (packages/extensions/src/server-instances.ts); any other is imported once per store, and Node keeps every copy
|
|
556
|
+
function serverRenderFormat(code) {
|
|
557
|
+
const match = /\bexport\s*(?:\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}|default\s+([A-Za-z_$][\w$]*))\s*;?\s*(?:\/\/#\s*sourceMappingURL=\S*\s*)?$/.exec(code);
|
|
558
|
+
if (!match || /(^|[^\w$.])import\s*\(/.test(code.slice(0, match.index))) return false;
|
|
559
|
+
try {
|
|
560
|
+
new vm.Script(`(function () {\n"use strict";\n${code.slice(0, match.index)}\nreturn ${match[1] || match[2]};\n})`);
|
|
561
|
+
return true;
|
|
562
|
+
} catch {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function check(target) {
|
|
568
|
+
const { dir, manifestFile } = resolvePackage(target);
|
|
569
|
+
const manifest = readJson(manifestFile);
|
|
570
|
+
validateManifest(manifest, path.basename(manifestFile));
|
|
571
|
+
const errors = [];
|
|
572
|
+
const warnings = [];
|
|
573
|
+
|
|
574
|
+
// the listing
|
|
575
|
+
for (const key of ["label", "publisher", "description"]) if (!String(manifest[key] || "").trim()) errors.push(`"${key}" is missing (the marketplace shows it)`);
|
|
576
|
+
if (/describe what it does/i.test(manifest.description || "") || manifest.publisher === "Your company") errors.push("the starter's publisher or description is still in place");
|
|
577
|
+
else if (String(manifest.description || "").length < 30) warnings.push('"description" is short: say what it does for the store');
|
|
578
|
+
for (const key of ["license", "compatibility"]) if (!manifest[key]) warnings.push(`"${key}" is missing`);
|
|
579
|
+
|
|
580
|
+
// permissions against the code
|
|
581
|
+
const server = sourceOf(dir, manifest.build?.server);
|
|
582
|
+
const browser = sourceOf(dir, manifest.build?.browser);
|
|
583
|
+
const used = new Set();
|
|
584
|
+
if (/\broutes\s*[:=,}]/.test(server)) used.add("routes");
|
|
585
|
+
if (HOOK_NAMES.some((hook) => new RegExp(`\\b${hook}\\b`).test(server))) used.add("cartHooks");
|
|
586
|
+
if (Object.keys(manifest.overrides || {}).length) used.add("overrides");
|
|
587
|
+
if (/\bznode\s*\(/.test(server)) used.add("znodeApi");
|
|
588
|
+
if (/\bfetch\s*\(|\bhttps?\.request\s*\(/.test(server)) used.add("externalNetwork");
|
|
589
|
+
if (/\.files\b/.test(server)) used.add("uploads");
|
|
590
|
+
const declared = new Set(manifest.permissions || []);
|
|
591
|
+
for (const item of used) if (!declared.has(item)) errors.push(`uses "${item}" without declaring it in "permissions"`);
|
|
592
|
+
for (const item of declared) if (!used.has(item)) warnings.push(`declares "${item}" but no code uses it (the review asks why)`);
|
|
593
|
+
if (/\beval\s*\(|new Function\s*\(/.test(server + browser)) errors.push("uses eval or new Function");
|
|
594
|
+
if (/document\.cookie/.test(browser)) warnings.push("reads cookies in the browser: keep secrets on the server (routes)");
|
|
595
|
+
|
|
596
|
+
// settings
|
|
597
|
+
for (const [key, field] of Object.entries(manifest.settingsSchema || {})) {
|
|
598
|
+
if (!field.label) warnings.push(`setting "${key}" has no label (the admin shows it)`);
|
|
599
|
+
if (field.required && field.default !== undefined) warnings.push(`setting "${key}" is required but has a default`);
|
|
600
|
+
if (field.browser && /secret|password|token|api_?key/i.test(key)) errors.push(`setting "${key}" looks secret but is sent to the browser ("browser": true)`);
|
|
601
|
+
}
|
|
602
|
+
if (manifest.kind === "theme" && !Object.values(manifest.settingsSchema || {}).some((field) => field.attribute)) {
|
|
603
|
+
warnings.push("no setting is bound to a Znode attribute: per-store branding then needs a registry change per store");
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// the built files (pack first)
|
|
607
|
+
const out = path.join(dir, "dist");
|
|
608
|
+
if (fs.existsSync(out)) {
|
|
609
|
+
let total = 0;
|
|
610
|
+
for (const name of fs.readdirSync(out)) {
|
|
611
|
+
const size = fs.statSync(path.join(out, name)).size;
|
|
612
|
+
total += size;
|
|
613
|
+
if (name.endsWith(".browser.mjs")) {
|
|
614
|
+
if (size > 512 * 1024) warnings.push(`${name} is ${Math.round(size / 1024)} KB: every page of the store loads it`);
|
|
615
|
+
if (!serverRenderFormat(fs.readFileSync(path.join(out, name), "utf8")))
|
|
616
|
+
warnings.push(`${name} is not in the server-render format (no imports, one default export): the server keeps a copy per store`);
|
|
617
|
+
}
|
|
618
|
+
if (name.endsWith(".css") && size > 200 * 1024) warnings.push(`${name} is ${Math.round(size / 1024)} KB`);
|
|
619
|
+
}
|
|
620
|
+
if (total > MAX_BYTES) errors.push(`dist is ${Math.round(total / 1048576)} MB (limit 10 MB)`);
|
|
621
|
+
} else warnings.push("not built yet: pack it to check sizes and the server-render format");
|
|
622
|
+
|
|
623
|
+
for (const item of errors) console.log(`error ${item}`);
|
|
624
|
+
for (const item of warnings) console.log(`warning ${item}`);
|
|
625
|
+
console.log(`check ${manifest.name}@${manifest.version}: ${errors.length} errors, ${warnings.length} warnings`);
|
|
626
|
+
if (errors.length) process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---- project commands: deploy, dev, link-store ------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
const needStore = (storeDir) =>
|
|
632
|
+
storeDir || fail("needs the store's plugin folder: pass it after the package, or set RUNTIME_PLUGINS_DIR");
|
|
633
|
+
|
|
634
|
+
// pack, then install the zip it made into the store's plugin folder
|
|
635
|
+
function deploy(target, storeDir) {
|
|
636
|
+
pack(target);
|
|
637
|
+
const { dir, manifestFile } = resolvePackage(target);
|
|
638
|
+
const manifest = readJson(manifestFile);
|
|
639
|
+
install(path.join(dir, "releases", `${manifest.name}-${manifest.version}.zpkg.zip`), storeDir);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// deploy now, then again whenever the package's sources or manifest change; the store picks a new file up on the next
|
|
643
|
+
// request (it reloads a package when its files change), so a page refresh shows the change
|
|
644
|
+
function dev(target, storeDir) {
|
|
645
|
+
const { dir } = resolvePackage(target);
|
|
646
|
+
const self = fileURLToPath(import.meta.url);
|
|
647
|
+
const run = () => spawnSync(process.execPath, [self, "deploy", dir, storeDir], { stdio: "inherit" });
|
|
648
|
+
run();
|
|
649
|
+
let timer = null;
|
|
650
|
+
const changed = (file) => {
|
|
651
|
+
if (!file || /(^|[\\/])(dist|releases|\.build|node_modules)([\\/]|$)/.test(String(file))) return;
|
|
652
|
+
clearTimeout(timer);
|
|
653
|
+
timer = setTimeout(() => {
|
|
654
|
+
console.log(`znode dev: ${file} changed, deploying`);
|
|
655
|
+
run();
|
|
656
|
+
}, 300);
|
|
657
|
+
};
|
|
658
|
+
fs.watch(path.join(dir, "src"), { recursive: true }, (_event, file) => changed(file && path.join("src", file)));
|
|
659
|
+
fs.watch(dir, (_event, file) => file === MANIFEST && changed(file));
|
|
660
|
+
console.log(`znode dev: watching ${dir} (Ctrl+C to stop)`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// the store's real types for the editor and the build check: tsconfig.store.json (git-ignored, per machine) is this
|
|
664
|
+
// package's preset plus the store repo's aliases (every @znode path of its tsconfig.base.json, from the store's folder).
|
|
665
|
+
// Only the aliases are taken: the store's own options (its rootDir among them) would refuse a project kept elsewhere.
|
|
666
|
+
function linkStore(storeRepo, project = ".") {
|
|
667
|
+
const base = path.resolve(storeRepo, "tsconfig.base.json");
|
|
668
|
+
if (!fs.existsSync(base)) fail(`${base} not found: pass the store repo's folder (the one with tsconfig.base.json)`);
|
|
669
|
+
const ts = require("typescript");
|
|
670
|
+
const { config, error } = ts.readConfigFile(base, ts.sys.readFile);
|
|
671
|
+
if (error) fail(`${base}: ${ts.flattenDiagnosticMessageText(error.messageText, "\n")}`);
|
|
672
|
+
const options = config.compilerOptions || {};
|
|
673
|
+
const toSlash = (value) => value.split(path.sep).join("/");
|
|
674
|
+
const file = path.resolve(project, "tsconfig.store.json");
|
|
675
|
+
const linked = {
|
|
676
|
+
extends: "znode-plugin/tsconfig.json",
|
|
677
|
+
compilerOptions: { baseUrl: toSlash(path.resolve(path.dirname(base), options.baseUrl || ".")), paths: options.paths || {} },
|
|
678
|
+
};
|
|
679
|
+
fs.writeFileSync(file, JSON.stringify(linked, null, 2) + "\n");
|
|
680
|
+
console.log(`linked ${file} to ${base} (${Object.keys(linked.compilerOptions.paths).length} store aliases)`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ---- eject: a store component copied into the package, in place of the store's, through its slot ----------------
|
|
684
|
+
// A store component that has a runtime slot whose children are the component itself (withRuntimeWrapper(Wrapper, Base,
|
|
685
|
+
// "Name") where it is defined) can be taken over by a package: `znode eject <Name>` copies its file from the linked store
|
|
686
|
+
// into src/components/<Name>.tsx (relative imports turned into the store's @znode aliases, the runtime wrapper removed so
|
|
687
|
+
// the copy is the plain component) and registers it in src/browser.tsx under the slot, so the store renders the copy
|
|
688
|
+
// instead of its own. `znode eject` lists the components that can be ejected.
|
|
689
|
+
const EJECT_SLOTS = {
|
|
690
|
+
RuntimeProductBasicInfo: "product.basicInfo",
|
|
691
|
+
RuntimeProductCard: "product.card",
|
|
692
|
+
RuntimeProductImage: "product.image",
|
|
693
|
+
RuntimeAddToCart: "product.addToCart",
|
|
694
|
+
RuntimeProductVariants: "product.variants",
|
|
695
|
+
RuntimeAddToCartNotification: "addToCart.notification",
|
|
696
|
+
RuntimeCheckoutAddress: "checkout.address",
|
|
697
|
+
RuntimeShippingOptions: "checkout.shippingOptions",
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// every withRuntimeWrapper(<Wrapper>, <Base>, "<Name>") in the store's base components, with the slot it fills
|
|
701
|
+
function ejectableComponents(root) {
|
|
702
|
+
const found = [];
|
|
703
|
+
const walk = (dir) => {
|
|
704
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
705
|
+
const file = path.join(dir, entry.name);
|
|
706
|
+
if (entry.isDirectory()) {
|
|
707
|
+
if (entry.name !== "node_modules") walk(file);
|
|
708
|
+
} else if (/\.tsx?$/.test(entry.name)) {
|
|
709
|
+
const text = fs.readFileSync(file, "utf8");
|
|
710
|
+
for (const m of text.matchAll(/withRuntimeWrapper\(\s*(\w+)\s*,\s*(\w+)\s*,\s*"(\w+)"\s*\)/g)) {
|
|
711
|
+
if (EJECT_SLOTS[m[1]]) found.push({ name: m[3], wrapper: m[1], base: m[2], slot: EJECT_SLOTS[m[1]], file });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
for (const dir of ["packages/base-components/src", "packages/page-builder/src"]) if (fs.existsSync(path.join(root, dir))) walk(path.join(root, dir));
|
|
717
|
+
return found.sort((a, b) => a.name.localeCompare(b.name));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// a store file's import path as the package can import it: the store alias that maps to that file, without extension
|
|
721
|
+
function storeAlias(store, absolute) {
|
|
722
|
+
const target = absolute.split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
|
|
723
|
+
let best = null;
|
|
724
|
+
const norm = (value) => path.resolve(store.root, value).split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
|
|
725
|
+
// an exact alias for this very module ("@znode/base-components/stores": [".../stores/index.ts"]) wins
|
|
726
|
+
for (const [alias, targets] of Object.entries(store.paths)) {
|
|
727
|
+
if (!alias.endsWith("/*") && targets.some((pattern) => norm(pattern) === target)) return alias;
|
|
728
|
+
}
|
|
729
|
+
for (const [alias, targets] of Object.entries(store.paths)) {
|
|
730
|
+
if (!alias.endsWith("/*")) continue;
|
|
731
|
+
for (const pattern of targets) {
|
|
732
|
+
const prefix = path.resolve(store.root, pattern.replace(/\*$/, "")).split(path.sep).join("/").replace(/\/$/, "") + "/";
|
|
733
|
+
if (target.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, alias: alias.slice(0, -1) };
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return best ? best.alias + target.slice(best.prefix.length) : null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function eject(name, force) {
|
|
740
|
+
const project = path.resolve(".");
|
|
741
|
+
if (!fs.existsSync(path.join(project, MANIFEST))) fail(`run znode eject in a package folder (the one with ${MANIFEST})`);
|
|
742
|
+
const store = linkedStore(project);
|
|
743
|
+
if (!store) fail("the package is not linked to a store: npm run link-store -- <store repo>");
|
|
744
|
+
const components = ejectableComponents(store.root);
|
|
745
|
+
if (!name || name === "--list") {
|
|
746
|
+
console.log("Store components a package can eject (the slot that takes your copy):\n");
|
|
747
|
+
for (const c of components) console.log(` ${c.name.padEnd(34)} ${c.slot.padEnd(26)} ${path.relative(store.root, c.file).split(path.sep).join("/")}`);
|
|
748
|
+
console.log("\nznode eject <Name> copies one into src/components/ and registers it in src/browser.tsx");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const c = components.find((item) => item.name.toLowerCase() === name.toLowerCase());
|
|
752
|
+
if (!c) fail(`${name} cannot be ejected: it has no slot that hands over the component (znode eject lists the ones that can)`);
|
|
753
|
+
|
|
754
|
+
const out = path.join(project, "src", "components", `${c.name}.tsx`);
|
|
755
|
+
if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${c.name} --force`);
|
|
756
|
+
const warnings = [];
|
|
757
|
+
let text = fs.readFileSync(c.file, "utf8");
|
|
758
|
+
// relative imports become the store's aliases (the package lives elsewhere); a style file stays the store's
|
|
759
|
+
text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after) => {
|
|
760
|
+
const absolute = path.resolve(path.dirname(c.file), spec);
|
|
761
|
+
if (/\.(s?css)$/.test(spec)) {
|
|
762
|
+
warnings.push(`style import ${spec}: kept out of the copy; the store's own stylesheet is loaded where the store renders it, add your CSS to src/styles/`);
|
|
763
|
+
return `${before}${spec}${after}`;
|
|
764
|
+
}
|
|
765
|
+
const alias = storeAlias(store, fs.existsSync(absolute) || /\.\w+$/.test(spec) ? absolute : absolute);
|
|
766
|
+
if (!alias) {
|
|
767
|
+
warnings.push(`import ${spec}: no store alias for it; change it by hand`);
|
|
768
|
+
return whole;
|
|
769
|
+
}
|
|
770
|
+
return `${before}${alias}${after}`;
|
|
771
|
+
});
|
|
772
|
+
// drop scss/css side-effect imports that point into the store (they cannot be bundled into a package)
|
|
773
|
+
text = text.replace(/^import\s+["'][^"']+\.s?css["'];?\s*\n/gm, "");
|
|
774
|
+
// the copy is the plain component: no runtime wrapper (the slot is what puts it in place)
|
|
775
|
+
text = text.replace(new RegExp(`^.*import\\s*\\{[^}]*\\b(?:withRuntimeWrapper|${c.wrapper})\\b[^}]*\\}\\s*from\\s*["'][^"']+["'];?\\s*\\n`, "gm"), "");
|
|
776
|
+
text = text.replace(new RegExp(`withRuntimeWrapper\\(\\s*${c.wrapper}\\s*,\\s*${c.base}\\s*,\\s*"${c.name}"\\s*\\)`), c.base);
|
|
777
|
+
const storeFile = path.relative(store.root, c.file).split(path.sep).join("/");
|
|
778
|
+
const header = `// Ejected from the store: ${storeFile} (${new Date().toISOString().slice(0, 10)}), with \`znode eject ${c.name}\`.\n// The store renders this copy instead of its own through the slot "${c.slot}" (src/browser.tsx). It is yours now: the\n// store's later changes to ${c.name} do not reach it, so compare it with the store's file after a store upgrade.\n`;
|
|
779
|
+
text = text.startsWith('"use client"') || text.startsWith("'use client'") ? text.replace(/^(["']use client["'];?\s*\n)/, `$1${header}`) : header + text;
|
|
780
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
781
|
+
fs.writeFileSync(out, text);
|
|
782
|
+
console.log(`ejected ${c.name} -> ${path.relative(project, out)}`);
|
|
783
|
+
|
|
784
|
+
// npm packages the copy imports that this package does not have (react, next and the store's @znode modules come from
|
|
785
|
+
// the store): install them at the store's version, so the types match and the build finds them
|
|
786
|
+
const PROVIDED = /^(react|react-dom|next|next-intl|@znode)(\/|$)/;
|
|
787
|
+
const storePackage = (() => {
|
|
788
|
+
try {
|
|
789
|
+
return readJson(path.join(store.root, "package.json"));
|
|
790
|
+
} catch {
|
|
791
|
+
return {};
|
|
792
|
+
}
|
|
793
|
+
})();
|
|
794
|
+
const storeDeps = { ...(storePackage.devDependencies || {}), ...(storePackage.dependencies || {}) };
|
|
795
|
+
const missing = new Map();
|
|
796
|
+
for (const m of text.matchAll(/(?:from|import)\s*\(?\s*["']([^."'/][^"']*)["']/g)) {
|
|
797
|
+
const spec = m[1];
|
|
798
|
+
if (PROVIDED.test(spec)) continue;
|
|
799
|
+
const pkg = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
|
|
800
|
+
try {
|
|
801
|
+
createRequire(path.join(project, "package.json")).resolve(`${pkg}/package.json`);
|
|
802
|
+
} catch {
|
|
803
|
+
missing.set(pkg, storeDeps[pkg] ? `${pkg}@${storeDeps[pkg].replace(/^[\^~]/, "")}` : pkg);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (missing.size) {
|
|
807
|
+
console.log(`\nThe copy imports npm packages this package does not have (bundled into the package when it builds):`);
|
|
808
|
+
console.log(` npm install ${[...missing.values()].join(" ")}`);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// register the copy under the slot in src/browser.tsx
|
|
812
|
+
const browser = path.join(project, "src", "browser.tsx");
|
|
813
|
+
const exportsDefault = new RegExp(`export\\s+default\\s+${c.name}\\b`).test(text) || new RegExp(`export\\s+default\\s+${c.base}\\b`).test(text);
|
|
814
|
+
const local = `Ejected${c.name}`;
|
|
815
|
+
const importLine = exportsDefault
|
|
816
|
+
? `import ${local} from "./components/${c.name}.js";`
|
|
817
|
+
: `import { ${c.name} as ${local} } from "./components/${c.name}.js";`;
|
|
818
|
+
if (!fs.existsSync(browser)) {
|
|
819
|
+
warnings.push(`src/browser.tsx not found: register it yourself: ${importLine} and slots: { "${c.slot}": ${local} }`);
|
|
820
|
+
} else {
|
|
821
|
+
let source = fs.readFileSync(browser, "utf8");
|
|
822
|
+
if (new RegExp(`["']${c.slot.replace(".", "\\.")}["']\\s*:`).test(source)) {
|
|
823
|
+
warnings.push(`src/browser.tsx already fills "${c.slot}": point it at ${local} (${importLine}) yourself`);
|
|
824
|
+
} else if (/slots\s*:\s*\{/.test(source)) {
|
|
825
|
+
if (!source.includes(importLine)) {
|
|
826
|
+
const imports = [...source.matchAll(/^import .*;\s*$/gm)];
|
|
827
|
+
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
|
|
828
|
+
source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
|
|
829
|
+
}
|
|
830
|
+
source = source.replace(/slots\s*:\s*\{\s*\}/, `slots: {\n "${c.slot}": ${local},\n }`);
|
|
831
|
+
if (!source.includes(`"${c.slot}": ${local}`)) source = source.replace(/slots\s*:\s*\{/, `slots: {\n // ejected (znode eject ${c.name}): the store renders this copy instead of its own\n "${c.slot}": ${local},`);
|
|
832
|
+
fs.writeFileSync(browser, source);
|
|
833
|
+
console.log(`registered "${c.slot}": ${local} in src/browser.tsx`);
|
|
834
|
+
} else {
|
|
835
|
+
warnings.push(`no "slots" in src/browser.tsx: add ${importLine} and slots: { "${c.slot}": ${local} }`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
for (const warning of warnings) console.log(`warning ${warning}`);
|
|
839
|
+
console.log(`\nNext: npm run build (it names any store import a package cannot use), then npm run dev and open a page that shows ${c.name}.`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const args = process.argv.slice(2);
|
|
843
|
+
// the store's plugin folder: the argument, else RUNTIME_PLUGINS_DIR, else the project's git-ignored znode.local.json
|
|
844
|
+
// ({ "pluginsDir": "…" }, written by create-znode-plugin --plugins-dir) in the folder the command runs in
|
|
845
|
+
function localPluginsDir() {
|
|
846
|
+
try {
|
|
847
|
+
return JSON.parse(fs.readFileSync(path.resolve("znode.local.json"), "utf8")).pluginsDir || undefined;
|
|
848
|
+
} catch {
|
|
849
|
+
return undefined;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const [command, target, storeDir = process.env.RUNTIME_PLUGINS_DIR || localPluginsDir()] = args;
|
|
853
|
+
if (command === "create") {
|
|
854
|
+
create(args[1], args[2], args[3]);
|
|
855
|
+
process.exit(0);
|
|
856
|
+
}
|
|
857
|
+
if (command === "eject") {
|
|
858
|
+
eject(args.find((arg, index) => index > 0 && !arg.startsWith("--")), args.includes("--force"));
|
|
859
|
+
process.exit(0);
|
|
860
|
+
}
|
|
861
|
+
if (command === "link-store") {
|
|
862
|
+
if (!target) fail("usage: znode link-store <storeRepoDir> [projectDir]");
|
|
863
|
+
linkStore(target, args[2]);
|
|
864
|
+
process.exit(0);
|
|
865
|
+
}
|
|
866
|
+
if (!target) fail("usage: znode <build|pack|check|install|deploy|dev> <dir|zip> [storeDir], znode create <kind> <name> [dir], znode link-store <storeRepoDir>");
|
|
867
|
+
if (command === "check") check(path.resolve(target));
|
|
868
|
+
else if (command === "build") build(path.resolve(target));
|
|
869
|
+
else if (command === "pack") pack(path.resolve(target));
|
|
870
|
+
else if (command === "install") install(path.resolve(target), path.resolve(needStore(storeDir)));
|
|
871
|
+
else if (command === "deploy") deploy(path.resolve(target), path.resolve(needStore(storeDir)));
|
|
872
|
+
else if (command === "dev") dev(path.resolve(target), path.resolve(needStore(storeDir)));
|
|
873
|
+
else fail(`unknown command "${command}"`);
|