@xleddyl/nuxt-cms 0.4.1 → 0.4.3

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 (60) hide show
  1. package/README.md +74 -96
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +86 -22
  4. package/dist/runtime/app/components/cms/BlocksField.vue +30 -5
  5. package/dist/runtime/app/components/cms/DropdownMenu.vue +1 -5
  6. package/dist/runtime/app/components/cms/EntryDrawer.d.vue.ts +3 -0
  7. package/dist/runtime/app/components/cms/EntryDrawer.vue +32 -13
  8. package/dist/runtime/app/components/cms/EntryDrawer.vue.d.ts +3 -0
  9. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
  10. package/dist/runtime/app/components/cms/EntryForm.vue +20 -3
  11. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
  12. package/dist/runtime/app/components/cms/FieldInput.vue +2 -2
  13. package/dist/runtime/app/components/cms/Form.d.vue.ts +8 -6
  14. package/dist/runtime/app/components/cms/Form.vue +26 -5
  15. package/dist/runtime/app/components/cms/Form.vue.d.ts +8 -6
  16. package/dist/runtime/app/components/cms/FormField.vue +6 -1
  17. package/dist/runtime/app/components/cms/ImportModal.d.vue.ts +18 -0
  18. package/dist/runtime/app/components/cms/ImportModal.vue +252 -0
  19. package/dist/runtime/app/components/cms/ImportModal.vue.d.ts +18 -0
  20. package/dist/runtime/app/components/cms/ListFilters.d.vue.ts +18 -0
  21. package/dist/runtime/app/components/cms/ListFilters.vue +151 -0
  22. package/dist/runtime/app/components/cms/ListFilters.vue.d.ts +18 -0
  23. package/dist/runtime/app/components/cms/RelationField.vue +3 -1
  24. package/dist/runtime/app/components/cms/VisualEditor.vue +81 -12
  25. package/dist/runtime/app/pages/admin-collection.vue +158 -7
  26. package/dist/runtime/app/pages/admin-content.vue +18 -7
  27. package/dist/runtime/app/pages/admin-entry.vue +1 -0
  28. package/dist/runtime/app/pages/admin-preview.vue +6 -3
  29. package/dist/runtime/app/preview/PreviewOverlay.vue +4 -0
  30. package/dist/runtime/app/utils/ui.d.ts +2 -1
  31. package/dist/runtime/app/utils/ui.js +1 -0
  32. package/dist/runtime/assets/main.css +1 -1
  33. package/dist/runtime/server/api/collection-export.get.d.ts +2 -0
  34. package/dist/runtime/server/api/collection-export.get.js +67 -0
  35. package/dist/runtime/server/api/collection-import.post.d.ts +2 -0
  36. package/dist/runtime/server/api/collection-import.post.js +85 -0
  37. package/dist/runtime/server/api/collection.get.js +7 -15
  38. package/dist/runtime/server/utils/import.d.ts +31 -0
  39. package/dist/runtime/server/utils/import.js +202 -0
  40. package/dist/runtime/server/utils/list-query.d.ts +15 -0
  41. package/dist/runtime/server/utils/list-query.js +54 -0
  42. package/dist/runtime/server/utils/media-check.d.ts +2 -0
  43. package/dist/runtime/server/utils/media-check.js +1 -1
  44. package/dist/runtime/server/utils/media-references.js +4 -1
  45. package/dist/runtime/server/utils/registry.js +1 -1
  46. package/dist/runtime/server/utils/search.js +23 -4
  47. package/dist/runtime/shared/duplicate.d.ts +10 -0
  48. package/dist/runtime/shared/duplicate.js +54 -0
  49. package/dist/runtime/shared/graphql-sdl.js +4 -7
  50. package/dist/runtime/shared/index.d.ts +49 -2
  51. package/dist/runtime/shared/index.js +33 -0
  52. package/dist/runtime/shared/layout.d.ts +3 -0
  53. package/dist/runtime/shared/layout.js +35 -2
  54. package/dist/runtime/shared/list-filters.d.ts +20 -0
  55. package/dist/runtime/shared/list-filters.js +83 -0
  56. package/dist/runtime/shared/transfer.d.ts +70 -0
  57. package/dist/runtime/shared/transfer.js +256 -0
  58. package/dist/runtime/shared/validation.d.ts +15 -2
  59. package/dist/runtime/shared/validation.js +102 -9
  60. package/package.json +1 -1
package/README.md CHANGED
@@ -4,131 +4,109 @@
4
4
  [![CI](https://github.com/xleddyl/nuxt-cms/actions/workflows/ci.yml/badge.svg)](https://github.com/xleddyl/nuxt-cms/actions/workflows/ci.yml)
5
5
  [![license](https://img.shields.io/npm/l/%40xleddyl%2Fnuxt-cms)](LICENSE)
6
6
 
7
- nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight CMS together with your Nuxt frontend, no external CMS, no second service to deploy, nothing extra to pay for. Content lives next to the code that renders it: you define content types in code, editors manage entries from a built-in admin panel, and the same server that serves your site serves your content.
7
+ nuxt-cms is a Nuxt module that adds a CMS to your Nuxt app. The CMS runs in the Nitro server of the app. You deploy one app, with no external CMS service.
8
8
 
9
- - **Zero extra infrastructure**: the CMS runs inside your app's Nitro server; you deploy one thing.
10
- - **Content types in code**: a `cms.config.ts` with `defineCmsConfig()` declares collections, single documents, relations, blocks and translatable fields; database schema, migrations and TypeScript types are generated from it.
11
- - **Content with blocks**: news, blog and other content types with title, slug, cover, SEO fields and a body built from your own site components, drafts and scheduled publishing; `<CmsBlocks>` renders the body with typed props.
12
- - **Visual editor**: content items (and any blocks field built from site components) are edited on a live preview of your own site, with drag and drop, a block palette, per-locale and mobile previews, scheduling and publishing.
13
- - **Admin panel at `/cms`**: entry editing with validation, drafts, media library with one-click download of the original file (S3-compatible storage, a directory on the server's disk, or a local mode backed directly by your `public/` folder), a global search (⌘K / Ctrl K) over labels and every stored value in all locales, single-admin auth from env credentials.
14
- - **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination; fields marked `private` stay out of it.
15
- - **SQLite, Postgres, libSQL/Turso or Cloudflare D1**: a local file database by default, one config line to switch (including remote SQLite over the network).
16
- - **Runs on serverless too**: migrations are baked into the server bundle, uploads are presigned straight to your bucket, sessions are sealed cookies. Nothing on the request path needs a local disk or sticky instances.
9
+ ![Visual editor](docs/screenshots/visual-editor.png)
17
10
 
18
- ## Screenshots
19
-
20
- ![Collection view](docs/screenshots/collection.png)
11
+ ## Features
21
12
 
22
- ![Entry editor](docs/screenshots/entry.png)
13
+ - **Content types in code.** You declare collections, singles, pages and content types (news, blog) in `cms.config.ts`. The module generates the database schema, the migrations and the TypeScript types.
14
+ - **Admin panel at `/cms`.** Editors manage entries, drafts and media, filter and duplicate entries, and import or export a collection as JSON or CSV. The panel has a light and a dark theme, a global search (⌘K) and admin accounts.
15
+ - **Visual editor.** Editors build content from your own site components, on a live preview of the site.
16
+ - **GraphQL API.** A read-only, typed API with filters, sorting and pagination. Composables such as `useCmsCollection` and `useCmsContent` query it for you.
17
+ - **Your database.** SQLite (default), Postgres, libSQL/Turso or Cloudflare D1.
18
+ - **Your storage.** S3-compatible storage, a folder on the server disk, or your `public/` folder.
19
+ - **Serverless ready.** The build puts the migrations in the server bundle. Uploads go directly to the bucket. Sessions are sealed cookies.
23
20
 
24
- ![Media library](docs/screenshots/media.png)
21
+ ## Screenshots
25
22
 
26
- ## Installation
23
+ | Collection | Entry editor |
24
+ | --- | --- |
25
+ | ![Collection](docs/screenshots/collection.png) | ![Entry editor](docs/screenshots/entry.png) |
26
+ | **Media library** | **Search** |
27
+ | ![Media library](docs/screenshots/media.png) | ![Search](docs/screenshots/search.png) |
27
28
 
28
- ```bash
29
- npm install @xleddyl/nuxt-cms
30
- ```
29
+ ## Quick start
31
30
 
32
- Register the module and configure it under the `cms` key in `nuxt.config.ts`:
31
+ 1. Install the module:
33
32
 
34
- ```ts
35
- export default defineNuxtConfig({
36
- modules: ['@xleddyl/nuxt-cms'],
37
- cms: {
38
- i18n: {
39
- locales: ['en', 'it'],
40
- defaultLocale: 'en',
41
- },
42
- },
43
- })
44
- ```
33
+ ```bash
34
+ npm install @xleddyl/nuxt-cms
35
+ ```
45
36
 
46
- `database` defaults to SQLite (`{ driver: 'sqlite', path: 'data/cms.db' }`) and can be omitted
47
- entirely. Switch it to `{ driver: 'postgres', url: '...' }`,
48
- `{ driver: 'libsql', url: '...', authToken: '...' }` (Turso/remote) or
49
- `{ driver: 'd1', binding: 'DB' }` (Cloudflare Workers); see
50
- [Configuration](docs/configuration.md) for every option and
51
- [Deployment](docs/deployment.md) for which driver each host supports.
37
+ 2. Add the module to `nuxt.config.ts`:
52
38
 
53
- Each driver brings its own client, and only the one you use has to be installed: SQLite works out of
54
- the box, `postgres` needs `pg`, `libsql` needs `@libsql/client`, and `d1` needs nothing extra. The
55
- build stops with an explicit message if the client for the configured driver is missing.
39
+ ```ts
40
+ export default defineNuxtConfig({
41
+ modules: ['@xleddyl/nuxt-cms'],
42
+ })
43
+ ```
56
44
 
57
- Then declare your content types in a `cms.config.ts` at the project root with `defineCmsConfig()`.
45
+ 3. Declare your content types in `cms.config.ts`, at the project root:
58
46
 
59
- ### Admin branding
47
+ ```ts
48
+ import { defineCmsConfig } from '#nuxt-cms'
60
49
 
61
- `cms.admin.title`, `cms.admin.subtitle` and `cms.admin.logo` replace the name, the tagline and the
62
- mark in the admin sidebar, mobile bar and login page. `logo` takes a URL or public path, a path to
63
- an `.svg` file in the project (inlined, so it follows the light and dark theme) or raw `<svg>`
64
- markup; see [Configuration](docs/configuration.md#admin-branding).
50
+ export default defineCmsConfig({
51
+ events: {
52
+ id: 'events',
53
+ label: 'Events',
54
+ kind: 'collection',
55
+ titleField: 'title',
56
+ fields: {
57
+ title: { label: 'Title', type: 'text', required: true },
58
+ date: { label: 'Date', type: 'date' },
59
+ poster: { label: 'Poster', type: 'media', mediaType: 'image' },
60
+ },
61
+ },
62
+ })
63
+ ```
65
64
 
66
- ### Disabling the CMS
65
+ 4. Set the admin credentials in `.env`:
67
66
 
68
- Keep the module in `modules[]` at all times and turn it off with the `enabled` option or the
69
- `NUXT_CMS_ENABLED` env var. When disabled the module registers no-op query composables and keeps the
70
- generated types, and nothing else, so components can call them unconditionally and simply render
71
- their empty states:
67
+ ```bash
68
+ NUXT_CMS_ADMIN_EMAIL=admin@example.com
69
+ NUXT_CMS_ADMIN_PASSWORD=change-me
70
+ NUXT_SESSION_PASSWORD=a-random-secret-of-at-least-32-characters
71
+ ```
72
72
 
73
- ```ts
74
- export default defineNuxtConfig({
75
- modules: ['@xleddyl/nuxt-cms'],
76
- cms: { enabled: false },
77
- })
78
- ```
73
+ 5. Start the app with `npm run dev`. Open `http://localhost:3000/cms` and sign in.
79
74
 
80
- See [Configuration](docs/configuration.md#enabled) for the resolution order.
81
-
82
- ### Environment variables
83
-
84
- Every secret maps to runtime config, so it can be set as an env var instead of in `nuxt.config.ts`:
85
-
86
- | Variable | Required | Purpose |
87
- | --- | --- | --- |
88
- | `NUXT_CMS_ENABLED` | no | set to `0` / `false` to disable the CMS (default enabled) |
89
- | `NUXT_CMS_ADMIN_EMAIL` | yes | admin login email |
90
- | `NUXT_CMS_ADMIN_PASSWORD` | yes | admin login password |
91
- | `NUXT_SESSION_PASSWORD` | in production | session encryption key (32+ chars) |
92
- | `NUXT_CMS_DATABASE_URL` | with `postgres` / remote `libsql` | Postgres connection string or libSQL URL |
93
- | `NUXT_CMS_DATABASE_AUTH_TOKEN` | with remote `libsql` | libSQL/Turso auth token |
94
- | `NUXT_CMS_MIGRATE_ON_BOOT` | no | `false` to apply migrations in CI instead of on boot |
95
- | `NUXT_CMS_MEDIA_ENDPOINT` | for media | S3-compatible endpoint |
96
- | `NUXT_CMS_MEDIA_REGION` | for media | S3 region (default `auto`) |
97
- | `NUXT_CMS_MEDIA_BUCKET` | for media | bucket name |
98
- | `NUXT_CMS_MEDIA_ACCESS_KEY_ID` | for media | S3 access key id |
99
- | `NUXT_CMS_MEDIA_SECRET_ACCESS_KEY` | for media | S3 secret access key |
100
- | `NUXT_PUBLIC_CMS_MEDIA_BASE_URL` | for media | public base URL for uploaded files |
101
- | `NUXT_CMS_MEDIA_DIR` | for filesystem media | directory for uploaded files (default `data/media`) |
75
+ 6. Read the content in a page:
102
76
 
103
- ## Documentation
77
+ ```vue
78
+ <script setup lang="ts">
79
+ const { data: events } = await useCmsCollection('events', { sort: [{ field: 'date' }] })
80
+ </script>
81
+ ```
104
82
 
105
- Full documentation lives in [`docs/`](docs/README.md):
83
+ The database is a local SQLite file (`data/cms.db`) by default. To use a different database or media storage, see [Configuration](docs/configuration.md).
106
84
 
107
- - [Getting started](docs/getting-started.md) — install, configure, first content type, run.
108
- - [Configuration](docs/configuration.md) — every `cms.*` option and the `NUXT_CMS_*` env vars.
109
- - [Database](docs/database.md) — SQLite, Postgres, libSQL/Turso and D1 drivers, migrations, studio.
110
- - [Schema](docs/schema.md) — `defineCmsConfig`, entries, pages, field types, relations, blocks, i18n.
111
- - [Querying content](docs/querying.md) — GraphQL API, `useCmsSingle` / `useCmsCollection` / `useCmsPage` / `useCmsContents` / `useCmsContent` / `useCms` / `$cmsQuery`, `<CmsBlocks>`, filters, sorting, pagination.
112
- - [Admin panel & security](docs/admin.md) — pages, authentication, sessions, admin REST API.
113
- - [Media](docs/media.md) — S3-compatible storage, filesystem storage on the server's disk, or local mode backed by your `public/` folder, upload flow, allowed file types.
114
- - [Deployment](docs/deployment.md) — host/driver matrix, migrations on serverless, horizontal scaling, Cloudflare Workers.
85
+ ## Documentation
115
86
 
116
- ## LLM guide
87
+ | Page | Content |
88
+ | --- | --- |
89
+ | [Getting started](docs/getting-started.md) | Install, configure, first content type, run |
90
+ | [Configuration](docs/configuration.md) | Every `cms.*` option and every env var |
91
+ | [Database](docs/database.md) | Drivers, migrations, Drizzle Studio |
92
+ | [Schema](docs/schema.md) | Entry kinds, field types, relations, blocks, i18n, form layout |
93
+ | [Querying content](docs/querying.md) | GraphQL API, composables, `<CmsBlocks>` |
94
+ | [Admin panel & security](docs/admin.md) | Admin pages, search, visual editor, accounts, sessions |
95
+ | [Media](docs/media.md) | Storage modes, uploads, allowed file types |
96
+ | [Deployment](docs/deployment.md) | Hosts, migrations on serverless, scaling |
117
97
 
118
- [llm.txt](llm.txt) is a compact reference meant to be fed to an LLM: schema definition (`cms.config.ts`, field types, relations, blocks, i18n), the `useCms` / `$cmsQuery` composables and the generated GraphQL API (filters, sorting, pagination).
98
+ [llm.txt](llm.txt) is a compact reference for LLMs: schema, composables and GraphQL API.
119
99
 
120
100
  ## Development
121
101
 
122
102
  ```bash
123
103
  pnpm install
124
- pnpm dev:prepare # module stub + playground prepare (first time and after module.ts changes)
125
- pnpm dev # playground on http://localhost:3000 (generates + applies migrations on boot)
126
- pnpm db:studio # drizzle studio
104
+ pnpm dev:prepare # first time, and after a change to src/module.ts
105
+ pnpm dev # playground on http://localhost:3000
106
+ pnpm test
127
107
  ```
128
108
 
129
- ## Configuration
130
-
131
- All config lives under `cms` in `nuxt.config.ts` (see `ModuleOptions` in `src/module.ts`); secrets map to runtime config, so they can be provided as `NUXT_CMS_*` env vars instead (plus `NUXT_SESSION_PASSWORD` for sessions in production). `cms.database.driver: 'postgres'` switches driver, drizzle dialect and migration folder together; re-run `dev:prepare` after switching.
109
+ The playground (`playground/`) uses every option of the module. Copy `playground/.env.example` to `playground/.env` before the first start.
132
110
 
133
111
  ## License
134
112
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.4.1",
4
+ "version": "0.4.3",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -11,7 +11,7 @@ import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal'
11
11
  import { createJiti } from 'jiti';
12
12
  import { typeName, blockTypeName as blockTypeName$1, blocksFieldTypeName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
13
13
  import { migrationsDirFor } from '../dist/runtime/shared/migrations-dir.js';
14
- import { entryFieldsFor, isPrivateField, blockTypeName, pageColumnFields, isTranslatableField, isTranslatableMediaField, isMultiSelect, pageAllFields, normalizeContentEntries, PAGE_PATH_FIELD, pageRoutes, isCollectionKind, layoutErrors, listErrors, mobileMediaKey, fieldConditions, CONTENT_SLUG_FIELD, pageFieldsTableName, pageMediaTableName, CONTENT_SYSTEM_FIELDS, isRequiredField, pageFields, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE, normalizeCmsConfig } from '../dist/runtime/shared/index.js';
14
+ import { entryFieldsFor, isPrivateField, blockTypeName, pageColumnFields, isTranslatableField, isTranslatableMediaField, isMultiSelect, pageAllFields, normalizeContentEntries, PAGE_PATH_FIELD, pageRoutes, isCollectionKind, layoutErrors, listErrors, mobileMediaKey, fieldConditions, selectOptionValues, CONTENT_SLUG_FIELD, pageFieldsTableName, pageMediaTableName, CONTENT_SYSTEM_FIELDS, isRequiredField, pageFields, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE, normalizeCmsConfig } from '../dist/runtime/shared/index.js';
15
15
  import { previewPathErrors, DEFAULT_CMS_PREVIEW_PATH } from '../dist/runtime/shared/preview.js';
16
16
  import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
17
17
  import { createHash } from 'node:crypto';
@@ -405,6 +405,8 @@ function validateConfig(config, i18n) {
405
405
  errors.push(`${at}: list needs kind 'collection' or 'content'`);
406
406
  if (entry.icon !== void 0 && (typeof entry.icon !== "string" || !entry.icon))
407
407
  errors.push(`${at}: icon must be a non-empty string`);
408
+ if (entry.validate !== void 0 && typeof entry.validate !== "function")
409
+ errors.push(`${at}: validate must be a function`);
408
410
  const columnNames = /* @__PURE__ */ new Set();
409
411
  for (const [key, field] of Object.entries(allFields)) {
410
412
  const fat = `${at}, field '${key}'`;
@@ -432,12 +434,7 @@ function validateConfig(config, i18n) {
432
434
  else if (allFields[mobileKey] && allFields[mobileKey].mobileOf !== key)
433
435
  errors.push(`${fat}: mobile needs the key '${mobileKey}', which is already declared`);
434
436
  }
435
- if (field.type === "select") {
436
- if (!field.options?.length)
437
- errors.push(`${fat}: select requires a non-empty options array`);
438
- else if (new Set(field.options).size !== field.options.length)
439
- errors.push(`${fat}: select options must be unique`);
440
- }
437
+ if (field.type === "select") errors.push(...selectOptionErrors(fat, field));
441
438
  if (field.showIf) {
442
439
  if (entry.titleField === key)
443
440
  errors.push(`${fat}: the titleField cannot be conditional`);
@@ -472,7 +469,8 @@ function validateConfig(config, i18n) {
472
469
  if (condition.in && condition.eq !== void 0)
473
470
  errors.push(`${cat}: showIf accepts either 'eq' or 'in', not both`);
474
471
  if (target.type === "select") {
475
- const unknown = values.filter((value) => !target.options?.includes(String(value)));
472
+ const options = selectOptionValues(target);
473
+ const unknown = values.filter((value) => !options.includes(String(value)));
476
474
  if (unknown.length)
477
475
  errors.push(
478
476
  `${cat}: ${unknown.map((value) => `'${String(value)}'`).join(", ")} not in the options of '${condition.field}'`
@@ -488,8 +486,11 @@ function validateConfig(config, i18n) {
488
486
  else if (isTranslatableField(source) && !(entry.kind === "content" && key === CONTENT_SLUG_FIELD))
489
487
  errors.push(`${fat}: slug source '${field.from}' cannot be translatable`);
490
488
  }
489
+ if (field.type !== "blocks" && (field.min !== void 0 || field.max !== void 0))
490
+ errors.push(`${fat}: min and max are only supported on blocks fields`);
491
491
  if (field.type === "blocks") {
492
492
  if (field.translatable) errors.push(`${fat}: blocks fields cannot be translatable`);
493
+ errors.push(...blocksCountErrors(fat, field));
493
494
  if (!field.blocks || !Object.keys(field.blocks).length) {
494
495
  errors.push(`${fat}: blocks requires a non-empty blocks map`);
495
496
  }
@@ -527,9 +528,8 @@ function validateConfig(config, i18n) {
527
528
  errors.push(`${bfat}: showIf is not supported inside blocks`);
528
529
  if (blockField.mobile)
529
530
  errors.push(`${bfat}: mobile is not supported inside blocks`);
530
- if (blockField.type === "select" && !blockField.options?.length) {
531
- errors.push(`${bfat}: select requires a non-empty options array`);
532
- }
531
+ if (blockField.type === "select")
532
+ errors.push(...selectOptionErrors(bfat, blockField));
533
533
  if (blockField.type === "select" && blockField.multiple) {
534
534
  errors.push(`${bfat}: multiple select is not supported inside blocks`);
535
535
  }
@@ -622,8 +622,49 @@ function contentErrors(at, entry) {
622
622
  }
623
623
  return errors;
624
624
  }
625
+ function selectOptionErrors(at, field) {
626
+ const errors = [];
627
+ if (!Array.isArray(field.options) || !field.options.length)
628
+ return [`${at}: select requires a non-empty options array`];
629
+ const values = /* @__PURE__ */ new Set();
630
+ for (const option of field.options) {
631
+ if (typeof option === "string") {
632
+ if (!option) errors.push(`${at}: select options cannot be empty strings`);
633
+ else if (values.has(option)) errors.push(`${at}: select option '${option}' is duplicated`);
634
+ values.add(option);
635
+ continue;
636
+ }
637
+ if (!option || typeof option !== "object") {
638
+ errors.push(`${at}: select options must be strings or { value, label } objects`);
639
+ continue;
640
+ }
641
+ const { value, label } = option;
642
+ if (typeof value !== "string" || !value) {
643
+ errors.push(`${at}: every select option object needs a non-empty string value`);
644
+ continue;
645
+ }
646
+ if (values.has(value)) errors.push(`${at}: select option '${value}' is duplicated`);
647
+ values.add(value);
648
+ if (typeof label !== "string" || !label.trim())
649
+ errors.push(`${at}: select option '${value}' needs a non-empty label`);
650
+ }
651
+ return errors;
652
+ }
653
+ function blocksCountErrors(at, field) {
654
+ const errors = [];
655
+ const isCount = (value, least) => typeof value === "number" && Number.isInteger(value) && value >= least;
656
+ if (field.min !== void 0 && !isCount(field.min, 0))
657
+ errors.push(`${at}: min must be an integer of 0 or more`);
658
+ if (field.max !== void 0 && !isCount(field.max, 1))
659
+ errors.push(`${at}: max must be an integer of 1 or more`);
660
+ if (isCount(field.min, 0) && isCount(field.max, 1) && field.min > field.max)
661
+ errors.push(`${at}: min (${field.min}) cannot be greater than max (${field.max})`);
662
+ return errors;
663
+ }
625
664
  function blockMetaErrors(at, block) {
626
665
  const errors = [];
666
+ if (block.validate !== void 0 && typeof block.validate !== "function")
667
+ errors.push(`${at}: validate must be a function`);
627
668
  if (block.component !== void 0) {
628
669
  if (typeof block.component !== "string" || !block.component.trim())
629
670
  errors.push(`${at}: component must be a non-empty string`);
@@ -675,7 +716,7 @@ function snakeCase(key) {
675
716
  function jsonExpr(col, dialect) {
676
717
  return dialect === "postgres" ? `jsonb('${col}')` : `text('${col}', { mode: 'json' })`;
677
718
  }
678
- function columnExpr(key, field, dialect) {
719
+ function columnExpr(key, field, dialect, table) {
679
720
  const col = snakeCase(key);
680
721
  let expr;
681
722
  if (isTranslatableField(field)) {
@@ -705,7 +746,8 @@ function columnExpr(key, field, dialect) {
705
746
  break;
706
747
  case "relation": {
707
748
  const onDelete = field.onDelete ?? (field.required ? "restrict" : "set null");
708
- expr = `text('${col}').references(() => ${field.to}.id, { onDelete: '${onDelete}' })`;
749
+ const target = field.to === table ? `(): ${anyColumnType(dialect)}` : "()";
750
+ expr = `text('${col}').references(${target} => ${field.to}.id, { onDelete: '${onDelete}' })`;
709
751
  if (field.cardinality === "one-to-one") expr += ".unique()";
710
752
  break;
711
753
  }
@@ -715,6 +757,12 @@ function columnExpr(key, field, dialect) {
715
757
  if (isRequiredField(field)) expr += ".notNull()";
716
758
  return ` ${key}: ${expr},`;
717
759
  }
760
+ function anyColumnType(dialect) {
761
+ return dialect === "postgres" ? "AnyPgColumn" : "AnySQLiteColumn";
762
+ }
763
+ function isSelfRelation(table, field) {
764
+ return field.type === "relation" && !isManyToMany(field) && field.to === table;
765
+ }
718
766
  function isManyToMany(field) {
719
767
  return field.type === "relation" && field.cardinality === "many-to-many";
720
768
  }
@@ -734,7 +782,7 @@ function tableExpr(name, entry, dialect) {
734
782
  entry.kind === "page" ? pageColumnFields(entry) : entry.fields
735
783
  )) {
736
784
  if (isManyToMany(field)) continue;
737
- lines.push(columnExpr(key, field, dialect));
785
+ lines.push(columnExpr(key, field, dialect, name));
738
786
  }
739
787
  if (entry.drafts) lines.push(` status: text('status').notNull().default('draft'),`);
740
788
  if (isCollectionKind(entry)) lines.push(` createdAt: ${nowExpr(pg, "created_at")},`);
@@ -824,8 +872,18 @@ function renderSchemaFile(config, dialect, resolveImport = (s) => s) {
824
872
  core.add("jsonb");
825
873
  if (pg) core.add("timestamp");
826
874
  if (fields.some(isManyToMany) || rowsEntries.length) core.add("primaryKey");
875
+ const hasSelfRelation = derived.some(
876
+ ([name, entry]) => Object.values(entry.kind === "page" ? pageColumnFields(entry) : entry.fields).some(
877
+ (field) => isSelfRelation(name, field)
878
+ )
879
+ );
827
880
  const imports = [
828
881
  ...pg ? [] : [`import { sql } from '${resolveImport("drizzle-orm")}'`],
882
+ ...hasSelfRelation ? [
883
+ `import type { ${anyColumnType(dialect)} } from '${resolveImport(
884
+ `drizzle-orm/${pg ? "pg-core" : "sqlite-core"}`
885
+ )}'`
886
+ ] : [],
829
887
  `import { ${[...core].sort().join(", ")} } from '${resolveImport(
830
888
  `drizzle-orm/${pg ? "pg-core" : "sqlite-core"}`
831
889
  )}'`
@@ -981,7 +1039,7 @@ function scalarTsType(field) {
981
1039
  case "json":
982
1040
  return "unknown";
983
1041
  case "select":
984
- return field.options.map((o) => JSON.stringify(o)).join(" | ");
1042
+ return selectOptionValues(field).map((o) => JSON.stringify(o)).join(" | ");
985
1043
  default:
986
1044
  return "string";
987
1045
  }
@@ -994,7 +1052,7 @@ function fieldTsType(config, entryName, key, field) {
994
1052
  }
995
1053
  if (field.type === "media") return `${mediaTsType(field)} | null`;
996
1054
  if (field.type === "select" && field.multiple) {
997
- const union = field.options.map((o) => JSON.stringify(o)).join(" | ");
1055
+ const union = selectOptionValues(field).map((o) => JSON.stringify(o)).join(" | ");
998
1056
  return `(${union})[]`;
999
1057
  }
1000
1058
  if (field.type === "blocks") {
@@ -1017,12 +1075,8 @@ function blockTypesTs(entryName, key, field) {
1017
1075
  members.push(name);
1018
1076
  const lines = [];
1019
1077
  for (const [blockFieldKey, blockField] of Object.entries(block.fields)) {
1020
- if (blockField.type === "media") {
1021
- lines.push(` ${blockFieldKey}: ${mediaTsType(blockField)} | null`);
1022
- continue;
1023
- }
1024
- const base = scalarTsType(blockField);
1025
- lines.push(` ${blockFieldKey}: ${blockField.required ? base : `${base} | null`}`);
1078
+ const base = blockField.type === "media" ? mediaTsType(blockField) : scalarTsType(blockField);
1079
+ lines.push(` ${blockFieldKey}: ${base} | null`);
1026
1080
  }
1027
1081
  defs.push(`export interface ${props} {
1028
1082
  ${lines.join("\n")}
@@ -2038,6 +2092,16 @@ const module$1 = defineNuxtModule({
2038
2092
  method: "delete",
2039
2093
  handler: resolver.resolve("./runtime/server/api/settings.delete")
2040
2094
  });
2095
+ addServerHandler({
2096
+ route: "/api/cms/export/:collection",
2097
+ method: "get",
2098
+ handler: resolver.resolve("./runtime/server/api/collection-export.get")
2099
+ });
2100
+ addServerHandler({
2101
+ route: "/api/cms/import/:collection",
2102
+ method: "post",
2103
+ handler: resolver.resolve("./runtime/server/api/collection-import.post")
2104
+ });
2041
2105
  const api = "/api/cms/admin/:collection";
2042
2106
  addServerHandler({
2043
2107
  route: api,
@@ -100,18 +100,27 @@
100
100
  </div>
101
101
  </div>
102
102
 
103
- <CmsDropdownMenu v-if="addItems.length > 1" :items="addItems">
103
+ <CmsDropdownMenu v-if="canAdd && addItems.length > 1" :items="addItems">
104
104
  <button type="button" class="cms-block-add">
105
105
  <CmsIcon name="plus" class="size-5" />
106
106
  <span>Add</span>
107
107
  </button>
108
108
  </CmsDropdownMenu>
109
- <button v-else type="button" class="cms-block-add" @click="addFirst">
109
+ <button
110
+ v-else
111
+ type="button"
112
+ class="cms-block-add"
113
+ :disabled="!canAdd"
114
+ :title="canAdd ? void 0 : countHint"
115
+ @click="addFirst"
116
+ >
110
117
  <CmsIcon name="plus" class="size-5" />
111
118
  <span>{{ mediaOnly ? addMediaLabel : "Add" }}</span>
112
119
  </button>
113
120
  </div>
114
121
 
122
+ <p v-if="countHint" class="cms-form-description">{{ countHint }}</p>
123
+
115
124
  <CmsButton
116
125
  v-if="visual"
117
126
  class="self-start"
@@ -204,6 +213,7 @@
204
213
  icon="document-duplicate"
205
214
  variant="soft"
206
215
  color="neutral"
216
+ :disabled="!canAdd"
207
217
  @click="duplicateCurrent"
208
218
  />
209
219
  <CmsButton
@@ -234,6 +244,7 @@
234
244
 
235
245
  <script setup>
236
246
  import {
247
+ canAddBlock,
237
248
  declaresHiddenField,
238
249
  isHiddenBlock,
239
250
  isTranslatableMediaField,
@@ -260,6 +271,19 @@ const { mediaBaseUrl, i18n } = useCmsRuntime();
260
271
  const mediaKeys = useCmsMediaKeys();
261
272
  const blocks = computed(() => props.field.blocks ?? {});
262
273
  const items = computed(() => Array.isArray(model.value) ? model.value : []);
274
+ const canAdd = computed(() => canAddBlock(props.field, items.value.length));
275
+ function blockCount(count) {
276
+ return `${count} ${count === 1 ? "block" : "blocks"}`;
277
+ }
278
+ const countHint = computed(() => {
279
+ const min = props.field.min || void 0;
280
+ const max = props.field.max;
281
+ if (min !== void 0 && max !== void 0)
282
+ return min === max ? `Exactly ${blockCount(min)}.` : `From ${min} to ${blockCount(max)}.`;
283
+ if (max !== void 0) return `At most ${blockCount(max)}.`;
284
+ if (min !== void 0) return `At least ${blockCount(min)}.`;
285
+ return "";
286
+ });
263
287
  const mediaOnly = computed(() => {
264
288
  const types = Object.entries(blocks.value);
265
289
  if (types.length !== 1) return null;
@@ -295,7 +319,7 @@ const pickerOpen = ref(false);
295
319
  const pickerFolder = useState("cms-media-picker-folder", () => null);
296
320
  function addMedia(media) {
297
321
  const target = mediaOnly.value;
298
- if (!target) return;
322
+ if (!target || !canAdd.value) return;
299
323
  mediaKeys.remember(media.key);
300
324
  model.value = [...items.value, { type: target.type, [target.key]: media.key }];
301
325
  pickerOpen.value = false;
@@ -374,7 +398,7 @@ function updateField(index, key, value) {
374
398
  }
375
399
  function add(type) {
376
400
  const block = blocks.value[type];
377
- if (!block) return;
401
+ if (!block || !canAdd.value) return;
378
402
  const empty = Object.fromEntries(Object.keys(block.fields).map((k) => [k, null]));
379
403
  const index = items.value.length;
380
404
  model.value = [...items.value, { type, ...empty }];
@@ -391,6 +415,7 @@ function onPreviewClick(index) {
391
415
  if (!mediaOnly.value) openEditor(index);
392
416
  }
393
417
  function addFirst() {
418
+ if (!canAdd.value) return;
394
419
  if (mediaOnly.value) {
395
420
  pickerOpen.value = true;
396
421
  return;
@@ -404,7 +429,7 @@ function remove(index) {
404
429
  }
405
430
  function duplicate(index) {
406
431
  const current = items.value[index];
407
- if (!current) return;
432
+ if (!current || !canAdd.value) return;
408
433
  const copy = JSON.parse(JSON.stringify(current));
409
434
  const next = [...items.value];
410
435
  next.splice(index + 1, 0, copy);
@@ -12,11 +12,7 @@
12
12
  @click="onClick(item, $event)"
13
13
  >
14
14
  <span>{{ item.label }}</span>
15
- <CmsIcon
16
- v-if="item.type === 'checkbox' && item.checked"
17
- name="check"
18
- class="cms-menu-check size-3.5"
19
- />
15
+ <CmsIcon v-if="item.checked" name="check" class="cms-menu-check size-3.5" />
20
16
  </button>
21
17
  </div>
22
18
  </div>
@@ -3,6 +3,7 @@ type __VLS_Props = {
3
3
  collection: string;
4
4
  config: CmsEntry;
5
5
  entryId: string | null;
6
+ initial?: Record<string, unknown> | null;
6
7
  };
7
8
  type __VLS_ModelProps = {
8
9
  'open'?: boolean;
@@ -11,10 +12,12 @@ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
11
12
  declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
13
  saved: () => any;
13
14
  deleted: () => any;
15
+ duplicate: (values: Record<string, unknown>) => any;
14
16
  "update:open": (value: boolean) => any;
15
17
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
16
18
  onSaved?: (() => any) | undefined;
17
19
  onDeleted?: (() => any) | undefined;
20
+ onDuplicate?: ((values: Record<string, unknown>) => any) | undefined;
18
21
  "onUpdate:open"?: ((value: boolean) => any) | undefined;
19
22
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
20
23
  declare const _default: typeof __VLS_export;