@michaelthielemann/kestrel 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +2 -1
  2. package/layers/admin/app/components/AdminAccount.vue +3 -3
  3. package/layers/admin/app/components/AdminNav.vue +1 -1
  4. package/layers/admin/app/components/BlockFields.vue +2 -2
  5. package/layers/admin/app/components/BlockPreview.vue +8 -8
  6. package/layers/admin/app/components/BlockTree.vue +10 -10
  7. package/layers/admin/app/components/BlocksBody.vue +4 -4
  8. package/layers/admin/app/components/CollectionDeleteDialog.vue +7 -7
  9. package/layers/admin/app/components/CollectionEditor.vue +3 -3
  10. package/layers/admin/app/components/CollectionList.vue +91 -792
  11. package/layers/admin/app/components/CollectionListBulkBar.vue +48 -0
  12. package/layers/admin/app/components/CollectionListColumnsPanel.vue +37 -0
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +145 -0
  14. package/layers/admin/app/components/CollectionListPager.vue +70 -0
  15. package/layers/admin/app/components/CollectionListTable.vue +308 -0
  16. package/layers/admin/app/components/EditorStatus.vue +4 -4
  17. package/layers/admin/app/components/FieldsBody.vue +1 -1
  18. package/layers/admin/app/components/LocaleBar.vue +4 -4
  19. package/layers/admin/app/components/PageFields.vue +12 -12
  20. package/layers/admin/app/components/PageFieldsPane.vue +2 -2
  21. package/layers/admin/app/components/SeoFields.vue +19 -19
  22. package/layers/admin/app/components/SingletonEditor.vue +7 -7
  23. package/layers/admin/app/composables/useListBatchActions.ts +61 -0
  24. package/layers/admin/app/composables/useListFilterDraft.ts +128 -0
  25. package/layers/admin/app/composables/useListRows.ts +70 -0
  26. package/layers/admin/app/composables/useListSelection.ts +28 -0
  27. package/layers/admin/app/composables/useToolbarPanel.ts +38 -0
  28. package/layers/admin/app/layouts/admin.vue +7 -7
  29. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +2 -2
  30. package/layers/admin/app/pages/admin/[collection]/[id].vue +11 -11
  31. package/layers/admin/app/pages/admin/[collection]/index.vue +2 -2
  32. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +1 -1
  33. package/layers/admin/app/pages/admin/index.vue +3 -3
  34. package/layers/admin/app/pages/admin/login.vue +6 -6
  35. package/layers/admin/app/pages/admin/media.vue +1 -1
  36. package/layers/admin/app/pages/admin/references.vue +3 -3
  37. package/layers/admin/app/utils/list-cell.ts +46 -0
  38. package/layers/admin/nuxt.config.ts +3 -0
  39. package/layers/core/modules/component-namespace/index.ts +20 -0
  40. package/layers/core/modules/component-namespace/shared.ts +24 -0
  41. package/layers/core/nuxt.config.ts +1 -0
  42. package/layers/core/server/utils/defineCollection.ts +0 -1
  43. package/layers/core/server/utils/serialize-collection.ts +0 -2
  44. package/layers/fields/app/utils/field-factories.ts +1 -1
  45. package/layers/media/app/components/MediaConflictDialog.vue +7 -7
  46. package/layers/media/app/components/MediaDeleteDialog.vue +5 -5
  47. package/layers/media/app/components/MediaGrid.vue +3 -3
  48. package/layers/media/app/components/MediaLibrary.vue +20 -20
  49. package/layers/media/app/components/MediaNewFolderDialog.vue +7 -7
  50. package/layers/media/app/components/MediaPathBar.vue +1 -1
  51. package/layers/media/app/components/MediaPicker.vue +3 -3
  52. package/layers/media/app/components/MediaRenameDialog.vue +8 -8
  53. package/layers/media/app/components/MediaTable.vue +2 -2
  54. package/layers/media/app/components/MediaToolbar.vue +4 -4
  55. package/layers/media/app/components/MediaUploadDialog.vue +9 -9
  56. package/layers/media/app/components/MediaViewer.vue +14 -14
  57. package/layers/media/app/components/field/Media.vue +9 -9
  58. package/layers/media/app/plugins/field-media.client.ts +3 -3
  59. package/layers/media/nuxt.config.ts +5 -1
  60. package/layers/public/app/pages/[...slug].vue +2 -2
  61. package/layers/public/app/pages/__kestrel/preview.vue +1 -1
  62. package/layers/public/nuxt.config.ts +3 -0
  63. package/layers/ui/app/components/field/DeadRefNote.vue +1 -1
  64. package/layers/ui/app/components/field/Slug.vue +2 -2
  65. package/layers/ui/app/components/ui/Button.vue +1 -1
  66. package/layers/ui/app/components/ui/EmptyState.vue +1 -1
  67. package/layers/ui/app/components/ui/TextInput.vue +1 -1
  68. package/layers/ui/app/components/ui/Toasts.vue +1 -1
  69. package/layers/ui/app/composables/useToast.ts +1 -1
  70. package/layers/ui/nuxt.config.ts +5 -1
  71. package/package.json +1 -1
@@ -27,7 +27,7 @@ function rename(c: UploadItem) {
27
27
  </script>
28
28
 
29
29
  <template>
30
- <UiDialog
30
+ <KestrelUiDialog
31
31
  :open="open"
32
32
  :title="t('media.conflicts.title')"
33
33
  :description="t('media.conflicts.desc', { count: conflicts.length })"
@@ -35,22 +35,22 @@ function rename(c: UploadItem) {
35
35
  >
36
36
  <div class="media-conflicts__all">
37
37
  <span>{{ t('media.conflicts.applyAll') }}</span>
38
- <UiButton @click="emit('resolve-all', 'overwrite')">{{ t('media.conflicts.overwriteAll') }}</UiButton>
39
- <UiButton @click="emit('resolve-all', 'rename')">{{ t('media.conflicts.renameAll') }}</UiButton>
40
- <UiButton @click="emit('resolve-all', 'skip')">{{ t('media.conflicts.skipAll') }}</UiButton>
38
+ <KestrelUiButton @click="emit('resolve-all', 'overwrite')">{{ t('media.conflicts.overwriteAll') }}</KestrelUiButton>
39
+ <KestrelUiButton @click="emit('resolve-all', 'rename')">{{ t('media.conflicts.renameAll') }}</KestrelUiButton>
40
+ <KestrelUiButton @click="emit('resolve-all', 'skip')">{{ t('media.conflicts.skipAll') }}</KestrelUiButton>
41
41
  </div>
42
42
  <ul class="media-conflicts__list">
43
43
  <li v-for="c in conflicts" :key="c.id" class="media-conflicts__row">
44
44
  <span class="media-conflicts__name">{{ c.filename }}</span>
45
- <UiTextInput v-model="names[c.id]" :aria-label="t('media.conflicts.newName', { name: c.filename })" />
45
+ <KestrelUiTextInput v-model="names[c.id]" :aria-label="t('media.conflicts.newName', { name: c.filename })" />
46
46
  <div class="media-conflicts__actions">
47
- <UiButton @click="emit('resolve', c.id, 'overwrite', undefined)">{{ t('media.conflicts.overwrite') }}</UiButton>
48
- <UiButton variant="primary" @click="rename(c)">{{ t('media.conflicts.rename') }}</UiButton>
49
- <UiButton @click="emit('resolve', c.id, 'skip', undefined)">{{ t('media.conflicts.skip') }}</UiButton>
47
+ <KestrelUiButton @click="emit('resolve', c.id, 'overwrite', undefined)">{{ t('media.conflicts.overwrite') }}</KestrelUiButton>
48
+ <KestrelUiButton variant="primary" @click="rename(c)">{{ t('media.conflicts.rename') }}</KestrelUiButton>
49
+ <KestrelUiButton @click="emit('resolve', c.id, 'skip', undefined)">{{ t('media.conflicts.skip') }}</KestrelUiButton>
50
50
  </div>
51
51
  </li>
52
52
  </ul>
53
- </UiDialog>
53
+ </KestrelUiDialog>
54
54
  </template>
55
55
 
56
56
  <style lang="scss" scoped>
@@ -63,7 +63,7 @@ function save() {
63
63
  </script>
64
64
 
65
65
  <template>
66
- <UiDialog :open="open" size="xl" :title="file?.filename ?? ''" @update:open="(v) => emit('update:open', v)">
66
+ <KestrelUiDialog :open="open" size="xl" :title="file?.filename ?? ''" @update:open="(v) => emit('update:open', v)">
67
67
  <div v-if="file" class="media-viewer">
68
68
  <div class="media-viewer__preview">
69
69
  <img v-if="isImage" :src="file.src" :srcset="file.srcset" sizes="(max-width: 60rem) 100vw, 60rem" :alt="file.alt ?? file.filename" />
@@ -77,33 +77,33 @@ function save() {
77
77
  <div><dt>{{ t('mediaViewer.folder') }}</dt><dd>{{ file.folder || '/' }}</dd></div>
78
78
  <div><dt>{{ t('mediaViewer.uploaded') }}</dt><dd>{{ uploaded }}</dd></div>
79
79
  </dl>
80
- <UiField v-if="isImage" :label="t('mediaViewer.alt')" :hint="t('mediaViewer.altHint')">
80
+ <KestrelUiField v-if="isImage" :label="t('mediaViewer.alt')" :hint="t('mediaViewer.altHint')">
81
81
  <template #default="f">
82
- <UiTextInput v-model="alt" v-bind="f" @keydown.enter="save" />
82
+ <KestrelUiTextInput v-model="alt" v-bind="f" @keydown.enter="save" />
83
83
  </template>
84
- </UiField>
84
+ </KestrelUiField>
85
85
  <div v-if="showAi" class="media-viewer__ai">
86
- <UiField :label="t('mediaViewer.aiSourceTypeLabel')" :hint="t('mediaViewer.aiSourceTypeHint')">
86
+ <KestrelUiField :label="t('mediaViewer.aiSourceTypeLabel')" :hint="t('mediaViewer.aiSourceTypeHint')">
87
87
  <template #default="f">
88
- <UiSelect v-model="aiSourceType" :options="aiSourceTypeOptions" v-bind="f" />
88
+ <KestrelUiSelect v-model="aiSourceType" :options="aiSourceTypeOptions" v-bind="f" />
89
89
  </template>
90
- </UiField>
91
- <UiField :label="t('mediaViewer.aiNote')" :hint="t('mediaViewer.aiNoteHint')">
90
+ </KestrelUiField>
91
+ <KestrelUiField :label="t('mediaViewer.aiNote')" :hint="t('mediaViewer.aiNoteHint')">
92
92
  <template #default="f">
93
- <UiTextInput v-model="aiNote" v-bind="f" @keydown.enter="save" />
93
+ <KestrelUiTextInput v-model="aiNote" v-bind="f" @keydown.enter="save" />
94
94
  </template>
95
- </UiField>
95
+ </KestrelUiField>
96
96
  </div>
97
- <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
97
+ <KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
98
98
  <!-- Optional per-file extra panel (e.g. proofing comments). Empty by default. -->
99
99
  <slot name="extra" :file="file" />
100
100
  </aside>
101
101
  </div>
102
102
  <template v-if="file" #footer>
103
- <UiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.close') }}</UiButton>
104
- <UiButton v-if="isImage" variant="primary" :disabled="busy || !dirty" @click="save">{{ t('common.save') }}</UiButton>
103
+ <KestrelUiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.close') }}</KestrelUiButton>
104
+ <KestrelUiButton v-if="isImage" variant="primary" :disabled="busy || !dirty" @click="save">{{ t('common.save') }}</KestrelUiButton>
105
105
  </template>
106
- </UiDialog>
106
+ </KestrelUiDialog>
107
107
  </template>
108
108
 
109
109
  <style lang="scss" scoped>
@@ -114,7 +114,7 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
114
114
  </script>
115
115
 
116
116
  <template>
117
- <UiField :id="id" :label="name" :error="error" :required="required">
117
+ <KestrelUiField :id="id" :label="name" :error="error" :required="required">
118
118
  <template #default="f">
119
119
  <div class="field-media">
120
120
  <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- @dragleave is a mouse-only progressive enhancement; the move-earlier/move-later buttons below give the same reorder fully keyboard access -->
@@ -135,7 +135,7 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
135
135
  @drop.prevent="onDrop(i)"
136
136
  @dragend="onDragEnd"
137
137
  >
138
- <MediaThumb v-if="isImage(r)" draggable="false" class="field-media__thumb" :src="r.src" :srcset="srcsetOf(r)" sizes="80px" :alt="r.alt ?? ''" :thumbhash="r.thumbhash" />
138
+ <KestrelMediaThumb v-if="isImage(r)" draggable="false" class="field-media__thumb" :src="r.src" :srcset="srcsetOf(r)" sizes="80px" :alt="r.alt ?? ''" :thumbhash="r.thumbhash" />
139
139
  <a v-else draggable="false" class="field-media__file" :href="r.src" target="_blank" rel="noopener noreferrer" :title="filenameOf(r)">
140
140
  <span class="field-media__badge" aria-hidden="true">{{ fileExt(r) }}</span>
141
141
  <span class="field-media__filename">{{ filenameOf(r) }}</span>
@@ -148,7 +148,7 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
148
148
  :aria-label="t('field.media.move_earlier', { name: nameOf(r, i) })"
149
149
  :disabled="i === 0"
150
150
  @click="moveItem(i, -1)"
151
- ><UiIcon name="chevron-left" size="0.875rem" /></button>
151
+ ><KestrelUiIcon name="chevron-left" size="0.875rem" /></button>
152
152
  <button
153
153
  type="button"
154
154
  draggable="false"
@@ -156,13 +156,13 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
156
156
  :aria-label="t('field.media.move_later', { name: nameOf(r, i) })"
157
157
  :disabled="i === resolved.length - 1"
158
158
  @click="moveItem(i, 1)"
159
- ><UiIcon name="chevron-right" size="0.875rem" /></button>
159
+ ><KestrelUiIcon name="chevron-right" size="0.875rem" /></button>
160
160
  </template>
161
- <button v-if="!disabled" type="button" draggable="false" class="field-media__remove" :aria-label="t('field.media.remove', { name: nameOf(r, i) })" @click="removeId(r.id)"><UiIcon name="x" size="1rem" /></button>
161
+ <button v-if="!disabled" type="button" draggable="false" class="field-media__remove" :aria-label="t('field.media.remove', { name: nameOf(r, i) })" @click="removeId(r.id)"><KestrelUiIcon name="x" size="1rem" /></button>
162
162
  </li>
163
163
  </ul>
164
164
  <p v-else class="field-media__empty">{{ t('field.media.empty') }}</p>
165
- <UiButton
165
+ <KestrelUiButton
166
166
  v-if="!disabled"
167
167
  :id="f.id"
168
168
  type="button"
@@ -172,12 +172,12 @@ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
172
172
  @click="pickerOpen = true"
173
173
  >
174
174
  {{ multiple ? t('field.media.add') : (resolved.length ? t('field.media.replace') : t('field.media.select')) }}
175
- </UiButton>
175
+ </KestrelUiButton>
176
176
  <span class="field-media__live" aria-live="polite">{{ liveMessage }}</span>
177
177
  </div>
178
- <MediaPicker v-model:open="pickerOpen" :multiple="multiple" :accept="accept" :initial-folder="initialFolder" :initial-selected="ids" @confirm="onConfirm" />
178
+ <KestrelMediaPicker v-model:open="pickerOpen" :multiple="multiple" :accept="accept" :initial-folder="initialFolder" :initial-selected="ids" @confirm="onConfirm" />
179
179
  </template>
180
- </UiField>
180
+ </KestrelUiField>
181
181
  </template>
182
182
 
183
183
  <style lang="scss" scoped>
@@ -1,6 +1,6 @@
1
- // components are NOT auto-imported in a plugin's script context — pull FieldMedia from #components
2
- import { FieldMedia } from '#components'
1
+ // components are NOT auto-imported in a plugin's script context — pull KestrelFieldMedia from #components
2
+ import { KestrelFieldMedia } from '#components'
3
3
 
4
4
  export default defineNuxtPlugin(() => {
5
- registerFieldComponent('media', FieldMedia)
5
+ registerFieldComponent('media', KestrelFieldMedia)
6
6
  })
@@ -1,3 +1,7 @@
1
+ import { kestrelComponents } from '../core/modules/component-namespace/shared'
2
+
1
3
  // Marks `media` as a Nuxt layer (so its components/server code are auto-registered). The media config
2
4
  // + the `kestrel` module that resolves it now live in the core layer (single non-auth config source).
3
- export default defineNuxtConfig({})
5
+ export default defineNuxtConfig({
6
+ components: [kestrelComponents(import.meta.url)],
7
+ })
@@ -205,9 +205,9 @@ useSeoMeta({
205
205
  <!-- Editor preview: the bridge swaps in the editor's live (unsaved) tree over postMessage and makes
206
206
  blocks selectable; the saved content renders until the first message. Normal path unchanged. -->
207
207
  <LazyKestrelPreviewBridge v-if="previewActive" :blocks="(page?.content as any[]) ?? []" v-slot="{ blocks }">
208
- <BlockRenderer :blocks="(blocks as any[])" />
208
+ <KestrelBlockRenderer :blocks="(blocks as any[])" />
209
209
  </LazyKestrelPreviewBridge>
210
- <BlockRenderer v-else :blocks="(page?.content as any[]) ?? []" />
210
+ <KestrelBlockRenderer v-else :blocks="(page?.content as any[]) ?? []" />
211
211
  </article>
212
212
  </NuxtLayout>
213
213
  </template>
@@ -41,6 +41,6 @@ useHead({
41
41
 
42
42
  <template>
43
43
  <KestrelPreviewBridge :blocks="ticketBlocks" v-slot="{ blocks }">
44
- <BlockRenderer :blocks="(blocks as any[])" />
44
+ <KestrelBlockRenderer :blocks="(blocks as any[])" />
45
45
  </KestrelPreviewBridge>
46
46
  </template>
@@ -1,6 +1,9 @@
1
1
  import { fileURLToPath } from 'node:url'
2
2
 
3
+ import { kestrelComponents } from '../core/modules/component-namespace/shared'
4
+
3
5
  export default defineNuxtConfig({
6
+ components: [kestrelComponents(import.meta.url)],
4
7
  modules: [
5
8
  fileURLToPath(new URL('./modules/prerender-routes/index.ts', import.meta.url)),
6
9
  fileURLToPath(new URL('./modules/prune-media/index.ts', import.meta.url)),
@@ -9,7 +9,7 @@ const { t } = useT()
9
9
 
10
10
  <template>
11
11
  <p v-if="show" class="field-dead-ref" role="note">
12
- <UiIcon name="triangle-alert" :size="13" class="field-dead-ref__icon" />{{ t('deadRefs.fieldNote') }}
12
+ <KestrelUiIcon name="triangle-alert" :size="13" class="field-dead-ref__icon" />{{ t('deadRefs.fieldNote') }}
13
13
  </p>
14
14
  </template>
15
15
 
@@ -25,7 +25,7 @@ function onBlur() {
25
25
  </script>
26
26
 
27
27
  <template>
28
- <UiField :id="id" :label="name" :error="error ?? undefined">
28
+ <KestrelUiField :id="id" :label="name" :error="error ?? undefined">
29
29
  <template #default="f">
30
30
  <div class="field-slug" :data-prefixed="!!prefix">
31
31
  <span v-if="prefix" class="field-slug__prefix">{{ prefix }}</span>
@@ -43,7 +43,7 @@ function onBlur() {
43
43
  />
44
44
  </div>
45
45
  </template>
46
- </UiField>
46
+ </KestrelUiField>
47
47
  </template>
48
48
 
49
49
  <style lang="scss" scoped>
@@ -23,7 +23,7 @@ withDefaults(
23
23
  :aria-busy="loading || undefined"
24
24
  >
25
25
  <span v-if="loading" class="ui-button__spinner" aria-hidden="true" />
26
- <UiIcon v-else-if="icon" :name="icon" class="ui-button__icon" />
26
+ <KestrelUiIcon v-else-if="icon" :name="icon" class="ui-button__icon" />
27
27
  <span v-if="$slots.default" class="ui-button__label"><slot /></span>
28
28
  </button>
29
29
  </template>
@@ -6,7 +6,7 @@ defineProps<{ icon?: IconName; title: string; description?: string }>()
6
6
 
7
7
  <template>
8
8
  <div class="ui-empty">
9
- <UiIcon v-if="icon" :name="icon" size="1.5rem" class="ui-empty__icon" />
9
+ <KestrelUiIcon v-if="icon" :name="icon" size="1.5rem" class="ui-empty__icon" />
10
10
  <p class="ui-empty__title">{{ title }}</p>
11
11
  <p v-if="description" class="ui-empty__desc">{{ description }}</p>
12
12
  <div v-if="$slots.action" class="ui-empty__action"><slot name="action" /></div>
@@ -49,7 +49,7 @@ const inputType = computed(() => (revealable.value && revealed.value ? 'text' :
49
49
  :aria-label="revealed ? t('input.hidePassword') : t('input.showPassword')"
50
50
  @click="revealed = !revealed"
51
51
  >
52
- <UiIcon :name="revealed ? 'eye-off' : 'eye'" size="1.125rem" />
52
+ <KestrelUiIcon :name="revealed ? 'eye-off' : 'eye'" size="1.125rem" />
53
53
  </button>
54
54
  </div>
55
55
  </template>
@@ -30,7 +30,7 @@ const { t } = useT()
30
30
  >
31
31
  <span class="ui-toast__msg">{{ toast.message }}</span>
32
32
  <button type="button" class="ui-toast__close" :aria-label="t('toast.dismiss')" @click="toasts.dismiss(toast.id)">
33
- <UiIcon name="x" size="0.875rem" />
33
+ <KestrelUiIcon name="x" size="0.875rem" />
34
34
  </button>
35
35
  </div>
36
36
  </TransitionGroup>
@@ -6,7 +6,7 @@ export interface Toast { id: number; type: ToastType; message: string }
6
6
  /**
7
7
  * Transient notification queue: `push` returns an id, auto-expires after `timeout` (0 = sticky),
8
8
  * and the queue is capped (oldest dropped). Pure factory so the logic is unit-testable; `useToast`
9
- * shares one instance app-wide. The teleported `<UiToasts>` container renders `items`.
9
+ * shares one instance app-wide. The teleported `<KestrelUiToasts>` container renders `items`.
10
10
  */
11
11
  export function createToastStore(opts: { limit?: number; defaultTimeout?: number } = {}) {
12
12
  const limit = opts.limit ?? 4
@@ -1,5 +1,9 @@
1
+ import { kestrelComponents } from '../core/modules/component-namespace/shared'
2
+
1
3
  // The UI design system (tokens / reset / base in app/assets/scss/main.scss) is intentionally NOT loaded
2
4
  // app-globally — that would leak Kestrel's CSS reset + base styles onto a consumer's public site and fight
3
5
  // their own CSS (e.g. Bootstrap). It is imported by the admin layout (layers/admin/app/layouts/admin.vue)
4
6
  // instead, which scopes it to /admin: every admin route uses that layout, and /admin/** is ssr:false.
5
- export default defineNuxtConfig({})
7
+ export default defineNuxtConfig({
8
+ components: [kestrelComponents(import.meta.url)],
9
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michaelthielemann/kestrel",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "A slim, collection-driven Nuxt 4 CMS meta-layer with a runtime schema engine. Add `extends: ['@michaelthielemann/kestrel']`, define collections, and the database migrates itself.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Michael Thielemann <283621694+MichaelThielemann@users.noreply.github.com>",