@xleddyl/nuxt-cms 0.1.42 → 0.1.44
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 +2 -2
- package/dist/module.json +1 -1
- package/dist/module.mjs +188 -16
- package/dist/runtime/app/composables/cms-entry-disabled.d.ts +5 -1
- package/dist/runtime/app/composables/cms-entry-disabled.js +7 -0
- package/dist/runtime/app/composables/cms-entry.d.ts +5 -1
- package/dist/runtime/app/composables/cms-entry.js +18 -1
- package/dist/runtime/app/layouts/cms-admin.vue +5 -0
- package/dist/runtime/app/pages/admin-collection.vue +44 -1
- package/dist/runtime/app/pages/admin-entry.vue +14 -6
- package/dist/runtime/server/api/collection.get.js +19 -0
- package/dist/runtime/server/api/item.delete.js +4 -1
- package/dist/runtime/server/api/item.get.js +13 -2
- package/dist/runtime/server/api/item.put.js +14 -1
- package/dist/runtime/server/utils/graphql.js +21 -4
- package/dist/runtime/server/utils/registry.d.ts +3 -2
- package/dist/runtime/server/utils/registry.js +14 -3
- package/dist/runtime/server/utils/relations.js +4 -3
- package/dist/runtime/shared/graphql-sdl.js +13 -2
- package/dist/runtime/shared/index.d.ts +43 -6
- package/dist/runtime/shared/index.js +54 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,8 +97,8 @@ Full documentation lives in [`docs/`](docs/README.md):
|
|
|
97
97
|
- [Getting started](docs/getting-started.md) — install, configure, first content type, run.
|
|
98
98
|
- [Configuration](docs/configuration.md) — every `cms.*` option and the `NUXT_CMS_*` env vars.
|
|
99
99
|
- [Database](docs/database.md) — SQLite, Postgres, libSQL/Turso and D1 drivers, migrations, studio.
|
|
100
|
-
- [Schema](docs/schema.md) — `defineCmsConfig`, entries, field types, relations, blocks, i18n.
|
|
101
|
-
- [Querying content](docs/querying.md) — GraphQL API, `useCmsSingle` / `useCmsCollection` / `useCms` / `$cmsQuery`, filters, sorting, pagination.
|
|
100
|
+
- [Schema](docs/schema.md) — `defineCmsConfig`, entries, pages, field types, relations, blocks, i18n.
|
|
101
|
+
- [Querying content](docs/querying.md) — GraphQL API, `useCmsSingle` / `useCmsCollection` / `useCmsPage` / `useCms` / `$cmsQuery`, filters, sorting, pagination.
|
|
102
102
|
- [Admin panel & security](docs/admin.md) — pages, authentication, sessions, admin REST API.
|
|
103
103
|
- [Media](docs/media.md) — S3-compatible storage or local mode backed by your `public/` folder, upload flow, allowed file types.
|
|
104
104
|
- [Deployment](docs/deployment.md) — host/driver matrix, migrations on serverless, horizontal scaling, Cloudflare Workers.
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { join, isAbsolute, resolve, relative, dirname } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -10,7 +10,7 @@ import { introspectionFromSchema, buildSchema } from 'graphql';
|
|
|
10
10
|
import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal';
|
|
11
11
|
import { createJiti } from 'jiti';
|
|
12
12
|
import { typeName, blockTypeName, blocksFieldTypeName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
|
|
13
|
-
import { isTranslatableField, isTranslatableMediaField, isMultiSelect, fieldConditions, isRequiredField, isPrivateField, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
|
|
13
|
+
import { isTranslatableField, isTranslatableMediaField, isMultiSelect, pageAllFields, PAGE_PATH_FIELD, pageRoutes, fieldConditions, isRequiredField, pageFields, isPrivateField, entryFieldsFor, pageLabelFromPath, pageKeyFromPath, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
|
|
14
14
|
import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
16
16
|
import { readFile } from 'node:fs/promises';
|
|
@@ -105,6 +105,7 @@ function validateConfig(config, i18n) {
|
|
|
105
105
|
)}]`
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
|
+
let pageEntry;
|
|
108
109
|
const entryIds = /* @__PURE__ */ new Map();
|
|
109
110
|
const typeNames = /* @__PURE__ */ new Map();
|
|
110
111
|
for (const [name, entry] of Object.entries(config)) {
|
|
@@ -130,8 +131,57 @@ function validateConfig(config, i18n) {
|
|
|
130
131
|
} else {
|
|
131
132
|
entryIds.set(entry.id, name);
|
|
132
133
|
}
|
|
133
|
-
if (entry.kind !== "collection" && entry.kind !== "single")
|
|
134
|
-
errors.push(`${at}: kind must be 'collection' or '
|
|
134
|
+
if (entry.kind !== "collection" && entry.kind !== "single" && entry.kind !== "page")
|
|
135
|
+
errors.push(`${at}: kind must be 'collection', 'single' or 'page'`);
|
|
136
|
+
if (entry.kind === "page") {
|
|
137
|
+
if (pageEntry) {
|
|
138
|
+
errors.push(
|
|
139
|
+
`${at}: only one page entry is allowed (already declared as '${pageEntry}')`
|
|
140
|
+
);
|
|
141
|
+
} else {
|
|
142
|
+
pageEntry = name;
|
|
143
|
+
}
|
|
144
|
+
if (entry.titleField) errors.push(`${at}: pages have no titleField`);
|
|
145
|
+
if (Object.hasOwn(entry.fields ?? {}, PAGE_PATH_FIELD))
|
|
146
|
+
errors.push(`${at}: '${PAGE_PATH_FIELD}' is a reserved field name on pages`);
|
|
147
|
+
const known = new Set(pageRoutes(entry).map((route) => route.path));
|
|
148
|
+
const keys = /* @__PURE__ */ new Map();
|
|
149
|
+
for (const route of pageRoutes(entry)) {
|
|
150
|
+
const clash = keys.get(route.key);
|
|
151
|
+
if (clash) errors.push(`${at}: paths '${clash}' and '${route.path}' give the same key`);
|
|
152
|
+
else keys.set(route.key, route.path);
|
|
153
|
+
}
|
|
154
|
+
for (const [path, override] of Object.entries(entry.overrides ?? {})) {
|
|
155
|
+
const oat = `${at}, override '${path}'`;
|
|
156
|
+
if (known.size && !known.has(path)) errors.push(`${oat}: '${path}' is not a page path`);
|
|
157
|
+
for (const [key, field] of Object.entries(override)) {
|
|
158
|
+
const shared = Object.hasOwn(entry.fields ?? {}, key);
|
|
159
|
+
if (field && shared)
|
|
160
|
+
errors.push(`${oat}: field '${key}' is already declared for every page`);
|
|
161
|
+
if (!field && !shared)
|
|
162
|
+
errors.push(
|
|
163
|
+
`${oat}: field '${key}' is not declared for every page, nothing to remove`
|
|
164
|
+
);
|
|
165
|
+
if (key === PAGE_PATH_FIELD)
|
|
166
|
+
errors.push(`${oat}: '${PAGE_PATH_FIELD}' is a reserved field name on pages`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const declared = /* @__PURE__ */ new Map();
|
|
170
|
+
for (const override of Object.values(entry.overrides ?? {})) {
|
|
171
|
+
for (const [key, field] of Object.entries(override)) {
|
|
172
|
+
if (!field) continue;
|
|
173
|
+
const seen = declared.get(key);
|
|
174
|
+
if (seen && seen !== field.type) {
|
|
175
|
+
errors.push(
|
|
176
|
+
`${at}: field '${key}' is declared as '${seen}' and '${field.type}' by different pages`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
declared.set(key, field.type);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} else if (entry.overrides || entry.routes || entry.include || entry.exclude) {
|
|
183
|
+
errors.push(`${at}: routes, include, exclude and overrides need kind 'page'`);
|
|
184
|
+
}
|
|
135
185
|
if (entry.drafts && entry.kind !== "collection")
|
|
136
186
|
errors.push(`${at}: drafts are only supported on collections`);
|
|
137
187
|
if (!entry.fields || !Object.keys(entry.fields).length)
|
|
@@ -150,8 +200,9 @@ function validateConfig(config, i18n) {
|
|
|
150
200
|
);
|
|
151
201
|
}
|
|
152
202
|
}
|
|
203
|
+
const allFields = entry.kind === "page" ? pageAllFields(entry) : entry.fields ?? {};
|
|
153
204
|
const columnNames = /* @__PURE__ */ new Set();
|
|
154
|
-
for (const [key, field] of Object.entries(
|
|
205
|
+
for (const [key, field] of Object.entries(allFields)) {
|
|
155
206
|
const fat = `${at}, field '${key}'`;
|
|
156
207
|
if (!IDENTIFIER.test(key)) errors.push(`${fat}: key must be a valid identifier`);
|
|
157
208
|
const column = snakeCase(key);
|
|
@@ -181,7 +232,7 @@ function validateConfig(config, i18n) {
|
|
|
181
232
|
errors.push(`${fat}: the titleField cannot be conditional`);
|
|
182
233
|
for (const condition of fieldConditions(field)) {
|
|
183
234
|
const cat = `${fat}, showIf on '${condition.field}'`;
|
|
184
|
-
const target =
|
|
235
|
+
const target = allFields[condition.field];
|
|
185
236
|
if (!condition.field || !target) {
|
|
186
237
|
errors.push(`${cat}: '${condition.field}' is not a declared field`);
|
|
187
238
|
continue;
|
|
@@ -380,7 +431,10 @@ function tableExpr(name, entry, dialect) {
|
|
|
380
431
|
const tableFn = pg ? "pgTable" : "sqliteTable";
|
|
381
432
|
const lines = [];
|
|
382
433
|
lines.push(` id: text('id').primaryKey(),`);
|
|
383
|
-
|
|
434
|
+
if (entry.kind === "page") lines.push(` path: text('path').notNull().unique(),`);
|
|
435
|
+
for (const [key, field] of Object.entries(
|
|
436
|
+
entry.kind === "page" ? pageAllFields(entry) : entry.fields
|
|
437
|
+
)) {
|
|
384
438
|
if (isManyToMany(field)) continue;
|
|
385
439
|
lines.push(columnExpr(key, field, dialect));
|
|
386
440
|
}
|
|
@@ -440,7 +494,7 @@ function renderSchemaFile(config, dialect, resolveImport = (s) => s) {
|
|
|
440
494
|
];
|
|
441
495
|
const tables = derived.map(([name, entry]) => tableExpr(name, entry, dialect));
|
|
442
496
|
const joins = derived.flatMap(
|
|
443
|
-
([name, entry]) => Object.entries(entry.fields).filter(([, field]) => isManyToMany(field)).map(([key, field]) => joinTableExpr(name, key, field, dialect))
|
|
497
|
+
([name, entry]) => Object.entries(entry.kind === "page" ? pageAllFields(entry) : entry.fields).filter(([, field]) => isManyToMany(field)).map(([key, field]) => joinTableExpr(name, key, field, dialect))
|
|
444
498
|
);
|
|
445
499
|
return `${imports.join("\n")}
|
|
446
500
|
|
|
@@ -462,9 +516,10 @@ function blocksSelection(entryName, key, field) {
|
|
|
462
516
|
}
|
|
463
517
|
return parts.join(" ");
|
|
464
518
|
}
|
|
465
|
-
function entrySelection(config, name, entry, withRelations) {
|
|
519
|
+
function entrySelection(config, name, entry, withRelations, fields = entryFieldsFor(entry)) {
|
|
466
520
|
const parts = ["id"];
|
|
467
|
-
|
|
521
|
+
if (entry.kind === "page") parts.push("path");
|
|
522
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
468
523
|
if (isPrivateField(field)) continue;
|
|
469
524
|
if (field.type === "relation") {
|
|
470
525
|
const target = config[field.to];
|
|
@@ -502,10 +557,25 @@ function collectionQuery(config, name, entry) {
|
|
|
502
557
|
].join(", ");
|
|
503
558
|
return `query CmsCollection(${args}) { ${name}(locale: $locale, filters: $filters, sort: $sort, limit: $limit, offset: $offset) { ${selection} } }`;
|
|
504
559
|
}
|
|
560
|
+
function pageQuery(config, name, entry, path) {
|
|
561
|
+
const selection = entrySelection(config, name, entry, true, pageFields(entry, path));
|
|
562
|
+
return `query CmsPage($path: String!, $locale: String) { page: ${name}ByPath(path: $path, locale: $locale) { ${selection} } }`;
|
|
563
|
+
}
|
|
505
564
|
function renderQueriesFile(config) {
|
|
506
565
|
const singles = [];
|
|
507
566
|
const collections = [];
|
|
567
|
+
const pages = [];
|
|
508
568
|
for (const [name, entry] of Object.entries(config)) {
|
|
569
|
+
if (entry.kind === "page") {
|
|
570
|
+
for (const route of pageRoutes(entry)) {
|
|
571
|
+
pages.push(
|
|
572
|
+
` ${JSON.stringify(route.path)}: ${JSON.stringify(
|
|
573
|
+
pageQuery(config, name, entry, route.path)
|
|
574
|
+
)},`
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
509
579
|
const line = ` ${JSON.stringify(name)}: ${JSON.stringify(
|
|
510
580
|
entry.kind === "single" ? singleQuery(config, name, entry) : collectionQuery(config, name, entry)
|
|
511
581
|
)},`;
|
|
@@ -520,10 +590,53 @@ function renderQueriesFile(config) {
|
|
|
520
590
|
`export const cmsCollectionQueries: Record<string, string> = {`,
|
|
521
591
|
...collections,
|
|
522
592
|
`}`,
|
|
593
|
+
``,
|
|
594
|
+
`export const cmsPageQueries: Record<string, string> = {`,
|
|
595
|
+
...pages,
|
|
596
|
+
`}`,
|
|
523
597
|
``
|
|
524
598
|
].join("\n");
|
|
525
599
|
}
|
|
526
600
|
|
|
601
|
+
const DYNAMIC = /[[\]]/;
|
|
602
|
+
function vueFiles(dir, prefix = "") {
|
|
603
|
+
if (!existsSync(dir)) return [];
|
|
604
|
+
const files = [];
|
|
605
|
+
for (const item of readdirSync(dir, { withFileTypes: true })) {
|
|
606
|
+
const name = `${prefix}${item.name}`;
|
|
607
|
+
if (item.isDirectory()) files.push(...vueFiles(join(dir, item.name), `${name}/`));
|
|
608
|
+
else if (item.name.endsWith(".vue")) files.push(name);
|
|
609
|
+
}
|
|
610
|
+
return files;
|
|
611
|
+
}
|
|
612
|
+
function routePathFromFile(file) {
|
|
613
|
+
if (DYNAMIC.test(file)) return null;
|
|
614
|
+
const segments = file.replace(/\.vue$/, "").split("/").filter((segment) => !/^\(.+\)$/.test(segment));
|
|
615
|
+
if (segments.at(-1) === "index") segments.pop();
|
|
616
|
+
const path = `/${segments.join("/")}`.replace(/\/$/, "");
|
|
617
|
+
return path || "/";
|
|
618
|
+
}
|
|
619
|
+
function routePathsFromDir(pagesDir) {
|
|
620
|
+
const paths = /* @__PURE__ */ new Set();
|
|
621
|
+
for (const file of vueFiles(pagesDir)) {
|
|
622
|
+
const path = routePathFromFile(file);
|
|
623
|
+
if (path) paths.add(path);
|
|
624
|
+
}
|
|
625
|
+
return [...paths];
|
|
626
|
+
}
|
|
627
|
+
function resolvePageRoutes(entry, discovered) {
|
|
628
|
+
const declared = Array.isArray(entry.routes) ? entry.routes : discovered;
|
|
629
|
+
const paths = /* @__PURE__ */ new Set([...declared, ...entry.include ?? []]);
|
|
630
|
+
for (const path of entry.exclude ?? []) paths.delete(path);
|
|
631
|
+
const rank = new Map((entry.order ?? []).map((path, index) => [path, index]));
|
|
632
|
+
const order = entry.order?.length ?? 0;
|
|
633
|
+
return [...paths].sort((a, b) => (rank.get(a) ?? order) - (rank.get(b) ?? order) || a.localeCompare(b)).map((path) => ({
|
|
634
|
+
path,
|
|
635
|
+
key: pageKeyFromPath(path),
|
|
636
|
+
label: entry.labels?.[path] ?? pageLabelFromPath(path)
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
|
|
527
640
|
function mediaTsType(field) {
|
|
528
641
|
const types = mediaTypeFilter(field.mediaType);
|
|
529
642
|
return types ? `CmsMedia<${types.map((type) => `'${type}'`).join(" | ")}>` : "CmsMedia";
|
|
@@ -586,7 +699,8 @@ ${lines.join("\n")}
|
|
|
586
699
|
}
|
|
587
700
|
function entryTs(config, name, entry) {
|
|
588
701
|
const lines = [" id: string"];
|
|
589
|
-
|
|
702
|
+
if (entry.kind === "page") lines.push(" path: string");
|
|
703
|
+
for (const [key, field] of Object.entries(entryFieldsFor(entry))) {
|
|
590
704
|
if (isPrivateField(field)) continue;
|
|
591
705
|
lines.push(` ${key}: ${fieldTsType(config, name, key, field)}`);
|
|
592
706
|
}
|
|
@@ -597,7 +711,7 @@ ${lines.join("\n")}
|
|
|
597
711
|
}`;
|
|
598
712
|
}
|
|
599
713
|
function relationKeys(entry) {
|
|
600
|
-
return Object.entries(entry
|
|
714
|
+
return Object.entries(entryFieldsFor(entry)).filter(([, field]) => field.type === "relation" && !isPrivateField(field)).map(([key]) => key);
|
|
601
715
|
}
|
|
602
716
|
function quotedKeys(keys) {
|
|
603
717
|
return keys.map((key) => `'${key}'`).join(" | ");
|
|
@@ -625,14 +739,37 @@ ${lines.join(
|
|
|
625
739
|
)}
|
|
626
740
|
}`;
|
|
627
741
|
}
|
|
742
|
+
function pageTypesTs(name, entry) {
|
|
743
|
+
const auto = `${typeName(name)}Auto`;
|
|
744
|
+
const lines = pageRoutes(entry).map((route) => {
|
|
745
|
+
const keys = ["id", "path", "updatedAt", ...Object.keys(pageFields(entry, route.path))];
|
|
746
|
+
return ` ${JSON.stringify(route.path)}: Pick<${auto}, ${quotedKeys(keys)}>`;
|
|
747
|
+
});
|
|
748
|
+
return [
|
|
749
|
+
`export interface CmsPageTypes {
|
|
750
|
+
${lines.join("\n")}
|
|
751
|
+
}`,
|
|
752
|
+
`export type CmsPagePath = keyof CmsPageTypes`
|
|
753
|
+
];
|
|
754
|
+
}
|
|
628
755
|
function entryMapsTs(config) {
|
|
629
756
|
const singles = [];
|
|
630
757
|
const collections = [];
|
|
758
|
+
const pages = [];
|
|
631
759
|
for (const [name, entry] of Object.entries(config)) {
|
|
632
760
|
const line = ` ${JSON.stringify(name)}: ${typeName(name)}Auto`;
|
|
633
761
|
if (entry.kind === "single") singles.push(line);
|
|
762
|
+
else if (entry.kind === "page") pages.push(...pageTypesTs(name, entry));
|
|
634
763
|
else collections.push(line);
|
|
635
764
|
}
|
|
765
|
+
if (!pages.length) {
|
|
766
|
+
pages.push(
|
|
767
|
+
`export interface CmsPageTypes {
|
|
768
|
+
[path: string]: never
|
|
769
|
+
}`,
|
|
770
|
+
`export type CmsPagePath = keyof CmsPageTypes`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
636
773
|
return [
|
|
637
774
|
`export interface CmsSingleTypes {
|
|
638
775
|
${singles.join("\n")}
|
|
@@ -641,7 +778,8 @@ ${singles.join("\n")}
|
|
|
641
778
|
${collections.join("\n")}
|
|
642
779
|
}`,
|
|
643
780
|
`export type CmsSingleName = keyof CmsSingleTypes`,
|
|
644
|
-
`export type CmsCollectionName = keyof CmsCollectionTypes
|
|
781
|
+
`export type CmsCollectionName = keyof CmsCollectionTypes`,
|
|
782
|
+
...pages
|
|
645
783
|
];
|
|
646
784
|
}
|
|
647
785
|
function renderTypesFile(config) {
|
|
@@ -660,7 +798,7 @@ function renderTypesFile(config) {
|
|
|
660
798
|
}`
|
|
661
799
|
];
|
|
662
800
|
for (const [name, entry] of Object.entries(config)) {
|
|
663
|
-
for (const [key, field] of Object.entries(entry
|
|
801
|
+
for (const [key, field] of Object.entries(entryFieldsFor(entry))) {
|
|
664
802
|
if (field.type === "blocks" && !isPrivateField(field))
|
|
665
803
|
parts.push(...blockTypesTs(name, key, field));
|
|
666
804
|
}
|
|
@@ -782,6 +920,38 @@ async function loadCmsConfig(nuxt, resolver, configPathOption, i18n, logger) {
|
|
|
782
920
|
);
|
|
783
921
|
nuxt.options.alias["#cms-config"] = resolver.resolve("./runtime/shared/empty-config");
|
|
784
922
|
}
|
|
923
|
+
const pagesDir = join(
|
|
924
|
+
nuxt.options.srcDir ?? nuxt.options.rootDir,
|
|
925
|
+
nuxt.options.dir?.pages ?? "pages"
|
|
926
|
+
);
|
|
927
|
+
const discoveredRoutes = routePathsFromDir(pagesDir);
|
|
928
|
+
const routesByEntry = {};
|
|
929
|
+
for (const [name, entry] of Object.entries(cmsConfig)) {
|
|
930
|
+
if (entry.kind !== "page") continue;
|
|
931
|
+
entry.pages = resolvePageRoutes(entry, discoveredRoutes);
|
|
932
|
+
routesByEntry[name] = entry.pages;
|
|
933
|
+
}
|
|
934
|
+
if (Object.keys(routesByEntry).length) {
|
|
935
|
+
const source = (nuxt.options.alias["#cms-config"] ?? "").replace(/\\/g, "/").replace(/\.[cm]?[jt]s$/, "");
|
|
936
|
+
const wrapper = addTemplate({
|
|
937
|
+
filename: "cms/config.ts",
|
|
938
|
+
write: true,
|
|
939
|
+
getContents: () => [
|
|
940
|
+
`import config from '${source}'`,
|
|
941
|
+
``,
|
|
942
|
+
`const pageRoutes = ${JSON.stringify(routesByEntry, null, 3)}`,
|
|
943
|
+
``,
|
|
944
|
+
`for (const [name, routes] of Object.entries(pageRoutes)) {`,
|
|
945
|
+
` const entry = (config as Record<string, { pages?: unknown }>)[name]`,
|
|
946
|
+
` if (entry) entry.pages = routes`,
|
|
947
|
+
`}`,
|
|
948
|
+
``,
|
|
949
|
+
`export default config`,
|
|
950
|
+
``
|
|
951
|
+
].join("\n")
|
|
952
|
+
});
|
|
953
|
+
nuxt.options.alias["#cms-config"] = wrapper.dst;
|
|
954
|
+
}
|
|
785
955
|
const configErrors = validateConfig(cmsConfig, i18n);
|
|
786
956
|
if (configErrors.length) {
|
|
787
957
|
for (const error of configErrors) logger.error(error);
|
|
@@ -904,7 +1074,8 @@ const module$1 = defineNuxtModule({
|
|
|
904
1074
|
{ name: "useCms", from: queryStub },
|
|
905
1075
|
{ name: "$cmsQuery", from: queryStub },
|
|
906
1076
|
{ name: "useCmsSingle", from: entryStub },
|
|
907
|
-
{ name: "useCmsCollection", from: entryStub }
|
|
1077
|
+
{ name: "useCmsCollection", from: entryStub },
|
|
1078
|
+
{ name: "useCmsPage", from: entryStub }
|
|
908
1079
|
]);
|
|
909
1080
|
addCmsTypeTemplates(
|
|
910
1081
|
nuxt,
|
|
@@ -966,7 +1137,8 @@ const module$1 = defineNuxtModule({
|
|
|
966
1137
|
{ name: "useCms", from: queryComposables },
|
|
967
1138
|
{ name: "$cmsQuery", from: queryComposables },
|
|
968
1139
|
{ name: "useCmsSingle", from: entryComposables },
|
|
969
|
-
{ name: "useCmsCollection", from: entryComposables }
|
|
1140
|
+
{ name: "useCmsCollection", from: entryComposables },
|
|
1141
|
+
{ name: "useCmsPage", from: entryComposables }
|
|
970
1142
|
]);
|
|
971
1143
|
const {
|
|
972
1144
|
driver,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AsyncData } from 'nuxt/app';
|
|
2
|
-
import type { CmsCollectionName, CmsCollectionTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
|
|
2
|
+
import type { CmsCollectionName, CmsCollectionTypes, CmsPagePath, CmsPageTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
|
|
3
3
|
import type { CmsAsyncDataOptions } from './cms-query-disabled.js';
|
|
4
4
|
export interface CmsSortInput<Entry> {
|
|
5
5
|
field: Extract<keyof Entry, string>;
|
|
@@ -8,6 +8,9 @@ export interface CmsSortInput<Entry> {
|
|
|
8
8
|
export interface CmsSingleOptions<ResT, DefaultT> extends CmsAsyncDataOptions<ResT, DefaultT> {
|
|
9
9
|
locale?: string;
|
|
10
10
|
}
|
|
11
|
+
export interface CmsPageOptions<ResT, DefaultT> extends CmsAsyncDataOptions<ResT, DefaultT> {
|
|
12
|
+
locale?: string;
|
|
13
|
+
}
|
|
11
14
|
export interface CmsCollectionOptions<ResT, DefaultT, Entry> extends CmsAsyncDataOptions<ResT, DefaultT> {
|
|
12
15
|
locale?: string;
|
|
13
16
|
filters?: Record<string, unknown>;
|
|
@@ -17,3 +20,4 @@ export interface CmsCollectionOptions<ResT, DefaultT, Entry> extends CmsAsyncDat
|
|
|
17
20
|
}
|
|
18
21
|
export declare function useCmsSingle<K extends CmsSingleName, DefaultT = null>(name: K, options?: CmsSingleOptions<CmsSingleTypes[K] | null, DefaultT>): AsyncData<CmsSingleTypes[K] | DefaultT | null, Error | undefined>;
|
|
19
22
|
export declare function useCmsCollection<K extends CmsCollectionName, DefaultT = CmsCollectionTypes[K][]>(name: K, options?: CmsCollectionOptions<CmsCollectionTypes[K][], DefaultT, CmsCollectionTypes[K]>): AsyncData<CmsCollectionTypes[K][] | DefaultT, Error | undefined>;
|
|
23
|
+
export declare function useCmsPage<P extends CmsPagePath, DefaultT = null>(path: P, options?: CmsPageOptions<CmsPageTypes[P] | null, DefaultT>): AsyncData<CmsPageTypes[P] | DefaultT | null, Error | undefined>;
|
|
@@ -14,3 +14,10 @@ export function useCmsCollection(name, options = {}) {
|
|
|
14
14
|
async () => fallback ? fallback() : []
|
|
15
15
|
);
|
|
16
16
|
}
|
|
17
|
+
export function useCmsPage(path, options = {}) {
|
|
18
|
+
const { locale, key, default: fallback } = options;
|
|
19
|
+
return useAsyncData(
|
|
20
|
+
`cms-page:${String(path)}:${locale ?? ""}`,
|
|
21
|
+
async () => fallback ? fallback() : null
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AsyncData } from 'nuxt/app';
|
|
2
|
-
import type { CmsCollectionName, CmsCollectionTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
|
|
2
|
+
import type { CmsCollectionName, CmsCollectionTypes, CmsPagePath, CmsPageTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
|
|
3
3
|
import type { CmsAsyncDataOptions } from './cms-query.js';
|
|
4
4
|
export interface CmsSortInput<Entry> {
|
|
5
5
|
field: Extract<keyof Entry, string>;
|
|
@@ -8,6 +8,9 @@ export interface CmsSortInput<Entry> {
|
|
|
8
8
|
export interface CmsSingleOptions<ResT, DefaultT> extends CmsAsyncDataOptions<ResT, DefaultT> {
|
|
9
9
|
locale?: string;
|
|
10
10
|
}
|
|
11
|
+
export interface CmsPageOptions<ResT, DefaultT> extends CmsAsyncDataOptions<ResT, DefaultT> {
|
|
12
|
+
locale?: string;
|
|
13
|
+
}
|
|
11
14
|
export interface CmsCollectionOptions<ResT, DefaultT, Entry> extends CmsAsyncDataOptions<ResT, DefaultT> {
|
|
12
15
|
locale?: string;
|
|
13
16
|
filters?: Record<string, unknown>;
|
|
@@ -17,3 +20,4 @@ export interface CmsCollectionOptions<ResT, DefaultT, Entry> extends CmsAsyncDat
|
|
|
17
20
|
}
|
|
18
21
|
export declare function useCmsSingle<K extends CmsSingleName, DefaultT = null>(name: K, options?: CmsSingleOptions<CmsSingleTypes[K] | null, DefaultT>): AsyncData<CmsSingleTypes[K] | DefaultT | null, Error | undefined>;
|
|
19
22
|
export declare function useCmsCollection<K extends CmsCollectionName, DefaultT = CmsCollectionTypes[K][]>(name: K, options?: CmsCollectionOptions<CmsCollectionTypes[K][], DefaultT, CmsCollectionTypes[K]>): AsyncData<CmsCollectionTypes[K][] | DefaultT, Error | undefined>;
|
|
23
|
+
export declare function useCmsPage<P extends CmsPagePath, DefaultT = null>(path: P, options?: CmsPageOptions<CmsPageTypes[P] | null, DefaultT>): AsyncData<CmsPageTypes[P] | DefaultT | null, Error | undefined>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cmsCollectionQueries, cmsSingleQueries } from "#cms-queries";
|
|
1
|
+
import { cmsCollectionQueries, cmsPageQueries, cmsSingleQueries } from "#cms-queries";
|
|
2
2
|
import { useAsyncData } from "#imports";
|
|
3
3
|
import { $cmsQuery } from "./cms-query.js";
|
|
4
4
|
function unknownEntry(name) {
|
|
@@ -48,3 +48,20 @@ export function useCmsCollection(name, options = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
|
+
export function useCmsPage(path, options = {}) {
|
|
52
|
+
const { locale, key, default: fallback, ...asyncDataOptions } = options;
|
|
53
|
+
const pagePath = String(path);
|
|
54
|
+
const query = cmsPageQueries[pagePath];
|
|
55
|
+
return useAsyncData(
|
|
56
|
+
key ?? `cms-page:${pagePath}:${locale ?? ""}`,
|
|
57
|
+
async () => {
|
|
58
|
+
if (!query) unknownEntry(pagePath);
|
|
59
|
+
const result = await $cmsQuery(query, { path: pagePath, locale });
|
|
60
|
+
return result?.page ?? null;
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
default: fallback ?? (() => null),
|
|
64
|
+
...asyncDataOptions
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -30,6 +30,49 @@
|
|
|
30
30
|
/>
|
|
31
31
|
</div>
|
|
32
32
|
|
|
33
|
+
<template v-else-if="config.kind === 'page'">
|
|
34
|
+
<div class="cms-toolbar">
|
|
35
|
+
<CmsInput
|
|
36
|
+
v-if="total || searchTerm"
|
|
37
|
+
v-model="search"
|
|
38
|
+
icon="magnifying-glass"
|
|
39
|
+
placeholder="Search…"
|
|
40
|
+
class="flex-1"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div v-if="rows.length" class="cms-card divide-y divide-(--cms-line)">
|
|
45
|
+
<NuxtLink
|
|
46
|
+
v-for="row in rows"
|
|
47
|
+
:key="String(row.id)"
|
|
48
|
+
:to="`/cms/${name}/${row.id}`"
|
|
49
|
+
class="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-(--ui-bg-elevated)"
|
|
50
|
+
>
|
|
51
|
+
<div class="min-w-0 flex-1">
|
|
52
|
+
<div class="truncate font-medium text-(--ui-text-highlighted)">
|
|
53
|
+
{{ row.label }}
|
|
54
|
+
</div>
|
|
55
|
+
<div class="cms-label truncate">{{ row.path }}</div>
|
|
56
|
+
</div>
|
|
57
|
+
<span class="cms-label shrink-0">{{ row.updatedAt ? "edited" : "empty" }}</span>
|
|
58
|
+
<CmsIcon name="chevron-right" class="size-4 shrink-0 text-(--ui-text-dimmed)" />
|
|
59
|
+
</NuxtLink>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<CmsSpinner v-else-if="status === 'pending'" />
|
|
63
|
+
|
|
64
|
+
<CmsEmptyState v-else-if="searchTerm" icon="magnifying-glass" title="No matching pages" />
|
|
65
|
+
|
|
66
|
+
<CmsEmptyState
|
|
67
|
+
v-else
|
|
68
|
+
icon="document-text"
|
|
69
|
+
title="No pages yet"
|
|
70
|
+
body="Pages come from the routes of the app."
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<CmsPagination v-model:page="page" :total="total" :items-per-page="PAGE_SIZE" />
|
|
74
|
+
</template>
|
|
75
|
+
|
|
33
76
|
<template v-else>
|
|
34
77
|
<div class="cms-toolbar">
|
|
35
78
|
<CmsInput
|
|
@@ -168,7 +211,7 @@ const listQuery = computed(() => ({
|
|
|
168
211
|
...sort.value ? { sort: sort.value.key, order: sort.value.order } : {}
|
|
169
212
|
}));
|
|
170
213
|
const { data, refresh, error, status } = await useFetch(endpoint, {
|
|
171
|
-
query: config.kind === "
|
|
214
|
+
query: config.kind === "single" ? void 0 : listQuery
|
|
172
215
|
});
|
|
173
216
|
function isList(value) {
|
|
174
217
|
return !!value && Array.isArray(value.items);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="cms-page">
|
|
3
|
-
<CmsPageHeader :title="
|
|
3
|
+
<CmsPageHeader :title="headerTitle">
|
|
4
4
|
<template v-if="drafts" #badge>
|
|
5
5
|
<CmsStatusBadge :published="published" />
|
|
6
6
|
</template>
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<div class="cms-card cms-panel">
|
|
22
22
|
<CmsEntryForm
|
|
23
23
|
v-model="formState"
|
|
24
|
-
:fields="
|
|
24
|
+
:fields="fields"
|
|
25
25
|
:drafts="drafts"
|
|
26
26
|
:form-id="FORM_ID"
|
|
27
27
|
:loading="saving"
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
</template>
|
|
34
34
|
|
|
35
35
|
<script setup>
|
|
36
|
+
import { pageFields, pageRouteOf } from "#nuxt-cms";
|
|
36
37
|
import {
|
|
37
38
|
computed,
|
|
38
39
|
createError,
|
|
@@ -61,13 +62,20 @@ const route = useRoute();
|
|
|
61
62
|
const toast = useCmsToast();
|
|
62
63
|
const name = route.params.collection;
|
|
63
64
|
const config = cmsConfig[name];
|
|
64
|
-
if (!config || config.kind !== "collection") {
|
|
65
|
+
if (!config || config.kind !== "collection" && config.kind !== "page") {
|
|
65
66
|
throw createError({ statusCode: 404, statusMessage: "Unknown collection", fatal: true });
|
|
66
67
|
}
|
|
67
68
|
const id = route.params.id;
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
69
|
+
const isPage = config.kind === "page";
|
|
70
|
+
const pageRoute = isPage && id ? pageRouteOf(config, id) : void 0;
|
|
71
|
+
if (isPage && !pageRoute) {
|
|
72
|
+
throw createError({ statusCode: 404, statusMessage: "Unknown page", fatal: true });
|
|
73
|
+
}
|
|
74
|
+
const isNew = !isPage && id === void 0;
|
|
75
|
+
const drafts = !isPage && !!config.drafts;
|
|
76
|
+
const fields = pageRoute ? pageFields(config, pageRoute.path) : config.fields;
|
|
77
|
+
const headerTitle = pageRoute ? pageRoute.label : isNew ? "New entry" : "Edit entry";
|
|
78
|
+
const fieldKeys = Object.keys(fields);
|
|
71
79
|
const formKeys = drafts ? [...fieldKeys, "status"] : fieldKeys;
|
|
72
80
|
const endpoint = `/api/cms/admin/${name}`;
|
|
73
81
|
function emptyState() {
|
|
@@ -2,6 +2,7 @@ import { asc, count, desc, sql } from "drizzle-orm";
|
|
|
2
2
|
import { defineEventHandler, getValidatedQuery } from "h3";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { useDb } from "#cms-db";
|
|
5
|
+
import { pageRoutes } from "../../shared/index.js";
|
|
5
6
|
import { decodeRows, getRegistryEntry, idColumn, tableColumns } from "../utils/registry.js";
|
|
6
7
|
import { attachManyToMany, relationTitles } from "../utils/relations.js";
|
|
7
8
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
@@ -29,6 +30,24 @@ export default defineEventHandler(async (event) => {
|
|
|
29
30
|
event,
|
|
30
31
|
querySchema.parse
|
|
31
32
|
);
|
|
33
|
+
if (entry.kind === "page") {
|
|
34
|
+
const rows = await db.select().from(table);
|
|
35
|
+
decodeRows(entry, rows);
|
|
36
|
+
const saved = new Map(rows.map((row) => [row.id, row]));
|
|
37
|
+
const items2 = pageRoutes(entry).map((route) => {
|
|
38
|
+
const row = saved.get(route.key) ?? {};
|
|
39
|
+
return { ...row, id: route.key, path: route.path, label: route.label };
|
|
40
|
+
});
|
|
41
|
+
const term = search?.toLowerCase();
|
|
42
|
+
const matching = term ? items2.filter(
|
|
43
|
+
(item) => item.path.toLowerCase().includes(term) || item.label.toLowerCase().includes(term)
|
|
44
|
+
) : items2;
|
|
45
|
+
return {
|
|
46
|
+
items: matching.slice(offset, offset + limit),
|
|
47
|
+
total: matching.length,
|
|
48
|
+
relations: {}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
32
51
|
const columns = tableColumns(table);
|
|
33
52
|
const titleColumn = entry.titleField && Object.hasOwn(columns, entry.titleField) ? columns[entry.titleField] : void 0;
|
|
34
53
|
let where;
|
|
@@ -8,7 +8,10 @@ export default defineEventHandler(async (event) => {
|
|
|
8
8
|
await requireAdmin(event);
|
|
9
9
|
const { entry, table } = getRegistryEntry(event);
|
|
10
10
|
if (entry.kind !== "collection") {
|
|
11
|
-
throw createError({
|
|
11
|
+
throw createError({
|
|
12
|
+
statusCode: 405,
|
|
13
|
+
statusMessage: "Only collection entries can be deleted"
|
|
14
|
+
});
|
|
12
15
|
}
|
|
13
16
|
const id = parseId(event);
|
|
14
17
|
return mapConstraintErrors(async () => {
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
import { eq } from "drizzle-orm";
|
|
2
2
|
import { createError, defineEventHandler } from "h3";
|
|
3
3
|
import { useDb } from "#cms-db";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
decodeRows,
|
|
6
|
+
getRegistryEntry,
|
|
7
|
+
idColumn,
|
|
8
|
+
parseId,
|
|
9
|
+
requirePageRoute
|
|
10
|
+
} from "../utils/registry.js";
|
|
5
11
|
import { attachManyToMany } from "../utils/relations.js";
|
|
6
12
|
import { requireAdmin } from "../utils/require-admin.js";
|
|
7
13
|
export default defineEventHandler(async (event) => {
|
|
8
14
|
await requireAdmin(event);
|
|
9
15
|
const { name, entry, table } = getRegistryEntry(event);
|
|
10
|
-
if (entry.kind
|
|
16
|
+
if (entry.kind === "single") {
|
|
11
17
|
throw createError({ statusCode: 404, statusMessage: "Single objects have no items" });
|
|
12
18
|
}
|
|
13
19
|
const id = parseId(event);
|
|
14
20
|
const db = useDb();
|
|
15
21
|
const rows = await db.select().from(table).where(eq(idColumn(table), id)).limit(1);
|
|
22
|
+
if (entry.kind === "page") {
|
|
23
|
+
const route = requirePageRoute(entry, id);
|
|
24
|
+
if (!rows[0]) return { id: route.key, path: route.path };
|
|
25
|
+
return decodeRows(entry, [rows[0]])[0];
|
|
26
|
+
}
|
|
16
27
|
if (!rows[0]) throw createError({ statusCode: 404, statusMessage: "Row not found" });
|
|
17
28
|
const [attached] = await attachManyToMany(db, name, entry, [rows[0]]);
|
|
18
29
|
return decodeRows(entry, [attached])[0];
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getRegistryEntry,
|
|
9
9
|
idColumn,
|
|
10
10
|
parseId,
|
|
11
|
+
requirePageRoute,
|
|
11
12
|
withUpdatedAt
|
|
12
13
|
} from "../utils/registry.js";
|
|
13
14
|
import {
|
|
@@ -21,13 +22,25 @@ import { mapConstraintErrors } from "../utils/db-errors.js";
|
|
|
21
22
|
export default defineEventHandler(async (event) => {
|
|
22
23
|
await requireAdmin(event);
|
|
23
24
|
const { name, entry, table } = getRegistryEntry(event);
|
|
24
|
-
if (entry.kind
|
|
25
|
+
if (entry.kind === "single") {
|
|
25
26
|
throw createError({
|
|
26
27
|
statusCode: 405,
|
|
27
28
|
statusMessage: "Single objects are updated with PUT without id"
|
|
28
29
|
});
|
|
29
30
|
}
|
|
30
31
|
const id = parseId(event);
|
|
32
|
+
if (entry.kind === "page") {
|
|
33
|
+
const route = requirePageRoute(entry, id);
|
|
34
|
+
const body2 = await readValidatedBody(event, buildValidator(entry, route.path).parse);
|
|
35
|
+
const values2 = encodeColumnValues(entry, body2);
|
|
36
|
+
const set2 = withUpdatedAt(table, values2);
|
|
37
|
+
return mapConstraintErrors(
|
|
38
|
+
() => withTransaction(async (db) => {
|
|
39
|
+
const [row] = await db.insert(table).values({ id: route.key, path: route.path, ...set2 }).onConflictDoUpdate({ target: idColumn(table), set: set2 }).returning();
|
|
40
|
+
return decodeRows(entry, [row])[0];
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
}
|
|
31
44
|
const body = await readValidatedBody(event, buildValidator(entry).parse);
|
|
32
45
|
const { values, lists } = splitRelationValues(
|
|
33
46
|
entry,
|
|
@@ -21,6 +21,7 @@ import * as cmsTables from "#cms-tables";
|
|
|
21
21
|
import { useRuntimeConfig } from "#imports";
|
|
22
22
|
import {
|
|
23
23
|
decodeTranslatableMedia,
|
|
24
|
+
entryFieldsFor,
|
|
24
25
|
hasTranslatableBlockFields,
|
|
25
26
|
isPrivateField,
|
|
26
27
|
isTranslatableMediaField,
|
|
@@ -102,8 +103,9 @@ function resolveLocaleArg(locale) {
|
|
|
102
103
|
function localizeRow(entry, row, locale) {
|
|
103
104
|
const { defaultLocale } = getContentI18n();
|
|
104
105
|
const result = { ...row, [LOCALE]: locale };
|
|
106
|
+
const fields = entryFieldsFor(entry);
|
|
105
107
|
for (const key of translatableFieldKeys(entry)) {
|
|
106
|
-
if (isTranslatableMediaField(
|
|
108
|
+
if (isTranslatableMediaField(fields[key])) {
|
|
107
109
|
const media = decodeTranslatableMedia(row[key], defaultLocale);
|
|
108
110
|
result[key] = pickTranslatedMedia(media, locale, defaultLocale);
|
|
109
111
|
continue;
|
|
@@ -111,7 +113,7 @@ function localizeRow(entry, row, locale) {
|
|
|
111
113
|
const value = row[key];
|
|
112
114
|
result[key] = value?.[locale] ?? value?.[defaultLocale] ?? null;
|
|
113
115
|
}
|
|
114
|
-
for (const [key, field] of Object.entries(
|
|
116
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
115
117
|
if (isPrivateField(field) || !hasTranslatableBlockFields(field)) continue;
|
|
116
118
|
result[key] = localizeBlocks(field, result[key], locale, defaultLocale);
|
|
117
119
|
}
|
|
@@ -268,7 +270,7 @@ function mediaObject(key, row) {
|
|
|
268
270
|
}
|
|
269
271
|
function entryResolvers(config, name, entry) {
|
|
270
272
|
const resolvers = {};
|
|
271
|
-
for (const [key, field] of Object.entries(entry
|
|
273
|
+
for (const [key, field] of Object.entries(entryFieldsFor(entry))) {
|
|
272
274
|
if (isPrivateField(field)) continue;
|
|
273
275
|
if (field.type === "relation" && field.cardinality === "many-to-many") {
|
|
274
276
|
resolvers[key] = async (parent, _args, ctx) => {
|
|
@@ -313,10 +315,25 @@ export function buildCmsSchema() {
|
|
|
313
315
|
const gqlType = typeName(name);
|
|
314
316
|
const fieldLevel = entryResolvers(config, name, entry);
|
|
315
317
|
if (Object.keys(fieldLevel).length) typeResolvers[gqlType] = fieldLevel;
|
|
316
|
-
for (const [key, field] of Object.entries(entry
|
|
318
|
+
for (const [key, field] of Object.entries(entryFieldsFor(entry))) {
|
|
317
319
|
if (field.type === "blocks" && !isPrivateField(field))
|
|
318
320
|
Object.assign(typeResolvers, blockResolvers(name, key, field));
|
|
319
321
|
}
|
|
322
|
+
if (entry.kind === "page") {
|
|
323
|
+
queryResolvers[name] = async (_, args) => {
|
|
324
|
+
const locale = resolveLocaleArg(args.locale);
|
|
325
|
+
const table = tableFor(name);
|
|
326
|
+
const rows = await useDb().select().from(table).orderBy(asc(tableColumns(table).path));
|
|
327
|
+
return rows.map((row) => localizeRow(entry, row, locale));
|
|
328
|
+
};
|
|
329
|
+
queryResolvers[`${name}ByPath`] = async (_, args) => {
|
|
330
|
+
const locale = resolveLocaleArg(args.locale);
|
|
331
|
+
const table = tableFor(name);
|
|
332
|
+
const [row] = await useDb().select().from(table).where(eq(tableColumns(table).path, args.path)).limit(1);
|
|
333
|
+
return row ? localizeRow(entry, row, locale) : null;
|
|
334
|
+
};
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
320
337
|
if (entry.kind === "single") {
|
|
321
338
|
queryResolvers[name] = async (_, args) => {
|
|
322
339
|
const locale = resolveLocaleArg(args.locale);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AnySQLiteColumn, SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
2
2
|
import type { H3Event } from 'h3';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import type { CmsEntry, CmsI18n } from '../../shared/index.js';
|
|
4
|
+
import type { CmsEntry, CmsI18n, CmsPageRoute } from '../../shared/index.js';
|
|
5
5
|
export declare function getContentI18n(): CmsI18n;
|
|
6
6
|
export declare function resolveTable(name: string): SQLiteTable | undefined;
|
|
7
7
|
export declare function tableColumns(table: SQLiteTable): Record<string, AnySQLiteColumn>;
|
|
@@ -14,9 +14,10 @@ export declare function getRegistryEntry(event: H3Event): {
|
|
|
14
14
|
entry: CmsEntry;
|
|
15
15
|
table: SQLiteTable;
|
|
16
16
|
};
|
|
17
|
-
export declare function buildValidator(entry: CmsEntry): z.ZodObject<{
|
|
17
|
+
export declare function buildValidator(entry: CmsEntry, path?: string): z.ZodObject<{
|
|
18
18
|
[x: string]: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
|
|
19
19
|
}, z.core.$strip>;
|
|
20
|
+
export declare function requirePageRoute(entry: CmsEntry, key: string): CmsPageRoute;
|
|
20
21
|
export declare function encodeColumnValues(entry: CmsEntry, values: Record<string, unknown>): Record<string, unknown>;
|
|
21
22
|
export declare function decodeRows<T extends Record<string, unknown>>(entry: CmsEntry, rows: T[]): T[];
|
|
22
23
|
export declare function parseId(event: H3Event): string;
|
|
@@ -3,7 +3,12 @@ import { z } from "zod";
|
|
|
3
3
|
import cmsConfig from "#cms-config";
|
|
4
4
|
import * as cmsTables from "#cms-tables";
|
|
5
5
|
import { useRuntimeConfig } from "#imports";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
decodeEntryTranslatableMedia,
|
|
8
|
+
encodeEntryTranslatableMedia,
|
|
9
|
+
pageFields,
|
|
10
|
+
pageRouteOf
|
|
11
|
+
} from "../../shared/index.js";
|
|
7
12
|
import { buildEntrySchema } from "../../shared/validation.js";
|
|
8
13
|
let contentI18n;
|
|
9
14
|
export function getContentI18n() {
|
|
@@ -40,8 +45,14 @@ export function getRegistryEntry(event) {
|
|
|
40
45
|
}
|
|
41
46
|
return { name, entry: config[name], table };
|
|
42
47
|
}
|
|
43
|
-
export function buildValidator(entry) {
|
|
44
|
-
|
|
48
|
+
export function buildValidator(entry, path) {
|
|
49
|
+
const fields = path ? pageFields(entry, path) : entry.fields;
|
|
50
|
+
return buildEntrySchema({ ...entry, fields }, getContentI18n());
|
|
51
|
+
}
|
|
52
|
+
export function requirePageRoute(entry, key) {
|
|
53
|
+
const route = pageRouteOf(entry, key);
|
|
54
|
+
if (!route) throw createError({ statusCode: 404, statusMessage: `Unknown page: ${key}` });
|
|
55
|
+
return route;
|
|
45
56
|
}
|
|
46
57
|
export function encodeColumnValues(entry, values) {
|
|
47
58
|
return encodeEntryTranslatableMedia(entry, values);
|
|
@@ -3,9 +3,10 @@ import { createError } from "h3";
|
|
|
3
3
|
import cmsConfig from "#cms-config";
|
|
4
4
|
import { useDb } from "#cms-db";
|
|
5
5
|
import * as cmsTables from "#cms-tables";
|
|
6
|
+
import { entryFieldsFor } from "../../shared/index.js";
|
|
6
7
|
import { resolveTable, tableColumns } from "./registry.js";
|
|
7
8
|
function manyToManyKeys(entry) {
|
|
8
|
-
return Object.entries(entry
|
|
9
|
+
return Object.entries(entryFieldsFor(entry)).filter(([, field]) => field.type === "relation" && field.cardinality === "many-to-many").map(([key]) => key);
|
|
9
10
|
}
|
|
10
11
|
function joinTable(name, key) {
|
|
11
12
|
const table = cmsTables[`${name}_${key}`];
|
|
@@ -34,7 +35,7 @@ export async function assertRelationTargets(entry, lists) {
|
|
|
34
35
|
const db = useDb();
|
|
35
36
|
for (const [key, ids] of Object.entries(lists)) {
|
|
36
37
|
if (!ids.length) continue;
|
|
37
|
-
const field = entry
|
|
38
|
+
const field = entryFieldsFor(entry)[key];
|
|
38
39
|
const target = resolveTable(field.to);
|
|
39
40
|
if (!target) continue;
|
|
40
41
|
const idCol = tableColumns(target).id;
|
|
@@ -61,7 +62,7 @@ export async function saveManyToMany(db, name, sourceId, lists) {
|
|
|
61
62
|
export async function relationTitles(db, entry, rows) {
|
|
62
63
|
const config = cmsConfig;
|
|
63
64
|
const titles = {};
|
|
64
|
-
for (const [key, field] of Object.entries(entry
|
|
65
|
+
for (const [key, field] of Object.entries(entryFieldsFor(entry))) {
|
|
65
66
|
if (field.type !== "relation" || !field.to) continue;
|
|
66
67
|
const targetEntry = config[field.to];
|
|
67
68
|
const target = resolveTable(field.to);
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
|
+
PAGE_PATH_FIELD,
|
|
2
3
|
blockTypeName,
|
|
3
4
|
blocksFieldTypeName,
|
|
4
5
|
isPrivateField,
|
|
5
6
|
isRequiredField,
|
|
6
7
|
isTranslatableField,
|
|
8
|
+
pageAllFields,
|
|
7
9
|
typeName
|
|
8
10
|
} from "./index.js";
|
|
9
11
|
export { blockTypeName, blocksFieldTypeName, typeName };
|
|
12
|
+
function entryFields(entry) {
|
|
13
|
+
return entry.kind === "page" ? pageAllFields(entry) : entry.fields;
|
|
14
|
+
}
|
|
10
15
|
function scalarFor(field) {
|
|
11
16
|
switch (field.type) {
|
|
12
17
|
case "number":
|
|
@@ -62,7 +67,8 @@ function fieldSdl(config, entryName, key, field) {
|
|
|
62
67
|
}
|
|
63
68
|
function entrySdl(config, name, entry) {
|
|
64
69
|
const lines = [" id: ID!"];
|
|
65
|
-
|
|
70
|
+
if (entry.kind === "page") lines.push(` ${PAGE_PATH_FIELD}: String!`);
|
|
71
|
+
for (const [key, field] of Object.entries(entryFields(entry))) {
|
|
66
72
|
if (isPrivateField(field)) continue;
|
|
67
73
|
lines.push(fieldSdl(config, name, key, field));
|
|
68
74
|
}
|
|
@@ -146,7 +152,7 @@ export function renderGraphqlSdl(config) {
|
|
|
146
152
|
for (const [name, entry] of Object.entries(config)) {
|
|
147
153
|
const gqlType = typeName(name);
|
|
148
154
|
types.push(entrySdl(config, name, entry));
|
|
149
|
-
for (const [key, field] of Object.entries(entry
|
|
155
|
+
for (const [key, field] of Object.entries(entryFields(entry))) {
|
|
150
156
|
if (field.type === "blocks" && !isPrivateField(field))
|
|
151
157
|
types.push(...blocksSdl(name, key, field));
|
|
152
158
|
}
|
|
@@ -154,6 +160,11 @@ export function renderGraphqlSdl(config) {
|
|
|
154
160
|
queryLines.push(` ${name}(locale: String): ${gqlType}`);
|
|
155
161
|
continue;
|
|
156
162
|
}
|
|
163
|
+
if (entry.kind === "page") {
|
|
164
|
+
queryLines.push(` ${name}(locale: String): [${gqlType}!]!`);
|
|
165
|
+
queryLines.push(` ${name}ByPath(path: String!, locale: String): ${gqlType}`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
157
168
|
types.push(filterSdl(name, entry));
|
|
158
169
|
queryLines.push(
|
|
159
170
|
` ${name}(filters: ${gqlType}Filters, sort: [${gqlType}Sort!], limit: Int, offset: Int, locale: String): [${gqlType}!]!`
|
|
@@ -79,25 +79,51 @@ export declare function decodeTranslatableValue(value: unknown, defaultLocale: s
|
|
|
79
79
|
export declare const decodeTranslatableMedia: typeof decodeTranslatableValue;
|
|
80
80
|
export declare function encodeTranslatableMedia(value: unknown): string | null;
|
|
81
81
|
export declare function pickTranslatedMedia(values: Record<string, string> | null | undefined, locale: string, defaultLocale: string): string | null;
|
|
82
|
-
export declare function translatableMediaKeys(entry:
|
|
83
|
-
export declare function encodeEntryTranslatableMedia(entry:
|
|
84
|
-
export declare function decodeEntryTranslatableMedia<T extends Record<string, unknown>>(entry:
|
|
82
|
+
export declare function translatableMediaKeys(entry: EntryLike): string[];
|
|
83
|
+
export declare function encodeEntryTranslatableMedia(entry: EntryLike, values: Record<string, unknown>): Record<string, unknown>;
|
|
84
|
+
export declare function decodeEntryTranslatableMedia<T extends Record<string, unknown>>(entry: EntryLike, rows: T[], defaultLocale: string): T[];
|
|
85
85
|
export declare function isMultiSelect(field: FieldConfig): boolean;
|
|
86
|
-
export declare function translatableFieldKeys(entry:
|
|
86
|
+
export declare function translatableFieldKeys(entry: EntryLike): string[];
|
|
87
87
|
export declare function translatableBlockFieldKeys(block: BlockConfig): string[];
|
|
88
88
|
export declare function hasTranslatableBlockFields(field: FieldConfig): boolean;
|
|
89
89
|
export declare function localizeBlock(field: FieldConfig, item: unknown, locale: string, defaultLocale: string): unknown;
|
|
90
90
|
export declare function localizeBlocks(field: FieldConfig, value: unknown, locale: string, defaultLocale: string): unknown;
|
|
91
|
+
export type CmsEntryKind = 'collection' | 'single' | 'page';
|
|
92
|
+
export interface CmsPageRoute {
|
|
93
|
+
path: string;
|
|
94
|
+
key: string;
|
|
95
|
+
label: string;
|
|
96
|
+
}
|
|
91
97
|
export interface CmsEntry {
|
|
92
98
|
id: string;
|
|
93
99
|
label: string;
|
|
94
|
-
kind:
|
|
100
|
+
kind: CmsEntryKind;
|
|
95
101
|
titleField?: string;
|
|
96
102
|
drafts?: boolean;
|
|
97
103
|
fields: Record<string, FieldConfig>;
|
|
104
|
+
routes?: 'auto' | string[];
|
|
105
|
+
include?: string[];
|
|
106
|
+
exclude?: string[];
|
|
107
|
+
order?: string[];
|
|
108
|
+
labels?: Record<string, string>;
|
|
109
|
+
overrides?: Record<string, Record<string, FieldConfig | null>>;
|
|
110
|
+
pages?: CmsPageRoute[];
|
|
98
111
|
table?: CmsTable;
|
|
99
112
|
}
|
|
100
113
|
export type CmsConfig = Record<string, CmsEntry>;
|
|
114
|
+
export declare const PAGE_PATH_FIELD = "path";
|
|
115
|
+
export declare function isPageEntry(entry: CmsEntry): boolean;
|
|
116
|
+
export declare function pageSegments(path: string): string[];
|
|
117
|
+
export declare function pageKeyFromPath(path: string): string;
|
|
118
|
+
export declare function pageLabelFromPath(path: string): string;
|
|
119
|
+
export declare function pageParentPath(path: string): string | undefined;
|
|
120
|
+
export declare function pageRoutes(entry: CmsEntry): CmsPageRoute[];
|
|
121
|
+
export declare function pageRouteOf(entry: CmsEntry, key: string): CmsPageRoute | undefined;
|
|
122
|
+
export declare function pageOverrideFields(entry: CmsEntry, path: string): Record<string, FieldConfig | null>;
|
|
123
|
+
export declare function pageFields(entry: CmsEntry, path: string): Record<string, FieldConfig>;
|
|
124
|
+
export declare function pageAllFields(entry: CmsEntry): Record<string, FieldConfig>;
|
|
125
|
+
type EntryLike = Pick<CmsEntry, 'fields'> & Partial<Pick<CmsEntry, 'kind' | 'overrides'>>;
|
|
126
|
+
export declare function entryFieldsFor(entry: EntryLike, path?: string): Record<string, FieldConfig>;
|
|
101
127
|
export declare function typeName(name: string): string;
|
|
102
128
|
export declare function blocksFieldTypeName(entryName: string, fieldKey: string): string;
|
|
103
129
|
export declare function blockTypeName(entryName: string, fieldKey: string, blockName: string): string;
|
|
@@ -178,7 +204,18 @@ export interface CmsSingleInput extends CmsEntryInputBase {
|
|
|
178
204
|
kind: 'single';
|
|
179
205
|
titleField?: never;
|
|
180
206
|
}
|
|
181
|
-
export
|
|
207
|
+
export interface CmsPageInput extends CmsEntryInputBase {
|
|
208
|
+
kind: 'page';
|
|
209
|
+
titleField?: never;
|
|
210
|
+
drafts?: never;
|
|
211
|
+
routes?: 'auto' | string[];
|
|
212
|
+
include?: string[];
|
|
213
|
+
exclude?: string[];
|
|
214
|
+
order?: string[];
|
|
215
|
+
labels?: Record<string, string>;
|
|
216
|
+
overrides?: Record<string, Record<string, CmsFieldInput | null>>;
|
|
217
|
+
}
|
|
218
|
+
export type CmsEntryInput = CmsCollectionInput | CmsSingleInput | CmsPageInput;
|
|
182
219
|
export type CmsConfigInput = Record<string, CmsEntryInput>;
|
|
183
220
|
export declare function defineCmsConfig<T extends CmsConfigInput>(config: T): T;
|
|
184
221
|
export {};
|
|
@@ -129,7 +129,7 @@ export function pickTranslatedMedia(values, locale, defaultLocale) {
|
|
|
129
129
|
return values[locale] || values[defaultLocale] || Object.values(values).find(Boolean) || null;
|
|
130
130
|
}
|
|
131
131
|
export function translatableMediaKeys(entry) {
|
|
132
|
-
return Object.entries(entry
|
|
132
|
+
return Object.entries(entryFieldsFor(entry)).filter(([, field]) => isTranslatableMediaField(field)).map(([key]) => key);
|
|
133
133
|
}
|
|
134
134
|
export function encodeEntryTranslatableMedia(entry, values) {
|
|
135
135
|
const keys = translatableMediaKeys(entry);
|
|
@@ -156,7 +156,7 @@ export function isMultiSelect(field) {
|
|
|
156
156
|
return field.type === "select" && !!field.multiple;
|
|
157
157
|
}
|
|
158
158
|
export function translatableFieldKeys(entry) {
|
|
159
|
-
return Object.entries(entry
|
|
159
|
+
return Object.entries(entryFieldsFor(entry)).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
|
|
160
160
|
}
|
|
161
161
|
export function translatableBlockFieldKeys(block) {
|
|
162
162
|
return Object.entries(block.fields).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
|
|
@@ -184,6 +184,58 @@ export function localizeBlocks(field, value, locale, defaultLocale) {
|
|
|
184
184
|
if (!Array.isArray(value)) return value;
|
|
185
185
|
return value.map((item) => localizeBlock(field, item, locale, defaultLocale));
|
|
186
186
|
}
|
|
187
|
+
export const PAGE_PATH_FIELD = "path";
|
|
188
|
+
export function isPageEntry(entry) {
|
|
189
|
+
return entry.kind === "page";
|
|
190
|
+
}
|
|
191
|
+
export function pageSegments(path) {
|
|
192
|
+
return path.split("/").filter(Boolean);
|
|
193
|
+
}
|
|
194
|
+
export function pageKeyFromPath(path) {
|
|
195
|
+
const words = pageSegments(path).flatMap((segment) => segment.split("-"));
|
|
196
|
+
if (!words.length) return "home";
|
|
197
|
+
return words.map((word, index) => index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
198
|
+
}
|
|
199
|
+
export function pageLabelFromPath(path) {
|
|
200
|
+
const last = pageSegments(path).pop();
|
|
201
|
+
if (!last) return "Home";
|
|
202
|
+
return last.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
203
|
+
}
|
|
204
|
+
export function pageParentPath(path) {
|
|
205
|
+
const segments = pageSegments(path);
|
|
206
|
+
if (segments.length < 2) return void 0;
|
|
207
|
+
return `/${segments.slice(0, -1).join("/")}`;
|
|
208
|
+
}
|
|
209
|
+
export function pageRoutes(entry) {
|
|
210
|
+
return entry.pages ?? [];
|
|
211
|
+
}
|
|
212
|
+
export function pageRouteOf(entry, key) {
|
|
213
|
+
return pageRoutes(entry).find((route) => route.key === key);
|
|
214
|
+
}
|
|
215
|
+
export function pageOverrideFields(entry, path) {
|
|
216
|
+
return entry.overrides?.[path] ?? {};
|
|
217
|
+
}
|
|
218
|
+
export function pageFields(entry, path) {
|
|
219
|
+
const fields = { ...entry.fields };
|
|
220
|
+
for (const [key, field] of Object.entries(pageOverrideFields(entry, path))) {
|
|
221
|
+
if (field) fields[key] = field;
|
|
222
|
+
else delete fields[key];
|
|
223
|
+
}
|
|
224
|
+
return fields;
|
|
225
|
+
}
|
|
226
|
+
export function pageAllFields(entry) {
|
|
227
|
+
const fields = { ...entry.fields };
|
|
228
|
+
for (const override of Object.values(entry.overrides ?? {})) {
|
|
229
|
+
for (const [key, field] of Object.entries(override)) {
|
|
230
|
+
if (field && !fields[key]) fields[key] = field;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return fields;
|
|
234
|
+
}
|
|
235
|
+
export function entryFieldsFor(entry, path) {
|
|
236
|
+
if (entry.kind !== "page") return entry.fields;
|
|
237
|
+
return path ? pageFields(entry, path) : pageAllFields(entry);
|
|
238
|
+
}
|
|
187
239
|
export function typeName(name) {
|
|
188
240
|
return name.replace(/(?:^|_)([a-z0-9])/gi, (_, c) => c.toUpperCase());
|
|
189
241
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.44",
|
|
4
4
|
"description": "Lightweight CMS that ships with your Nuxt app: runs on the Nitro server, content types defined in code, /cms admin panel, GraphQL API, SQLite or Postgres. No external CMS needed!",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Edoardo Alberti (https://github.com/xleddyl)",
|