@kyro-cms/admin 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/index.cjs +11960 -11006
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +67 -65
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +563 -0
  6. package/dist/index.d.ts +7 -7
  7. package/dist/index.js +12183 -11238
  8. package/dist/index.js.map +1 -1
  9. package/package.json +15 -11
  10. package/src/components/ActionBar.tsx +27 -14
  11. package/src/components/Admin.tsx +1 -1
  12. package/src/components/ApiKeysManager.tsx +5 -5
  13. package/src/components/AutoForm.tsx +585 -369
  14. package/src/components/BrandingHub.tsx +7 -4
  15. package/src/components/CreateView.tsx +2 -0
  16. package/src/components/DetailView.tsx +71 -56
  17. package/src/components/DeveloperCenter.tsx +8 -6
  18. package/src/components/FieldRenderer.tsx +94 -19
  19. package/src/components/ListView.tsx +33 -20
  20. package/src/components/MediaGallery.tsx +219 -194
  21. package/src/components/PluginsManager.tsx +197 -70
  22. package/src/components/RestPlayground.tsx +7 -7
  23. package/src/components/SessionsManager.tsx +1 -1
  24. package/src/components/SettingsPage.tsx +22 -0
  25. package/src/components/Sidebar.astro +13 -41
  26. package/src/components/UserManagement.tsx +153 -15
  27. package/src/components/UserMenu.tsx +30 -4
  28. package/src/components/VersionHistoryPanel.tsx +112 -119
  29. package/src/components/WebhookManager.tsx +6 -4
  30. package/src/components/blocks/ArrayBlock.tsx +6 -23
  31. package/src/components/blocks/BlockEditModal.tsx +82 -309
  32. package/src/components/blocks/CardBlock.tsx +35 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +57 -31
  34. package/src/components/blocks/GenericBlock.tsx +44 -0
  35. package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
  36. package/src/components/blocks/HeroBlock.tsx +5 -14
  37. package/src/components/blocks/RichTextBlock.tsx +5 -5
  38. package/src/components/blocks/index.ts +5 -3
  39. package/src/components/fields/AccordionField.tsx +2 -2
  40. package/src/components/fields/ArrayField.tsx +1 -1
  41. package/src/components/fields/ArrayLayout.tsx +120 -29
  42. package/src/components/fields/BlocksField.tsx +430 -50
  43. package/src/components/fields/CardField.tsx +73 -0
  44. package/src/components/fields/CheckboxField.tsx +7 -3
  45. package/src/components/fields/DateField.tsx +4 -1
  46. package/src/components/fields/GroupLayout.tsx +2 -2
  47. package/src/components/fields/HeadingSubheadingField.tsx +43 -0
  48. package/src/components/fields/ListField.tsx +2 -2
  49. package/src/components/fields/NumberField.tsx +4 -1
  50. package/src/components/fields/RelationshipField.tsx +153 -87
  51. package/src/components/fields/RichTextField.tsx +781 -0
  52. package/src/components/fields/SecretField.tsx +102 -0
  53. package/src/components/fields/SelectField.tsx +19 -6
  54. package/src/components/fields/TabsLayout.tsx +19 -9
  55. package/src/components/fields/TextField.tsx +4 -1
  56. package/src/components/fields/UploadField.tsx +122 -56
  57. package/src/components/fields/extensions/blockComponents.tsx +103 -174
  58. package/src/components/fields/extensions/blocksStore.ts +8 -1
  59. package/src/components/fields/index.ts +4 -2
  60. package/src/components/ui/PageHeader.tsx +5 -5
  61. package/src/components/ui/SlidePanel.tsx +8 -3
  62. package/src/components/ui/icons.tsx +109 -109
  63. package/src/components/users/UserDetail.tsx +79 -16
  64. package/src/hooks/useAutoFormState.ts +125 -62
  65. package/src/integration.ts +148 -46
  66. package/src/kyro-cms.d.ts +7 -2
  67. package/src/layouts/AuthLayout.astro +14 -2
  68. package/src/lib/autoform-store.ts +85 -52
  69. package/src/lib/change-source.ts +9 -0
  70. package/src/lib/config.ts +104 -8
  71. package/src/lib/globals.ts +44 -9
  72. package/src/lib/normalize-upload-fields.ts +41 -0
  73. package/src/lib/paths.ts +2 -2
  74. package/src/lib/resolve-field-value.ts +110 -0
  75. package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
  76. package/src/lib/shim/use-sync-external-store.js +1 -0
  77. package/src/lib/stores/index.ts +1 -0
  78. package/src/lib/useResourceManager.ts +4 -4
  79. package/src/lib/vite-shim-plugin.ts +100 -0
  80. package/src/pages/[collection]/[id].astro +1 -1
  81. package/src/pages/preview/[collection]/[id].astro +4 -4
  82. package/src/pages/settings/[slug].astro +2 -2
  83. package/src/styles/main.css +60 -54
  84. package/README.md +0 -46
  85. package/dist/EditorClient-Q23UXR37.cjs +0 -468
  86. package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
  87. package/dist/EditorClient-T5PASFNR.js +0 -466
  88. package/dist/EditorClient-T5PASFNR.js.map +0 -1
  89. package/dist/chunk-3BGDYKTD.cjs +0 -348
  90. package/dist/chunk-3BGDYKTD.cjs.map +0 -1
  91. package/dist/chunk-EEFXLQVT.js +0 -3
  92. package/dist/chunk-EEFXLQVT.js.map +0 -1
  93. package/src/components/blocks/ButtonBlock.tsx +0 -64
  94. package/src/components/blocks/ColumnsBlock.tsx +0 -55
  95. package/src/components/blocks/DividerBlock.tsx +0 -43
  96. package/src/components/blocks/LinkBlock.tsx +0 -65
  97. package/src/components/blocks/VStackBlock.tsx +0 -29
  98. package/src/components/fields/EditorClient.tsx +0 -535
  99. package/src/components/fields/PortableTextField.tsx +0 -155
  100. package/src/components/fields/PortableTextRenderer.tsx +0 -68
@@ -1,8 +1,11 @@
1
1
  import { useEffect, useRef, useCallback } from "react";
2
2
  import { useAutoFormStore } from "../lib/autoform-store";
3
+ import { getLastChangeSource, setChangeSource } from "../lib/change-source";
3
4
  import { slugifyText } from "../lib/slugify";
4
5
  import { resolveUrl, fetchWithAuth } from "../lib/api";
6
+ import { normalizeUploadFields } from "../lib/normalize-upload-fields";
5
7
  import { useUIStore } from "../lib/stores";
8
+ import { resolveFieldValue } from "../lib/resolve-field-value";
6
9
 
7
10
  interface UseAutoFormStateProps {
8
11
  config: Record<string, unknown>;
@@ -24,7 +27,7 @@ export function useAutoFormState({
24
27
  onActionError,
25
28
  }: UseAutoFormStateProps) {
26
29
  const store = useAutoFormStore();
27
- const { confirm, alert } = useUIStore();
30
+ const { confirm } = useUIStore();
28
31
  const {
29
32
  formData,
30
33
  setFormData,
@@ -43,12 +46,40 @@ export function useAutoFormState({
43
46
  getDraftCache,
44
47
  setDraftCache,
45
48
  clearDraftCache,
49
+ resetForm,
46
50
  } = store;
47
51
 
48
52
  const versionsEnabled = !!config.versions;
49
53
 
54
+ // Guard: clear stale formData from a previous page context.
55
+ // For collections, if the loaded document's id doesn't match the expected
56
+ // context key, the singleton zustand store is holding stale data.
57
+ // Globals have no id field and are singleton per slug, so skip this check.
58
+ // Must run in useEffect — calling resetForm() during render triggers
59
+ // "Cannot update a component while rendering" React error.
60
+ const currentContextKey = globalSlug || (initialData?.id as string | undefined) || collectionSlug;
61
+ const needsResetRef = useRef(false);
62
+ if (
63
+ !globalSlug &&
64
+ currentContextKey &&
65
+ formData &&
66
+ Object.keys(formData).length > 0 &&
67
+ formData.id !== currentContextKey
68
+ ) {
69
+ needsResetRef.current = true;
70
+ }
71
+
72
+ useEffect(() => {
73
+ if (needsResetRef.current) {
74
+ needsResetRef.current = false;
75
+ resetForm();
76
+ }
77
+ }, [resetForm]);
78
+
50
79
  const localSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
51
80
  const serverSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
81
+ const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
82
+ const isOnlineRef = useRef(typeof navigator !== 'undefined' ? navigator.onLine : true);
52
83
  const lastAutoSaveTimeRef = useRef<number>(0);
53
84
  const autoSaveSkipRef = useRef<boolean>(false);
54
85
  const restorePromptedRef = useRef<string | null>(null);
@@ -125,9 +156,8 @@ const persistBrowserDraft = useCallback(
125
156
  const performLocalAutoSave = useCallback(() => {
126
157
  const state = useAutoFormStore.getState();
127
158
  const latestFormData = state.formData;
128
- const currentLastSaved = state.lastSavedData;
129
159
  if (autoSaveSkipRef.current || !collectionSlug || !latestFormData.id) return;
130
- if (JSON.stringify(latestFormData) === JSON.stringify(currentLastSaved)) return;
160
+ if (!state.hasDirtyFields()) return;
131
161
  const documentKey = getDocumentKey(latestFormData.id);
132
162
  if (documentKey) {
133
163
  persistBrowserDraft(documentKey, latestFormData);
@@ -141,13 +171,18 @@ const persistBrowserDraft = useCallback(
141
171
 
142
172
  if (autoSaveSkipRef.current || (!versionsEnabled && !!globalSlug)) return;
143
173
  if (!globalSlug && (!collectionSlug || !latestFormData.id)) return;
144
- if (JSON.stringify(latestFormData) === JSON.stringify(currentLastSaved)) return;
174
+ if (!state.hasDirtyFields()) return;
145
175
 
146
176
  const documentKey = getDocumentKey(latestFormData.id);
147
177
  if (documentKey) {
148
178
  persistBrowserDraft(documentKey, latestFormData);
149
179
  }
150
180
 
181
+ if (!isOnlineRef.current) {
182
+ setAutoSaveStatus("offline");
183
+ return;
184
+ }
185
+
151
186
  setIsAutoSaving(true);
152
187
  setAutoSaveStatus("saving");
153
188
 
@@ -157,14 +192,20 @@ const persistBrowserDraft = useCallback(
157
192
  ? resolveUrl(`/api/globals/${globalSlug}/draft`)
158
193
  : resolveUrl(`/api/${collectionSlug}/${latestFormData.id}/draft`);
159
194
 
195
+ const delta = state.getDirtyData();
196
+ const idempotencyKey = `${documentKey || "global"}:${Date.now()}`;
197
+
160
198
  const response = await fetchWithAuth(
161
199
  draftUrl,
162
200
  {
163
201
  method: "PUT",
164
- headers: { "Content-Type": "application/json" },
202
+ headers: {
203
+ "Content-Type": "application/json",
204
+ "X-Idempotency-Key": idempotencyKey
205
+ },
165
206
  keepalive: options?.keepalive,
166
207
  body: JSON.stringify({
167
- data: latestFormData,
208
+ delta: normalizeUploadFields(delta),
168
209
  baseUpdatedAt: currentLastSaved.updatedAt ?? null,
169
210
  draftUpdatedAt,
170
211
  }),
@@ -174,6 +215,10 @@ const persistBrowserDraft = useCallback(
174
215
  if (response.ok) {
175
216
  const result = await response.json();
176
217
  lastAutoSaveTimeRef.current = Date.now();
218
+ state.setRetryCount(0);
219
+ state.setLastSavedAt(Date.now());
220
+ if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
221
+
177
222
  if (documentKey) {
178
223
  setDraftCache(documentKey, {
179
224
  data: latestFormData,
@@ -183,27 +228,39 @@ const persistBrowserDraft = useCallback(
183
228
  });
184
229
  }
185
230
  setAutoSaveStatus("success");
186
- setTimeout(() => setAutoSaveStatus("idle"), 2000);
231
+ setTimeout(() => {
232
+ if (useAutoFormStore.getState().autoSaveStatus === "success") {
233
+ setAutoSaveStatus("idle");
234
+ }
235
+ }, 2000);
187
236
  } else {
188
- const error = await response.json().catch(() => ({}));
189
- console.error("Draft auto-save failed:", error);
190
- setAutoSaveStatus("error");
191
- setTimeout(() => setAutoSaveStatus("idle"), 5000);
237
+ throw new Error(`Draft auto-save failed with status ${response.status}`);
192
238
  }
193
239
  } catch (err) {
194
240
  console.error("Auto-save failed:", err);
195
- setAutoSaveStatus("error");
196
- setTimeout(() => setAutoSaveStatus("idle"), 5000);
241
+ const state = useAutoFormStore.getState();
242
+ const currentRetryCount = state.retryCount;
243
+ if (currentRetryCount < 5) {
244
+ state.setRetryCount(currentRetryCount + 1);
245
+ setAutoSaveStatus("retrying");
246
+ const delay = Math.min(1000 * Math.pow(2, currentRetryCount), 60000);
247
+ if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
248
+ retryTimerRef.current = setTimeout(() => performServerAutoSave(options), delay);
249
+ } else {
250
+ setAutoSaveStatus("offline");
251
+ }
197
252
  } finally {
198
253
  setIsAutoSaving(false);
199
254
  }
200
255
  }, [
201
256
  collectionSlug,
202
257
  getDocumentKey,
258
+ globalSlug,
203
259
  persistBrowserDraft,
204
260
  setAutoSaveStatus,
205
261
  setDraftCache,
206
262
  setIsAutoSaving,
263
+ versionsEnabled
207
264
  ]);
208
265
 
209
266
  const saveDocument = useCallback(
@@ -221,7 +278,7 @@ const saveDocument = useCallback(
221
278
  method: "PATCH",
222
279
  headers: { "Content-Type": "application/json" },
223
280
  body: JSON.stringify({
224
- ...payload,
281
+ ...normalizeUploadFields(payload) as Record<string, unknown>,
225
282
  baseUpdatedAt: state.lastSavedData.updatedAt ?? null,
226
283
  }),
227
284
  },
@@ -285,11 +342,7 @@ const saveDocument = useCallback(
285
342
  return () => window.removeEventListener("toggle-sidebar", handleToggle);
286
343
  }, [sidebarCollapsed, setSidebarCollapsed]);
287
344
 
288
- // Track unsaved changes
289
- useEffect(() => {
290
- const isDifferent = JSON.stringify(formData) !== JSON.stringify(lastSavedData);
291
- setHasUnsavedChanges(isDifferent);
292
- }, [formData, lastSavedData, setHasUnsavedChanges]);
345
+ // Track unsaved changes (handled by setField / dirtyFields now)
293
346
 
294
347
  // Initial data load
295
348
  const lastLoadedSlugRef = useRef<string | null>(null);
@@ -298,11 +351,10 @@ const saveDocument = useCallback(
298
351
  const currentSlug = globalSlug || initialData?.id;
299
352
  if (initialDataLoadedRef.current && lastLoadedSlugRef.current === currentSlug) return;
300
353
 
301
- setFormData(initialData || {});
302
354
  loadDocument(initialData || {}, initialData || {});
303
355
  initialDataLoadedRef.current = true;
304
356
  lastLoadedSlugRef.current = currentSlug;
305
- }, [formData.id, globalSlug, initialData, loadDocument, setFormData]);
357
+ }, [collectionSlug, formData.id, globalSlug, initialData, loadDocument]);
306
358
 
307
359
  useEffect(() => {
308
360
  if (!collectionSlug || !initialData?.id) return;
@@ -356,7 +408,9 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
356
408
  cancelLabel: "Discard draft",
357
409
  onConfirm: async () => {
358
410
  if (cancelled) return;
359
- setFormData(candidate.data);
411
+ const currentFormData = useAutoFormStore.getState().formData;
412
+ const mergedData = { ...currentFormData, ...candidate.data };
413
+ setFormData(mergedData);
360
414
  onActionSuccess?.("Recovered autosaved draft");
361
415
  },
362
416
  onCancel: async () => {
@@ -409,45 +463,28 @@ const drafts = [browserDraft, serverDraft].filter(Boolean) as Array<{
409
463
 
410
464
  // Auto-generate metaTitle
411
465
  useEffect(() => {
412
- const metaTitleField = findFieldDeep(config.fields, "metaTitle");
466
+ const fields = config.fields as Record<string, unknown>[];
467
+ const metaTitleField = findFieldDeep(fields, "metaTitle");
413
468
  if (!metaTitleField) return;
414
469
 
415
- let titleValue = "";
416
- for (const field of config.fields) {
417
- if (field.type === "tabs" && "tabs" in field && field.name) {
418
- const tabData = formData[field.name as string];
419
- if (tabData && typeof tabData === "object" && tabData.title) {
420
- titleValue = tabData.title;
421
- break;
422
- }
423
- }
424
- }
470
+ const titleValue = resolveFieldValue(fields, formData, "title");
471
+ const titleStr = titleValue ? String(titleValue) : "";
425
472
 
426
- if (titleValue && (!formData.metaTitle || formData.metaTitle === formData._lastMetaTitle)) {
427
- setField("metaTitle", titleValue);
473
+ if (titleStr && (!formData.metaTitle || formData.metaTitle === formData._lastMetaTitle)) {
474
+ setField("metaTitle", titleStr);
428
475
  }
429
476
  }, [formData, config.fields, setField]);
430
477
 
431
478
  // Auto-generate slug
432
479
  useEffect(() => {
433
- const slugField = config.fields.find(
434
- (f: Record<string, unknown>) => f.name === "slug" && f.admin?.autoGenerate,
435
- );
480
+ const fields = config.fields as Record<string, unknown>[];
481
+ const slugField = fields.find(
482
+ (f: Record<string, unknown>) => f.name === "slug" && f.admin?.autoGenerate,
483
+ );
436
484
  if (!slugField?.admin?.autoGenerate) return;
437
485
  const sourceField: string = slugField.admin.autoGenerate;
438
486
 
439
- let sourceValue = formData[sourceField];
440
- if (!sourceValue) {
441
- for (const field of config.fields) {
442
- if (field.type === "tabs" && "tabs" in field && field.name) {
443
- const tabData = formData[field.name as string];
444
- if (tabData && typeof tabData === "object" && tabData[sourceField]) {
445
- sourceValue = tabData[sourceField];
446
- break;
447
- }
448
- }
449
- }
450
- }
487
+ const sourceValue = resolveFieldValue(fields, formData, sourceField);
451
488
 
452
489
  if (isSlugLocked && sourceValue) {
453
490
  const newSlug = slugifyText(sourceValue);
@@ -457,21 +494,28 @@ const slugField = config.fields.find(
457
494
  }
458
495
  }, [formData, isSlugLocked, config.fields, setField]);
459
496
 
460
- // Auto-save effect (Split-timer: 1.5s local, 10s server)
497
+ // Auto-save effect only starts timers on keystroke-originated changes.
498
+ // Local save fires after 1.5s of inactivity, server save after 8s.
499
+ // Non-keystroke changes (block add/drag, select, checkbox, etc.) do NOT restart auto-save.
461
500
  useEffect(() => {
462
501
  if (sidebarCollapsed) return;
463
502
  if (!globalSlug && (!collectionSlug || !formData.id)) return;
464
503
 
504
+ const state = useAutoFormStore.getState();
505
+ if (!state.hasDirtyFields()) return;
506
+
507
+ // Only schedule/reschedule on keystroke-originated changes
508
+ if (getLastChangeSource() !== "keystroke") return;
509
+
510
+ // Reset source so subsequent non-keystroke changes don't re-schedule
511
+ setChangeSource("other");
512
+
513
+ // Clear existing timers and start fresh
465
514
  if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
466
515
  if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
467
516
 
468
517
  localSaveTimerRef.current = setTimeout(performLocalAutoSave, 1500);
469
- serverSaveTimerRef.current = setTimeout(performServerAutoSave, 10000);
470
-
471
- return () => {
472
- if (localSaveTimerRef.current) clearTimeout(localSaveTimerRef.current);
473
- if (serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
474
- };
518
+ serverSaveTimerRef.current = setTimeout(performServerAutoSave, 8000);
475
519
  }, [formData, sidebarCollapsed, collectionSlug, globalSlug, performLocalAutoSave, performServerAutoSave]);
476
520
 
477
521
  useEffect(() => {
@@ -479,6 +523,9 @@ const slugField = config.fields.find(
479
523
 
480
524
  const flushDraft = () => {
481
525
  if (autoSaveSkipRef.current) return;
526
+ const state = useAutoFormStore.getState();
527
+ // Only flush if there are actual unsaved changes
528
+ if (!state.hasDirtyFields()) return;
482
529
  void performServerAutoSave({ keepalive: true });
483
530
  };
484
531
 
@@ -488,13 +535,30 @@ const slugField = config.fields.find(
488
535
  }
489
536
  };
490
537
 
538
+ const handleOnline = () => {
539
+ isOnlineRef.current = true;
540
+ flushDraft();
541
+ };
542
+
543
+ const handleOffline = () => {
544
+ isOnlineRef.current = false;
545
+ const state = useAutoFormStore.getState();
546
+ if (state.hasDirtyFields()) {
547
+ state.setAutoSaveStatus("offline");
548
+ }
549
+ };
550
+
491
551
  window.addEventListener("blur", flushDraft);
492
552
  window.addEventListener("pagehide", flushDraft);
553
+ window.addEventListener("online", handleOnline);
554
+ window.addEventListener("offline", handleOffline);
493
555
  document.addEventListener("visibilitychange", handleVisibilityChange);
494
556
 
495
557
  return () => {
496
558
  window.removeEventListener("blur", flushDraft);
497
559
  window.removeEventListener("pagehide", flushDraft);
560
+ window.removeEventListener("online", handleOnline);
561
+ window.removeEventListener("offline", handleOffline);
498
562
  document.removeEventListener("visibilitychange", handleVisibilityChange);
499
563
  };
500
564
  }, [collectionSlug, globalSlug, formData.id, performServerAutoSave]);
@@ -515,15 +579,15 @@ const slugField = config.fields.find(
515
579
 
516
580
  // Derived status values the UI can use for badges and button state
517
581
  const documentStatus: 'draft' | 'published' | 'archived' | undefined = (() => {
518
- if (!versionsEnabled) return 'published';
519
582
  if (!formData.id && !globalSlug) return 'draft';
583
+ if (!versionsEnabled) return 'published';
520
584
  // If it has a pending draft version, effectively it's in a draft state for the editor
521
- if (formData._has_draft) return 'draft';
522
- return formData._status || 'published';
585
+ if (formData.hasDraft) return 'draft';
586
+ return formData.publishStatus || 'published';
523
587
  })();
524
588
 
525
589
  const hasUnpublishedChanges =
526
- (!!formData.id || !!globalSlug) && (documentStatus !== 'published' || !!formData._has_draft);
590
+ (!!formData.id || !!globalSlug) && (documentStatus !== 'published' || !!formData.hasDraft);
527
591
 
528
592
  return {
529
593
  ...store,
@@ -535,7 +599,6 @@ const slugField = config.fields.find(
535
599
  clearDraftArtifacts,
536
600
  autoSaveSkipRef,
537
601
  lastAutoSaveTimeRef,
538
- alert,
539
602
  documentStatus,
540
603
  hasUnpublishedChanges,
541
604
  versionsEnabled,
@@ -1,10 +1,14 @@
1
1
  import type { AstroIntegration } from "astro";
2
2
  import path from "path";
3
3
  import fs from "fs";
4
- import { execSync } from "child_process";
5
4
  import { pathToFileURL } from "url";
5
+ import { build } from "esbuild";
6
6
  import { config as loadDotEnv } from "dotenv";
7
- import { transform } from "esbuild";
7
+ import { Worker } from "worker_threads";
8
+
9
+ const _shimDir = path.resolve(new URL(".", import.meta.url).pathname, "lib/shim");
10
+ const _shimUses = path.join(_shimDir, "use-sync-external-store.js");
11
+ const _shimUsesWs = path.join(_shimDir, "use-sync-external-store-with-selector.js");
8
12
 
9
13
  export interface KyroAdminOptions {
10
14
  basePath?: string;
@@ -46,81 +50,178 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
46
50
  logger.warn(`Config file not found. Using defaults.`);
47
51
  }
48
52
 
49
- // Load the user's config and expose it globally so that
50
- // admin lib modules (config.ts, globals.ts) can access it
51
- // without needing the kyro:config Vite alias during config loading.
52
- // Load .env first since Vite hasn't processed it yet at this point
53
- // in the lifecycle, and the config module evaluates eagerly.
54
- // Use esbuild to transpile TS to ESM, then evaluate in a child
55
- // process to completely bypass Vite's module runner interception.
53
+ // Transpile the user's config and write a serialized JSON copy.
54
+ // The JSON path is injected via Vite's define so the admin can read it reliably.
55
+ const configFile = path.join(path.dirname(resolvedConfig), ".kyro-admin-config.json");
56
56
  let tmpFile = "";
57
57
  try {
58
58
  const envPath = path.join(path.dirname(resolvedConfig), ".env");
59
59
  if (fs.existsSync(envPath)) {
60
60
  loadDotEnv({ path: envPath });
61
61
  }
62
- const configContent = fs.readFileSync(resolvedConfig, "utf8");
63
- const result = await transform(configContent, {
64
- loader: "ts",
62
+ const result = await build({
63
+ entryPoints: [resolvedConfig],
64
+ bundle: true,
65
65
  format: "esm",
66
+ platform: "node",
66
67
  target: "es2022",
68
+ write: false,
67
69
  sourcemap: false,
70
+ loader: { '.ts': 'ts', '.tsx': 'tsx' },
71
+ resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'],
72
+ external: ['@kyro-cms/*'],
68
73
  });
69
- // Write transpiled config alongside original so Node.js can
70
- // resolve @kyro-cms/core from the project's node_modules
71
74
  tmpFile = resolvedConfig.replace(/\.ts$/, ".admin.mjs");
72
- fs.writeFileSync(tmpFile, result.code, "utf8");
73
- // Evaluate in a child process to bypass Vite's module runner.
74
- // Write a wrapper entrypoint that imports the config and prints JSON,
75
- // then execute it with tsx (handles .ts resolution from .js imports).
76
- const entryFile = tmpFile.replace(/\.admin\.mjs$/, ".admin-entry.mjs");
77
- const resultFile = tmpFile.replace(/\.admin\.mjs$/, ".admin-result.json");
78
- fs.writeFileSync(entryFile, `
79
- import cfg from './${path.basename(tmpFile)}';
80
- import fs from 'fs';
81
- const data = { collections: cfg.default?.collections || cfg?.collections || [], globals: cfg.default?.globals || cfg?.globals || [] };
82
- fs.writeFileSync('${path.basename(resultFile)}', JSON.stringify(data));
83
- `, "utf8");
84
- execSync(
85
- `npx tsx "${entryFile}"`,
86
- { cwd: path.dirname(resolvedConfig), encoding: "utf8", timeout: 15000, stdio: "pipe" },
87
- );
88
- const resultContent = fs.readFileSync(resultFile, "utf8");
89
- const configModule = JSON.parse(resultContent);
90
- try { fs.unlinkSync(resultFile); } catch {}
91
- if (configModule.error) {
92
- throw new Error(configModule.error);
75
+ fs.writeFileSync(tmpFile, result.outputFiles[0].text, "utf8");
76
+
77
+ // Use a Worker thread to load the config in an isolated context.
78
+ const workerCode = `
79
+ import { parentPort } from 'worker_threads';
80
+ import('${pathToFileURL(tmpFile).href}').then(mod => {
81
+ const cfg = mod.default || mod;
82
+ const serialize = (obj) => {
83
+ if (obj === null || obj === undefined) return obj;
84
+ if (typeof obj === 'function') return undefined;
85
+ if (Array.isArray(obj)) return obj.map(serialize);
86
+ if (typeof obj === 'object') {
87
+ const result = {};
88
+ for (const [k, v] of Object.entries(obj)) {
89
+ const sv = serialize(v);
90
+ if (sv !== undefined) result[k] = sv;
91
+ }
92
+ return result;
93
+ }
94
+ return obj;
95
+ };
96
+ parentPort.postMessage({
97
+ collections: serialize(cfg?.collections) || [],
98
+ globals: serialize(cfg?.globals) || [],
99
+ collectionOverrides: serialize(cfg?.admin?.collectionOverrides) || {},
100
+ });
101
+ }).catch(err => {
102
+ parentPort.postMessage({ error: err.message });
103
+ });
104
+ `;
105
+ const worker = new Worker(workerCode, { eval: true, env: { ...process.env, NODE_OPTIONS: '' } });
106
+ const configResult = await new Promise<any>((resolve, reject) => {
107
+ worker.on("message", resolve);
108
+ worker.on("error", reject);
109
+ const timer = setTimeout(() => {
110
+ worker.terminate();
111
+ reject(new Error("Config loading timed out"));
112
+ }, 30000);
113
+ });
114
+ worker.terminate();
115
+
116
+ if (configResult.error) {
117
+ throw new Error(configResult.error);
93
118
  }
94
- (globalThis as any).__KYRO_ADMIN_PROJECT_CONFIG__ = {
95
- collections: configModule.collections,
96
- globals: configModule.globals,
97
- adapter: configModule.adapter || null,
98
- };
119
+ fs.writeFileSync(configFile, JSON.stringify(configResult, null, 2), "utf8");
99
120
  logger.info("Project config loaded for admin");
100
121
  } catch (e: any) {
101
- logger.warn(`Could not load project config: ${e.message}`);
122
+ logger.error(`Could not load project config: ${e.message}`);
102
123
  } finally {
103
- for (const suffix of [".admin.mjs", ".admin-entry.mjs", ".admin-result.json"]) {
104
- const f = resolvedConfig.replace(/\.ts$/, suffix);
105
- if (fs.existsSync(f)) { try { fs.unlinkSync(f); } catch { /* ignore */ } }
106
- }
124
+ if (tmpFile && fs.existsSync(tmpFile)) { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } }
107
125
  }
108
126
 
109
- // Set up Vite aliases and defines for runtime use
127
+ // Set up Vite aliases, defines, and plugins for runtime use
110
128
  updateConfig({
111
129
  vite: {
130
+ plugins: [
131
+ {
132
+ name: "kyro-admin-tsx-loader",
133
+ enforce: "pre" as const,
134
+ config(_config: any) {
135
+ const existingEsbuild = _config.esbuild;
136
+ const existingExclude = existingEsbuild?.exclude;
137
+ const adminInclude = /\/node_modules\/(?!.*@kyro-cms\/(admin|core))/;
138
+ return {
139
+ esbuild: {
140
+ ...(existingEsbuild || {}),
141
+ exclude: existingExclude
142
+ ? Array.isArray(existingExclude)
143
+ ? [...existingExclude, adminInclude]
144
+ : [existingExclude, adminInclude]
145
+ : adminInclude,
146
+ },
147
+ };
148
+ },
149
+ },
150
+ {
151
+ name: "kyro-cjs-shim",
152
+ enforce: "pre" as const,
153
+ resolveId(id: string) {
154
+ if (id.includes('react/compiler-runtime')) {
155
+ return "\0react-compiler-runtime";
156
+ }
157
+ if (id === 'debug' || id.includes('debug/src/browser.js')) {
158
+ return "\0debug-browser";
159
+ }
160
+ },
161
+ load(id: string) {
162
+ if (id === "\0react-compiler-runtime") {
163
+ return `
164
+ import React from "react";
165
+ export function c(size) {
166
+ const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
167
+ if (!internals || !internals.H) {
168
+ return new Array(size);
169
+ }
170
+ return internals.H.useMemoCache(size);
171
+ }
172
+ `;
173
+ }
174
+ if (id === "\0debug-browser") {
175
+ return `
176
+ function debug(namespace) {
177
+ function d(...args) {
178
+ if (typeof localStorage !== "undefined" && localStorage.getItem("DEBUG")) {
179
+ console.log(namespace, ...args);
180
+ }
181
+ }
182
+ d.enabled = false;
183
+ return d;
184
+ }
185
+ debug.enable = function() {};
186
+ debug.disable = function() {};
187
+ debug.enabled = function() { return false; };
188
+ export default debug;
189
+ `;
190
+ }
191
+ },
192
+ },
193
+ ],
112
194
  resolve: {
113
195
  alias: {
114
196
  "kyro:config": resolvedConfig,
115
197
  },
116
198
  },
199
+ optimizeDeps: {
200
+ include: [
201
+ 'use-sync-external-store',
202
+ ],
203
+ exclude: ['debug', 'react/compiler-runtime'],
204
+ },
117
205
  define: {
118
206
  __KYRO_ADMIN_PATH__: JSON.stringify(basePath),
119
207
  __KYRO_API_PATH__: JSON.stringify(apiPath),
208
+ __KYRO_ADMIN_CONFIG_FILE__: JSON.stringify(configFile),
209
+ },
210
+ ssr: {
211
+ noExternal: ['@kyro-cms/admin', '@kyro-cms/core'],
120
212
  },
121
213
  },
122
214
  });
123
215
 
216
+ // Load the user's config at runtime via the kyro:config alias.
217
+ // We set a placeholder here; the actual config is loaded by
218
+ // admin/lib/config.ts via dynamic import of kyro:config.
219
+ (globalThis as any).__KYRO_ADMIN_PROJECT_CONFIG__ = {
220
+ collections: [],
221
+ globals: [],
222
+ adapter: null,
223
+ };
224
+
124
225
  // Inject Admin UI Routes
125
226
  const pages = [
126
227
  { pattern: "", entrypoint: "./pages/index.astro" },
@@ -128,6 +229,7 @@ export function kyroAdmin(options: KyroAdminOptions = {}): AstroIntegration {
128
229
  { pattern: "/register", entrypoint: "./pages/auth/register.astro" },
129
230
  { pattern: "/media", entrypoint: "./pages/media.astro" },
130
231
  { pattern: "/users", entrypoint: "./pages/users/index.astro" },
232
+ { pattern: "/users/new", entrypoint: "./pages/users/new.astro" },
131
233
  { pattern: "/users/[id]", entrypoint: "./pages/users/[id].astro" },
132
234
  { pattern: "/roles", entrypoint: "./pages/roles/index.astro" },
133
235
  { pattern: "/settings", entrypoint: "./pages/settings/index.astro" },
package/src/kyro-cms.d.ts CHANGED
@@ -57,7 +57,7 @@ admin?: {
57
57
  export interface SelectField extends FieldConfig { type: 'select'; options?: { label: string; value: string | number }[] }
58
58
  export interface TextareaField extends FieldConfig { type: 'textarea' }
59
59
  export interface MarkdownField extends FieldConfig { type: 'markdown' }
60
- export interface RichTextField extends FieldConfig { type: 'richText' }
60
+ export interface RichTextField extends FieldConfig { type: 'richtext' }
61
61
  export interface CodeField extends FieldConfig { type: 'code'; language?: string }
62
62
  export interface JSONField extends FieldConfig { type: 'json' }
63
63
  export interface ImageField extends FieldConfig { type: 'image' }
@@ -208,4 +208,9 @@ declare module 'kyro:config' {
208
208
  import type { KyroConfig } from '@kyro-cms/core';
209
209
  const config: KyroConfig;
210
210
  export default config;
211
- }
211
+ }
212
+
213
+ // Injected by Vite's define config during admin integration setup
214
+ declare const __KYRO_ADMIN_CONFIG_FILE__: string;
215
+ declare const __KYRO_ADMIN_PATH__: string;
216
+ declare const __KYRO_API_PATH__: string;
@@ -49,10 +49,22 @@ const { title } = Astro.props;
49
49
  <div class="w-full max-w-md flex flex-col items-center">
50
50
  <!-- Logo -->
51
51
  <div class="w-full text-center mb-8">
52
- <a href="/" class="inline-block">
52
+ <a href="/" class="inline-flex items-center justify-center gap-2">
53
+ <svg class="w-7 h-7 text-[var(--kyro-text-primary)]" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
54
+ <defs>
55
+ <mask id="kyro-cutout-auth">
56
+ <rect width="100" height="100" fill="white" />
57
+ <path d="M 40 89.72 L 40 40 L 89.72 40 A 45 45 0 0 1 40 89.72 Z" stroke="black" stroke-width="8" fill="none" />
58
+ </mask>
59
+ </defs>
60
+ <g mask="url(#kyro-cutout-auth)" fill="currentColor">
61
+ <circle cx="45" cy="45" r="45" />
62
+ <rect x="40" y="40" width="60" height="60" />
63
+ </g>
64
+ </svg>
53
65
  <span
54
66
  class="text-xl font-bold tracking-tighter text-[var(--kyro-text-primary)]"
55
- >KYRO.</span
67
+ >Kyro CMS</span
56
68
  >
57
69
  </a>
58
70
  </div>