@xleddyl/nuxt-cms 0.1.42 → 0.1.43

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 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.1.42",
4
+ "version": "0.1.43",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
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,51 @@ 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 'single'`);
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 of Object.keys(override)) {
158
+ if (Object.hasOwn(entry.fields ?? {}, key))
159
+ errors.push(`${oat}: field '${key}' is already declared for every page`);
160
+ if (key === PAGE_PATH_FIELD)
161
+ errors.push(`${oat}: '${PAGE_PATH_FIELD}' is a reserved field name on pages`);
162
+ }
163
+ }
164
+ const declared = /* @__PURE__ */ new Map();
165
+ for (const [path, override] of Object.entries(entry.overrides ?? {})) {
166
+ for (const [key, field] of Object.entries(override)) {
167
+ const seen = declared.get(key);
168
+ if (seen && seen !== field.type) {
169
+ errors.push(
170
+ `${at}: field '${key}' is declared as '${seen}' and '${field.type}' by different pages`
171
+ );
172
+ }
173
+ declared.set(key, field.type);
174
+ }
175
+ }
176
+ } else if (entry.overrides || entry.routes || entry.include || entry.exclude) {
177
+ errors.push(`${at}: routes, include, exclude and overrides need kind 'page'`);
178
+ }
135
179
  if (entry.drafts && entry.kind !== "collection")
136
180
  errors.push(`${at}: drafts are only supported on collections`);
137
181
  if (!entry.fields || !Object.keys(entry.fields).length)
@@ -150,8 +194,9 @@ function validateConfig(config, i18n) {
150
194
  );
151
195
  }
152
196
  }
197
+ const allFields = entry.kind === "page" ? pageAllFields(entry) : entry.fields ?? {};
153
198
  const columnNames = /* @__PURE__ */ new Set();
154
- for (const [key, field] of Object.entries(entry.fields ?? {})) {
199
+ for (const [key, field] of Object.entries(allFields)) {
155
200
  const fat = `${at}, field '${key}'`;
156
201
  if (!IDENTIFIER.test(key)) errors.push(`${fat}: key must be a valid identifier`);
157
202
  const column = snakeCase(key);
@@ -181,7 +226,7 @@ function validateConfig(config, i18n) {
181
226
  errors.push(`${fat}: the titleField cannot be conditional`);
182
227
  for (const condition of fieldConditions(field)) {
183
228
  const cat = `${fat}, showIf on '${condition.field}'`;
184
- const target = entry.fields?.[condition.field];
229
+ const target = allFields[condition.field];
185
230
  if (!condition.field || !target) {
186
231
  errors.push(`${cat}: '${condition.field}' is not a declared field`);
187
232
  continue;
@@ -380,7 +425,10 @@ function tableExpr(name, entry, dialect) {
380
425
  const tableFn = pg ? "pgTable" : "sqliteTable";
381
426
  const lines = [];
382
427
  lines.push(` id: text('id').primaryKey(),`);
383
- for (const [key, field] of Object.entries(entry.fields)) {
428
+ if (entry.kind === "page") lines.push(` path: text('path').notNull().unique(),`);
429
+ for (const [key, field] of Object.entries(
430
+ entry.kind === "page" ? pageAllFields(entry) : entry.fields
431
+ )) {
384
432
  if (isManyToMany(field)) continue;
385
433
  lines.push(columnExpr(key, field, dialect));
386
434
  }
@@ -440,7 +488,7 @@ function renderSchemaFile(config, dialect, resolveImport = (s) => s) {
440
488
  ];
441
489
  const tables = derived.map(([name, entry]) => tableExpr(name, entry, dialect));
442
490
  const joins = derived.flatMap(
443
- ([name, entry]) => Object.entries(entry.fields).filter(([, field]) => isManyToMany(field)).map(([key, field]) => joinTableExpr(name, key, field, dialect))
491
+ ([name, entry]) => Object.entries(entry.kind === "page" ? pageAllFields(entry) : entry.fields).filter(([, field]) => isManyToMany(field)).map(([key, field]) => joinTableExpr(name, key, field, dialect))
444
492
  );
445
493
  return `${imports.join("\n")}
446
494
 
@@ -462,9 +510,10 @@ function blocksSelection(entryName, key, field) {
462
510
  }
463
511
  return parts.join(" ");
464
512
  }
465
- function entrySelection(config, name, entry, withRelations) {
513
+ function entrySelection(config, name, entry, withRelations, fields = entryFieldsFor(entry)) {
466
514
  const parts = ["id"];
467
- for (const [key, field] of Object.entries(entry.fields)) {
515
+ if (entry.kind === "page") parts.push("path");
516
+ for (const [key, field] of Object.entries(fields)) {
468
517
  if (isPrivateField(field)) continue;
469
518
  if (field.type === "relation") {
470
519
  const target = config[field.to];
@@ -502,10 +551,25 @@ function collectionQuery(config, name, entry) {
502
551
  ].join(", ");
503
552
  return `query CmsCollection(${args}) { ${name}(locale: $locale, filters: $filters, sort: $sort, limit: $limit, offset: $offset) { ${selection} } }`;
504
553
  }
554
+ function pageQuery(config, name, entry, path) {
555
+ const selection = entrySelection(config, name, entry, true, pageFields(entry, path));
556
+ return `query CmsPage($path: String!, $locale: String) { page: ${name}ByPath(path: $path, locale: $locale) { ${selection} } }`;
557
+ }
505
558
  function renderQueriesFile(config) {
506
559
  const singles = [];
507
560
  const collections = [];
561
+ const pages = [];
508
562
  for (const [name, entry] of Object.entries(config)) {
563
+ if (entry.kind === "page") {
564
+ for (const route of pageRoutes(entry)) {
565
+ pages.push(
566
+ ` ${JSON.stringify(route.path)}: ${JSON.stringify(
567
+ pageQuery(config, name, entry, route.path)
568
+ )},`
569
+ );
570
+ }
571
+ continue;
572
+ }
509
573
  const line = ` ${JSON.stringify(name)}: ${JSON.stringify(
510
574
  entry.kind === "single" ? singleQuery(config, name, entry) : collectionQuery(config, name, entry)
511
575
  )},`;
@@ -520,10 +584,53 @@ function renderQueriesFile(config) {
520
584
  `export const cmsCollectionQueries: Record<string, string> = {`,
521
585
  ...collections,
522
586
  `}`,
587
+ ``,
588
+ `export const cmsPageQueries: Record<string, string> = {`,
589
+ ...pages,
590
+ `}`,
523
591
  ``
524
592
  ].join("\n");
525
593
  }
526
594
 
595
+ const DYNAMIC = /[[\]]/;
596
+ function vueFiles(dir, prefix = "") {
597
+ if (!existsSync(dir)) return [];
598
+ const files = [];
599
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
600
+ const name = `${prefix}${item.name}`;
601
+ if (item.isDirectory()) files.push(...vueFiles(join(dir, item.name), `${name}/`));
602
+ else if (item.name.endsWith(".vue")) files.push(name);
603
+ }
604
+ return files;
605
+ }
606
+ function routePathFromFile(file) {
607
+ if (DYNAMIC.test(file)) return null;
608
+ const segments = file.replace(/\.vue$/, "").split("/").filter((segment) => !/^\(.+\)$/.test(segment));
609
+ if (segments.at(-1) === "index") segments.pop();
610
+ const path = `/${segments.join("/")}`.replace(/\/$/, "");
611
+ return path || "/";
612
+ }
613
+ function routePathsFromDir(pagesDir) {
614
+ const paths = /* @__PURE__ */ new Set();
615
+ for (const file of vueFiles(pagesDir)) {
616
+ const path = routePathFromFile(file);
617
+ if (path) paths.add(path);
618
+ }
619
+ return [...paths];
620
+ }
621
+ function resolvePageRoutes(entry, discovered) {
622
+ const declared = Array.isArray(entry.routes) ? entry.routes : discovered;
623
+ const paths = /* @__PURE__ */ new Set([...declared, ...entry.include ?? []]);
624
+ for (const path of entry.exclude ?? []) paths.delete(path);
625
+ const rank = new Map((entry.order ?? []).map((path, index) => [path, index]));
626
+ const order = entry.order?.length ?? 0;
627
+ return [...paths].sort((a, b) => (rank.get(a) ?? order) - (rank.get(b) ?? order) || a.localeCompare(b)).map((path) => ({
628
+ path,
629
+ key: pageKeyFromPath(path),
630
+ label: entry.labels?.[path] ?? pageLabelFromPath(path)
631
+ }));
632
+ }
633
+
527
634
  function mediaTsType(field) {
528
635
  const types = mediaTypeFilter(field.mediaType);
529
636
  return types ? `CmsMedia<${types.map((type) => `'${type}'`).join(" | ")}>` : "CmsMedia";
@@ -586,7 +693,8 @@ ${lines.join("\n")}
586
693
  }
587
694
  function entryTs(config, name, entry) {
588
695
  const lines = [" id: string"];
589
- for (const [key, field] of Object.entries(entry.fields)) {
696
+ if (entry.kind === "page") lines.push(" path: string");
697
+ for (const [key, field] of Object.entries(entryFieldsFor(entry))) {
590
698
  if (isPrivateField(field)) continue;
591
699
  lines.push(` ${key}: ${fieldTsType(config, name, key, field)}`);
592
700
  }
@@ -597,7 +705,7 @@ ${lines.join("\n")}
597
705
  }`;
598
706
  }
599
707
  function relationKeys(entry) {
600
- return Object.entries(entry.fields).filter(([, field]) => field.type === "relation" && !isPrivateField(field)).map(([key]) => key);
708
+ return Object.entries(entryFieldsFor(entry)).filter(([, field]) => field.type === "relation" && !isPrivateField(field)).map(([key]) => key);
601
709
  }
602
710
  function quotedKeys(keys) {
603
711
  return keys.map((key) => `'${key}'`).join(" | ");
@@ -625,14 +733,37 @@ ${lines.join(
625
733
  )}
626
734
  }`;
627
735
  }
736
+ function pageTypesTs(name, entry) {
737
+ const auto = `${typeName(name)}Auto`;
738
+ const lines = pageRoutes(entry).map((route) => {
739
+ const keys = ["id", "path", "updatedAt", ...Object.keys(pageFields(entry, route.path))];
740
+ return ` ${JSON.stringify(route.path)}: Pick<${auto}, ${quotedKeys(keys)}>`;
741
+ });
742
+ return [
743
+ `export interface CmsPageTypes {
744
+ ${lines.join("\n")}
745
+ }`,
746
+ `export type CmsPagePath = keyof CmsPageTypes`
747
+ ];
748
+ }
628
749
  function entryMapsTs(config) {
629
750
  const singles = [];
630
751
  const collections = [];
752
+ const pages = [];
631
753
  for (const [name, entry] of Object.entries(config)) {
632
754
  const line = ` ${JSON.stringify(name)}: ${typeName(name)}Auto`;
633
755
  if (entry.kind === "single") singles.push(line);
756
+ else if (entry.kind === "page") pages.push(...pageTypesTs(name, entry));
634
757
  else collections.push(line);
635
758
  }
759
+ if (!pages.length) {
760
+ pages.push(
761
+ `export interface CmsPageTypes {
762
+ [path: string]: never
763
+ }`,
764
+ `export type CmsPagePath = keyof CmsPageTypes`
765
+ );
766
+ }
636
767
  return [
637
768
  `export interface CmsSingleTypes {
638
769
  ${singles.join("\n")}
@@ -641,7 +772,8 @@ ${singles.join("\n")}
641
772
  ${collections.join("\n")}
642
773
  }`,
643
774
  `export type CmsSingleName = keyof CmsSingleTypes`,
644
- `export type CmsCollectionName = keyof CmsCollectionTypes`
775
+ `export type CmsCollectionName = keyof CmsCollectionTypes`,
776
+ ...pages
645
777
  ];
646
778
  }
647
779
  function renderTypesFile(config) {
@@ -660,7 +792,7 @@ function renderTypesFile(config) {
660
792
  }`
661
793
  ];
662
794
  for (const [name, entry] of Object.entries(config)) {
663
- for (const [key, field] of Object.entries(entry.fields)) {
795
+ for (const [key, field] of Object.entries(entryFieldsFor(entry))) {
664
796
  if (field.type === "blocks" && !isPrivateField(field))
665
797
  parts.push(...blockTypesTs(name, key, field));
666
798
  }
@@ -782,6 +914,38 @@ async function loadCmsConfig(nuxt, resolver, configPathOption, i18n, logger) {
782
914
  );
783
915
  nuxt.options.alias["#cms-config"] = resolver.resolve("./runtime/shared/empty-config");
784
916
  }
917
+ const pagesDir = join(
918
+ nuxt.options.srcDir ?? nuxt.options.rootDir,
919
+ nuxt.options.dir?.pages ?? "pages"
920
+ );
921
+ const discoveredRoutes = routePathsFromDir(pagesDir);
922
+ const routesByEntry = {};
923
+ for (const [name, entry] of Object.entries(cmsConfig)) {
924
+ if (entry.kind !== "page") continue;
925
+ entry.pages = resolvePageRoutes(entry, discoveredRoutes);
926
+ routesByEntry[name] = entry.pages;
927
+ }
928
+ if (Object.keys(routesByEntry).length) {
929
+ const source = (nuxt.options.alias["#cms-config"] ?? "").replace(/\\/g, "/").replace(/\.[cm]?[jt]s$/, "");
930
+ const wrapper = addTemplate({
931
+ filename: "cms/config.ts",
932
+ write: true,
933
+ getContents: () => [
934
+ `import config from '${source}'`,
935
+ ``,
936
+ `const pageRoutes = ${JSON.stringify(routesByEntry, null, 3)}`,
937
+ ``,
938
+ `for (const [name, routes] of Object.entries(pageRoutes)) {`,
939
+ ` const entry = (config as Record<string, { pages?: unknown }>)[name]`,
940
+ ` if (entry) entry.pages = routes`,
941
+ `}`,
942
+ ``,
943
+ `export default config`,
944
+ ``
945
+ ].join("\n")
946
+ });
947
+ nuxt.options.alias["#cms-config"] = wrapper.dst;
948
+ }
785
949
  const configErrors = validateConfig(cmsConfig, i18n);
786
950
  if (configErrors.length) {
787
951
  for (const error of configErrors) logger.error(error);
@@ -904,7 +1068,8 @@ const module$1 = defineNuxtModule({
904
1068
  { name: "useCms", from: queryStub },
905
1069
  { name: "$cmsQuery", from: queryStub },
906
1070
  { name: "useCmsSingle", from: entryStub },
907
- { name: "useCmsCollection", from: entryStub }
1071
+ { name: "useCmsCollection", from: entryStub },
1072
+ { name: "useCmsPage", from: entryStub }
908
1073
  ]);
909
1074
  addCmsTypeTemplates(
910
1075
  nuxt,
@@ -966,7 +1131,8 @@ const module$1 = defineNuxtModule({
966
1131
  { name: "useCms", from: queryComposables },
967
1132
  { name: "$cmsQuery", from: queryComposables },
968
1133
  { name: "useCmsSingle", from: entryComposables },
969
- { name: "useCmsCollection", from: entryComposables }
1134
+ { name: "useCmsCollection", from: entryComposables },
1135
+ { name: "useCmsPage", from: entryComposables }
970
1136
  ]);
971
1137
  const {
972
1138
  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
+ }
@@ -71,6 +71,11 @@ const groups = computed(
71
71
  icon: "document-text",
72
72
  links: links.filter((l) => l.kind === "single")
73
73
  },
74
+ {
75
+ title: "Pages",
76
+ icon: "window",
77
+ links: links.filter((l) => l.kind === "page")
78
+ },
74
79
  {
75
80
  title: "Library",
76
81
  icon: "photo",
@@ -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 === "collection" ? listQuery : void 0
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="isNew ? 'New entry' : 'Edit entry'">
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="config.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 isNew = id === void 0;
69
- const drafts = !!config.drafts;
70
- const fieldKeys = Object.keys(config.fields);
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({ statusCode: 405, statusMessage: "Single objects cannot be deleted" });
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 { decodeRows, getRegistryEntry, idColumn, parseId } from "../utils/registry.js";
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 !== "collection") {
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 !== "collection") {
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(entry.fields[key])) {
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(entry.fields)) {
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.fields)) {
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.fields)) {
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 { decodeEntryTranslatableMedia, encodeEntryTranslatableMedia } from "../../shared/index.js";
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
- return buildEntrySchema(entry, getContentI18n());
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.fields).filter(([, field]) => field.type === "relation" && field.cardinality === "many-to-many").map(([key]) => key);
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.fields[key];
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.fields)) {
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
- for (const [key, field] of Object.entries(entry.fields)) {
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.fields)) {
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: Pick<CmsEntry, 'fields'>): string[];
83
- export declare function encodeEntryTranslatableMedia(entry: Pick<CmsEntry, 'fields'>, values: Record<string, unknown>): Record<string, unknown>;
84
- export declare function decodeEntryTranslatableMedia<T extends Record<string, unknown>>(entry: Pick<CmsEntry, 'fields'>, rows: T[], defaultLocale: string): T[];
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: CmsEntry): string[];
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: 'collection' | 'single';
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>>;
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>;
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 type CmsEntryInput = CmsCollectionInput | CmsSingleInput;
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>>;
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.fields).filter(([, field]) => isTranslatableMediaField(field)).map(([key]) => key);
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.fields).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
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,53 @@ 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
+ return { ...entry.fields, ...pageOverrideFields(entry, path) };
220
+ }
221
+ export function pageAllFields(entry) {
222
+ const fields = { ...entry.fields };
223
+ for (const override of Object.values(entry.overrides ?? {})) {
224
+ for (const [key, field] of Object.entries(override)) {
225
+ if (!fields[key]) fields[key] = field;
226
+ }
227
+ }
228
+ return fields;
229
+ }
230
+ export function entryFieldsFor(entry, path) {
231
+ if (entry.kind !== "page") return entry.fields;
232
+ return path ? pageFields(entry, path) : pageAllFields(entry);
233
+ }
187
234
  export function typeName(name) {
188
235
  return name.replace(/(?:^|_)([a-z0-9])/gi, (_, c) => c.toUpperCase());
189
236
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
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)",