@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.
- package/README.md +74 -96
- package/dist/module.json +1 -1
- package/dist/module.mjs +86 -22
- package/dist/runtime/app/components/cms/BlocksField.vue +30 -5
- package/dist/runtime/app/components/cms/DropdownMenu.vue +1 -5
- package/dist/runtime/app/components/cms/EntryDrawer.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/EntryDrawer.vue +32 -13
- package/dist/runtime/app/components/cms/EntryDrawer.vue.d.ts +3 -0
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +20 -3
- package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
- package/dist/runtime/app/components/cms/FieldInput.vue +2 -2
- package/dist/runtime/app/components/cms/Form.d.vue.ts +8 -6
- package/dist/runtime/app/components/cms/Form.vue +26 -5
- package/dist/runtime/app/components/cms/Form.vue.d.ts +8 -6
- package/dist/runtime/app/components/cms/FormField.vue +6 -1
- package/dist/runtime/app/components/cms/ImportModal.d.vue.ts +18 -0
- package/dist/runtime/app/components/cms/ImportModal.vue +252 -0
- package/dist/runtime/app/components/cms/ImportModal.vue.d.ts +18 -0
- package/dist/runtime/app/components/cms/ListFilters.d.vue.ts +18 -0
- package/dist/runtime/app/components/cms/ListFilters.vue +151 -0
- package/dist/runtime/app/components/cms/ListFilters.vue.d.ts +18 -0
- package/dist/runtime/app/components/cms/RelationField.vue +3 -1
- package/dist/runtime/app/components/cms/VisualEditor.vue +81 -12
- package/dist/runtime/app/pages/admin-collection.vue +158 -7
- package/dist/runtime/app/pages/admin-content.vue +18 -7
- package/dist/runtime/app/pages/admin-entry.vue +1 -0
- package/dist/runtime/app/pages/admin-preview.vue +6 -3
- package/dist/runtime/app/preview/PreviewOverlay.vue +4 -0
- package/dist/runtime/app/utils/ui.d.ts +2 -1
- package/dist/runtime/app/utils/ui.js +1 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/collection-export.get.d.ts +2 -0
- package/dist/runtime/server/api/collection-export.get.js +67 -0
- package/dist/runtime/server/api/collection-import.post.d.ts +2 -0
- package/dist/runtime/server/api/collection-import.post.js +85 -0
- package/dist/runtime/server/api/collection.get.js +7 -15
- package/dist/runtime/server/utils/import.d.ts +31 -0
- package/dist/runtime/server/utils/import.js +202 -0
- package/dist/runtime/server/utils/list-query.d.ts +15 -0
- package/dist/runtime/server/utils/list-query.js +54 -0
- package/dist/runtime/server/utils/media-check.d.ts +2 -0
- package/dist/runtime/server/utils/media-check.js +1 -1
- package/dist/runtime/server/utils/media-references.js +4 -1
- package/dist/runtime/server/utils/registry.js +1 -1
- package/dist/runtime/server/utils/search.js +23 -4
- package/dist/runtime/shared/duplicate.d.ts +10 -0
- package/dist/runtime/shared/duplicate.js +54 -0
- package/dist/runtime/shared/graphql-sdl.js +4 -7
- package/dist/runtime/shared/index.d.ts +49 -2
- package/dist/runtime/shared/index.js +33 -0
- package/dist/runtime/shared/layout.d.ts +3 -0
- package/dist/runtime/shared/layout.js +35 -2
- package/dist/runtime/shared/list-filters.d.ts +20 -0
- package/dist/runtime/shared/list-filters.js +83 -0
- package/dist/runtime/shared/transfer.d.ts +70 -0
- package/dist/runtime/shared/transfer.js +256 -0
- package/dist/runtime/shared/validation.d.ts +15 -2
- package/dist/runtime/shared/validation.js +102 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,131 +4,109 @@
|
|
|
4
4
|
[](https://github.com/xleddyl/nuxt-cms/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
nuxt-cms is a Nuxt module that
|
|
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
|
-
-
|
|
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
|
+

|
|
17
10
|
|
|
18
|
-
##
|
|
19
|
-
|
|
20
|
-

|
|
11
|
+
## Features
|
|
21
12
|
|
|
22
|
-
|
|
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
|
-
|
|
21
|
+
## Screenshots
|
|
25
22
|
|
|
26
|
-
|
|
23
|
+
| Collection | Entry editor |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
|  |  |
|
|
26
|
+
| **Media library** | **Search** |
|
|
27
|
+
|  |  |
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
npm install @xleddyl/nuxt-cms
|
|
30
|
-
```
|
|
29
|
+
## Quick start
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
1. Install the module:
|
|
33
32
|
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
39
|
+
```ts
|
|
40
|
+
export default defineNuxtConfig({
|
|
41
|
+
modules: ['@xleddyl/nuxt-cms'],
|
|
42
|
+
})
|
|
43
|
+
```
|
|
56
44
|
|
|
57
|
-
|
|
45
|
+
3. Declare your content types in `cms.config.ts`, at the project root:
|
|
58
46
|
|
|
59
|
-
|
|
47
|
+
```ts
|
|
48
|
+
import { defineCmsConfig } from '#nuxt-cms'
|
|
60
49
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
65
|
+
4. Set the admin credentials in `.env`:
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
+
```vue
|
|
78
|
+
<script setup lang="ts">
|
|
79
|
+
const { data: events } = await useCmsCollection('events', { sort: [{ field: 'date' }] })
|
|
80
|
+
</script>
|
|
81
|
+
```
|
|
104
82
|
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
125
|
-
pnpm dev
|
|
126
|
-
pnpm
|
|
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
|
-
|
|
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
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
|
|
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"
|
|
531
|
-
errors.push(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1021
|
-
|
|
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
|
|
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;
|