@xleddyl/nuxt-cms 0.1.40 → 0.1.42

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
@@ -57,8 +57,9 @@ Then declare your content types in a `cms.config.ts` at the project root with `d
57
57
  ### Disabling the CMS
58
58
 
59
59
  Keep the module in `modules[]` at all times and turn it off with the `enabled` option or the
60
- `NUXT_CMS_ENABLED` env var. When disabled the module registers no-op `useCms` / `$cmsQuery` stubs and
61
- nothing else, so components can call them unconditionally and simply render their empty states:
60
+ `NUXT_CMS_ENABLED` env var. When disabled the module registers no-op query composables and keeps the
61
+ generated types, and nothing else, so components can call them unconditionally and simply render
62
+ their empty states:
62
63
 
63
64
  ```ts
64
65
  export default defineNuxtConfig({
@@ -97,7 +98,7 @@ Full documentation lives in [`docs/`](docs/README.md):
97
98
  - [Configuration](docs/configuration.md) — every `cms.*` option and the `NUXT_CMS_*` env vars.
98
99
  - [Database](docs/database.md) — SQLite, Postgres, libSQL/Turso and D1 drivers, migrations, studio.
99
100
  - [Schema](docs/schema.md) — `defineCmsConfig`, entries, field types, relations, blocks, i18n.
100
- - [Querying content](docs/querying.md) — GraphQL API, `useCms` / `$cmsQuery`, filters, sorting, pagination.
101
+ - [Querying content](docs/querying.md) — GraphQL API, `useCmsSingle` / `useCmsCollection` / `useCms` / `$cmsQuery`, filters, sorting, pagination.
101
102
  - [Admin panel & security](docs/admin.md) — pages, authentication, sessions, admin REST API.
102
103
  - [Media](docs/media.md) — S3-compatible storage or local mode backed by your `public/` folder, upload flow, allowed file types.
103
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.40",
4
+ "version": "0.1.42",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -3,14 +3,14 @@ import { existsSync } 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';
6
- import { defineNuxtModule, createResolver, useLogger, addImports, resolvePath, addTemplate, addServerPlugin, addTypeTemplate, addVitePlugin, addComponentsDir, addRouteMiddleware, extendPages, addServerHandler } from '@nuxt/kit';
6
+ import { defineNuxtModule, createResolver, useLogger, addImports, addTemplate, addServerPlugin, addTypeTemplate, addVitePlugin, addComponentsDir, addRouteMiddleware, extendPages, addServerHandler, resolvePath } from '@nuxt/kit';
7
7
  import tailwindcss from '@tailwindcss/vite';
8
8
  import svgLoader from 'vite-svg-loader';
9
9
  import { introspectionFromSchema, buildSchema } from 'graphql';
10
10
  import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal';
11
11
  import { createJiti } from 'jiti';
12
- import { typeName, blockTypeName, blockUnionName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
13
- import { isMultiSelect, fieldConditions, isTranslatableField, isTranslatableMediaField, isRequiredField, isPrivateField, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
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';
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';
@@ -450,6 +450,84 @@ ${[mediaTableExpr(dialect), ...tables, ...joins].join(
450
450
  `;
451
451
  }
452
452
 
453
+ const MEDIA_SELECTION = "key url type alt folder mime size width height";
454
+ function blocksSelection(entryName, key, field) {
455
+ const parts = ["type"];
456
+ for (const [blockName, block] of Object.entries(field.blocks ?? {})) {
457
+ const fields = Object.entries(block.fields).map(
458
+ ([blockFieldKey, blockField]) => blockField.type === "media" ? `${blockFieldKey} { ${MEDIA_SELECTION} }` : blockFieldKey
459
+ );
460
+ if (!fields.length) continue;
461
+ parts.push(`... on ${blockTypeName(entryName, key, blockName)} { ${fields.join(" ")} }`);
462
+ }
463
+ return parts.join(" ");
464
+ }
465
+ function entrySelection(config, name, entry, withRelations) {
466
+ const parts = ["id"];
467
+ for (const [key, field] of Object.entries(entry.fields)) {
468
+ if (isPrivateField(field)) continue;
469
+ if (field.type === "relation") {
470
+ const target = config[field.to];
471
+ if (!withRelations || !target) continue;
472
+ parts.push(`${key} { ${entrySelection(config, field.to, target, false)} }`);
473
+ continue;
474
+ }
475
+ if (field.type === "media") {
476
+ parts.push(`${key} { ${MEDIA_SELECTION} }`);
477
+ continue;
478
+ }
479
+ if (field.type === "blocks") {
480
+ parts.push(`${key} { ${blocksSelection(name, key, field)} }`);
481
+ continue;
482
+ }
483
+ parts.push(key);
484
+ }
485
+ if (entry.kind === "collection") parts.push("createdAt");
486
+ parts.push("updatedAt");
487
+ return parts.join(" ");
488
+ }
489
+ function singleQuery(config, name, entry) {
490
+ const selection = entrySelection(config, name, entry, true);
491
+ return `query CmsSingle($locale: String) { ${name}(locale: $locale) { ${selection} } }`;
492
+ }
493
+ function collectionQuery(config, name, entry) {
494
+ const selection = entrySelection(config, name, entry, true);
495
+ const gqlType = typeName(name);
496
+ const args = [
497
+ "$locale: String",
498
+ `$filters: ${gqlType}Filters`,
499
+ `$sort: [${gqlType}Sort!]`,
500
+ "$limit: Int",
501
+ "$offset: Int"
502
+ ].join(", ");
503
+ return `query CmsCollection(${args}) { ${name}(locale: $locale, filters: $filters, sort: $sort, limit: $limit, offset: $offset) { ${selection} } }`;
504
+ }
505
+ function renderQueriesFile(config) {
506
+ const singles = [];
507
+ const collections = [];
508
+ for (const [name, entry] of Object.entries(config)) {
509
+ const line = ` ${JSON.stringify(name)}: ${JSON.stringify(
510
+ entry.kind === "single" ? singleQuery(config, name, entry) : collectionQuery(config, name, entry)
511
+ )},`;
512
+ if (entry.kind === "single") singles.push(line);
513
+ else collections.push(line);
514
+ }
515
+ return [
516
+ `export const cmsSingleQueries: Record<string, string> = {`,
517
+ ...singles,
518
+ `}`,
519
+ ``,
520
+ `export const cmsCollectionQueries: Record<string, string> = {`,
521
+ ...collections,
522
+ `}`,
523
+ ``
524
+ ].join("\n");
525
+ }
526
+
527
+ function mediaTsType(field) {
528
+ const types = mediaTypeFilter(field.mediaType);
529
+ return types ? `CmsMedia<${types.map((type) => `'${type}'`).join(" | ")}>` : "CmsMedia";
530
+ }
453
531
  function scalarTsType(field) {
454
532
  switch (field.type) {
455
533
  case "number":
@@ -470,13 +548,13 @@ function fieldTsType(config, entryName, key, field) {
470
548
  if (field.cardinality === "many-to-many") return `${target}[]`;
471
549
  return isRequiredField(field) && !config[field.to]?.drafts ? target : `${target} | null`;
472
550
  }
473
- if (field.type === "media") return "CmsMedia | null";
551
+ if (field.type === "media") return `${mediaTsType(field)} | null`;
474
552
  if (field.type === "select" && field.multiple) {
475
553
  const union = field.options.map((o) => JSON.stringify(o)).join(" | ");
476
554
  return `(${union})[]`;
477
555
  }
478
556
  if (field.type === "blocks") {
479
- const union = blockUnionName(entryName, key);
557
+ const union = blocksFieldTypeName(entryName, key);
480
558
  return isRequiredField(field) ? `${union}[]` : `${union}[] | null`;
481
559
  }
482
560
  if (isTranslatableField(field)) return isRequiredField(field) ? "string" : "string | null";
@@ -492,7 +570,7 @@ function blockTypesTs(entryName, key, field) {
492
570
  const lines = [` __typename?: '${name}'`, ` type: '${blockName}'`];
493
571
  for (const [blockFieldKey, blockField] of Object.entries(block.fields)) {
494
572
  if (blockField.type === "media") {
495
- lines.push(` ${blockFieldKey}: CmsMedia | null`);
573
+ lines.push(` ${blockFieldKey}: ${mediaTsType(blockField)} | null`);
496
574
  continue;
497
575
  }
498
576
  const base = scalarTsType(blockField);
@@ -503,7 +581,7 @@ ${lines.join("\n")}
503
581
  }`);
504
582
  }
505
583
  if (members.length)
506
- defs.push(`export type ${blockUnionName(entryName, key)} = ${members.join(" | ")}`);
584
+ defs.push(`export type ${blocksFieldTypeName(entryName, key)} = ${members.join(" | ")}`);
507
585
  return defs;
508
586
  }
509
587
  function entryTs(config, name, entry) {
@@ -518,12 +596,61 @@ function entryTs(config, name, entry) {
518
596
  ${lines.join("\n")}
519
597
  }`;
520
598
  }
599
+ function relationKeys(entry) {
600
+ return Object.entries(entry.fields).filter(([, field]) => field.type === "relation" && !isPrivateField(field)).map(([key]) => key);
601
+ }
602
+ function quotedKeys(keys) {
603
+ return keys.map((key) => `'${key}'`).join(" | ");
604
+ }
605
+ function shallowTypeTs(config, name) {
606
+ const target = typeName(name);
607
+ const entry = config[name];
608
+ const keys = entry ? relationKeys(entry) : [];
609
+ return keys.length ? `Omit<${target}, ${quotedKeys(keys)}>` : target;
610
+ }
611
+ function autoTypeTs(config, name, entry) {
612
+ const auto = `${typeName(name)}Auto`;
613
+ const keys = relationKeys(entry);
614
+ if (!keys.length) return `export type ${auto} = ${typeName(name)}`;
615
+ const lines = keys.map((key) => {
616
+ const field = entry.fields[key];
617
+ const shallow = shallowTypeTs(config, field.to);
618
+ if (field.cardinality === "many-to-many") return ` ${key}: ${shallow}[]`;
619
+ const nonNull = isRequiredField(field) && !config[field.to]?.drafts;
620
+ return ` ${key}: ${shallow}${nonNull ? "" : " | null"}`;
621
+ });
622
+ return `export type ${auto} = Omit<${typeName(name)}, ${quotedKeys(keys)}> & {
623
+ ${lines.join(
624
+ "\n"
625
+ )}
626
+ }`;
627
+ }
628
+ function entryMapsTs(config) {
629
+ const singles = [];
630
+ const collections = [];
631
+ for (const [name, entry] of Object.entries(config)) {
632
+ const line = ` ${JSON.stringify(name)}: ${typeName(name)}Auto`;
633
+ if (entry.kind === "single") singles.push(line);
634
+ else collections.push(line);
635
+ }
636
+ return [
637
+ `export interface CmsSingleTypes {
638
+ ${singles.join("\n")}
639
+ }`,
640
+ `export interface CmsCollectionTypes {
641
+ ${collections.join("\n")}
642
+ }`,
643
+ `export type CmsSingleName = keyof CmsSingleTypes`,
644
+ `export type CmsCollectionName = keyof CmsCollectionTypes`
645
+ ];
646
+ }
521
647
  function renderTypesFile(config) {
522
648
  const parts = [
523
- `export interface CmsMedia {
649
+ `export type CmsMediaType = 'image' | 'video' | 'file'`,
650
+ `export interface CmsMedia<T extends CmsMediaType = CmsMediaType> {
524
651
  key: string
525
652
  url: string | null
526
- type: 'image' | 'video' | 'file'
653
+ type: T
527
654
  alt: string | null
528
655
  folder: string | null
529
656
  mime: string | null
@@ -539,6 +666,8 @@ function renderTypesFile(config) {
539
666
  }
540
667
  parts.push(entryTs(config, name, entry));
541
668
  }
669
+ for (const [name, entry] of Object.entries(config)) parts.push(autoTypeTs(config, name, entry));
670
+ parts.push(...entryMapsTs(config));
542
671
  return `${parts.join("\n\n")}
543
672
  `;
544
673
  }
@@ -627,6 +756,104 @@ function resolveModuleOptions(options) {
627
756
  }
628
757
  };
629
758
  }
759
+ const moduleRequire = createRequire(import.meta.url);
760
+ function resolveImport(specifier) {
761
+ try {
762
+ return fileURLToPath(import.meta.resolve(specifier)).replace(/\\/g, "/");
763
+ } catch {
764
+ return moduleRequire.resolve(specifier).replace(/\\/g, "/");
765
+ }
766
+ }
767
+ async function loadCmsConfig(nuxt, resolver, configPathOption, i18n, logger) {
768
+ const configPath = await resolvePath(configPathOption, { cwd: nuxt.options.rootDir });
769
+ nuxt.options.alias["#nuxt-cms"] = resolver.resolve("./runtime/shared/index");
770
+ nuxt.options.watch.push(configPath);
771
+ let cmsConfig = {};
772
+ if (existsSync(configPath)) {
773
+ nuxt.options.alias["#cms-config"] = configPath;
774
+ const jiti = createJiti(import.meta.url, {
775
+ moduleCache: false,
776
+ alias: { "#nuxt-cms": resolver.resolve("./runtime/shared/index") }
777
+ });
778
+ cmsConfig = await jiti.import(configPath, { default: true });
779
+ } else {
780
+ logger.warn(
781
+ `[nuxt-cms] Config file not found: ${configPath}. Using an empty registry \u2014 create a ${configPathOption}.ts with defineCmsConfig().`
782
+ );
783
+ nuxt.options.alias["#cms-config"] = resolver.resolve("./runtime/shared/empty-config");
784
+ }
785
+ const configErrors = validateConfig(cmsConfig, i18n);
786
+ if (configErrors.length) {
787
+ for (const error of configErrors) logger.error(error);
788
+ throw new Error(
789
+ `[nuxt-cms] Invalid cms config (${configErrors.length} error${configErrors.length > 1 ? "s" : ""})`
790
+ );
791
+ }
792
+ return cmsConfig;
793
+ }
794
+ function addCmsTypeTemplates(nuxt, cmsConfig) {
795
+ addTemplate({
796
+ filename: "cms/schema.graphql",
797
+ write: true,
798
+ getContents: () => renderGraphqlSdl(cmsConfig)
799
+ });
800
+ const typesTemplate = addTemplate({
801
+ filename: "cms/types.ts",
802
+ write: true,
803
+ getContents: () => renderTypesFile(cmsConfig)
804
+ });
805
+ nuxt.options.alias["#cms-types"] = typesTemplate.dst;
806
+ const queriesTemplate = addTemplate({
807
+ filename: "cms/queries.ts",
808
+ write: true,
809
+ getContents: () => renderQueriesFile(cmsConfig)
810
+ });
811
+ nuxt.options.alias["#cms-queries"] = queriesTemplate.dst;
812
+ addTemplate({
813
+ filename: "cms/graphql-env.d.ts",
814
+ write: true,
815
+ getContents: () => {
816
+ const introspection = minifyIntrospection(
817
+ introspectionFromSchema(buildSchema(renderGraphqlSdl(cmsConfig)))
818
+ );
819
+ return outputIntrospectionFile(introspection, {
820
+ fileType: ".d.ts",
821
+ shouldPreprocess: true
822
+ }).split("import * as gqlTada from 'gql.tada';")[0];
823
+ }
824
+ });
825
+ const gqlTadaTypesPath = resolveImport("gql.tada").replace(/\.[mc]?js$/, "");
826
+ const graphqlTemplate = addTemplate({
827
+ filename: "cms/graphql.ts",
828
+ write: true,
829
+ getContents: () => [
830
+ `import type { initGraphQLTada, ResultOf, VariablesOf } from '${gqlTadaTypesPath}'`,
831
+ `import type { introspection } from './graphql-env'`,
832
+ ``,
833
+ `export type CmsGraphql = initGraphQLTada<{`,
834
+ ` introspection: introspection`,
835
+ ` scalars: {`,
836
+ ` JSON: unknown`,
837
+ ` }`,
838
+ `}>`,
839
+ ``,
840
+ `declare const graphql: CmsGraphql`,
841
+ ``,
842
+ `// @ts-ignore: gql.tada's cache overload rejects this instantiation, but the parse overload still resolves`,
843
+ `export type CmsDocument<Query extends string> = ReturnType<typeof graphql<Query, []>>`,
844
+ ``,
845
+ `export type CmsResult<Query extends string> = string extends Query`,
846
+ ` ? Record<string, unknown>`,
847
+ ` : ResultOf<CmsDocument<Query>>`,
848
+ ``,
849
+ `export type CmsVariables<Query extends string> = string extends Query`,
850
+ ` ? Record<string, unknown>`,
851
+ ` : VariablesOf<CmsDocument<Query>>`,
852
+ ``
853
+ ].join("\n")
854
+ });
855
+ nuxt.options.alias["#cms-graphql"] = graphqlTemplate.dst;
856
+ }
630
857
  const module$1 = defineNuxtModule({
631
858
  meta: {
632
859
  name: "@xleddyl/nuxt-cms",
@@ -671,11 +898,18 @@ const module$1 = defineNuxtModule({
671
898
  const logger = useLogger("nuxt-cms");
672
899
  const resolved = resolveModuleOptions(options);
673
900
  if (!resolveCmsEnabled(options.enabled, process.env[CMS_ENABLED_ENV])) {
674
- const stub = resolver.resolve("./runtime/app/composables/cms-query-disabled");
901
+ const queryStub = resolver.resolve("./runtime/app/composables/cms-query-disabled");
902
+ const entryStub = resolver.resolve("./runtime/app/composables/cms-entry-disabled");
675
903
  addImports([
676
- { name: "useCms", from: stub },
677
- { name: "$cmsQuery", from: stub }
904
+ { name: "useCms", from: queryStub },
905
+ { name: "$cmsQuery", from: queryStub },
906
+ { name: "useCmsSingle", from: entryStub },
907
+ { name: "useCmsCollection", from: entryStub }
678
908
  ]);
909
+ addCmsTypeTemplates(
910
+ nuxt,
911
+ await loadCmsConfig(nuxt, resolver, resolved.configPath, resolved.i18n, logger)
912
+ );
679
913
  nuxt.options.runtimeConfig.public.cms = {
680
914
  mediaBaseUrl: resolved.media.publicBaseUrl,
681
915
  mediaStorage: resolved.media.storage,
@@ -683,7 +917,7 @@ const module$1 = defineNuxtModule({
683
917
  i18n: resolved.i18n
684
918
  };
685
919
  logger.info(
686
- "[nuxt-cms] disabled: registering no-op useCms/$cmsQuery stubs, skipping admin, server and database setup"
920
+ "[nuxt-cms] disabled: registering no-op query composables and generated types, skipping admin, server and database setup"
687
921
  );
688
922
  return;
689
923
  }
@@ -708,38 +942,13 @@ const module$1 = defineNuxtModule({
708
942
  "[nuxt-cms] media.storage is 's3' with credentials configured but no publicBaseUrl (media.publicBaseUrl or NUXT_PUBLIC_CMS_MEDIA_BASE_URL); uploaded media URLs will be null."
709
943
  );
710
944
  }
711
- const configPath = await resolvePath(resolved.configPath, { cwd: nuxt.options.rootDir });
712
- nuxt.options.alias["#nuxt-cms"] = resolver.resolve("./runtime/shared/index");
713
- nuxt.options.watch.push(configPath);
714
- let cmsConfig = {};
715
- if (existsSync(configPath)) {
716
- nuxt.options.alias["#cms-config"] = configPath;
717
- const jiti = createJiti(import.meta.url, {
718
- moduleCache: false,
719
- alias: { "#nuxt-cms": resolver.resolve("./runtime/shared/index") }
720
- });
721
- cmsConfig = await jiti.import(configPath, { default: true });
722
- } else {
723
- logger.warn(
724
- `[nuxt-cms] Config file not found: ${configPath}. Using an empty registry \u2014 create a ${resolved.configPath}.ts with defineCmsConfig().`
725
- );
726
- nuxt.options.alias["#cms-config"] = resolver.resolve("./runtime/shared/empty-config");
727
- }
728
- const configErrors = validateConfig(cmsConfig, resolved.i18n);
729
- if (configErrors.length) {
730
- for (const error of configErrors) logger.error(error);
731
- throw new Error(
732
- `[nuxt-cms] Invalid cms config (${configErrors.length} error${configErrors.length > 1 ? "s" : ""})`
733
- );
734
- }
735
- const moduleRequire = createRequire(import.meta.url);
736
- const resolveImport = (specifier) => {
737
- try {
738
- return fileURLToPath(import.meta.resolve(specifier)).replace(/\\/g, "/");
739
- } catch {
740
- return moduleRequire.resolve(specifier).replace(/\\/g, "/");
741
- }
742
- };
945
+ const cmsConfig = await loadCmsConfig(
946
+ nuxt,
947
+ resolver,
948
+ resolved.configPath,
949
+ resolved.i18n,
950
+ logger
951
+ );
743
952
  const schemaTemplate = addTemplate({
744
953
  filename: "cms/schema.ts",
745
954
  write: true,
@@ -750,64 +959,14 @@ const module$1 = defineNuxtModule({
750
959
  )
751
960
  });
752
961
  nuxt.options.alias["#cms-tables"] = schemaTemplate.dst;
753
- addTemplate({
754
- filename: "cms/schema.graphql",
755
- write: true,
756
- getContents: () => renderGraphqlSdl(cmsConfig)
757
- });
758
- const typesTemplate = addTemplate({
759
- filename: "cms/types.ts",
760
- write: true,
761
- getContents: () => renderTypesFile(cmsConfig)
762
- });
763
- nuxt.options.alias["#cms-types"] = typesTemplate.dst;
764
- addTemplate({
765
- filename: "cms/graphql-env.d.ts",
766
- write: true,
767
- getContents: () => {
768
- const introspection = minifyIntrospection(
769
- introspectionFromSchema(buildSchema(renderGraphqlSdl(cmsConfig)))
770
- );
771
- return outputIntrospectionFile(introspection, {
772
- fileType: ".d.ts",
773
- shouldPreprocess: true
774
- }).split("import * as gqlTada from 'gql.tada';")[0];
775
- }
776
- });
777
- const gqlTadaTypesPath = resolveImport("gql.tada").replace(/\.[mc]?js$/, "");
778
- const graphqlTemplate = addTemplate({
779
- filename: "cms/graphql.ts",
780
- write: true,
781
- getContents: () => [
782
- `import type { initGraphQLTada, ResultOf, VariablesOf } from '${gqlTadaTypesPath}'`,
783
- `import type { introspection } from './graphql-env'`,
784
- ``,
785
- `export type CmsGraphql = initGraphQLTada<{`,
786
- ` introspection: introspection`,
787
- ` scalars: {`,
788
- ` JSON: unknown`,
789
- ` }`,
790
- `}>`,
791
- ``,
792
- `declare const graphql: CmsGraphql`,
793
- ``,
794
- `// @ts-ignore: gql.tada's cache overload rejects this instantiation, but the parse overload still resolves`,
795
- `export type CmsDocument<Query extends string> = ReturnType<typeof graphql<Query, []>>`,
796
- ``,
797
- `export type CmsResult<Query extends string> = string extends Query`,
798
- ` ? Record<string, unknown>`,
799
- ` : ResultOf<CmsDocument<Query>>`,
800
- ``,
801
- `export type CmsVariables<Query extends string> = string extends Query`,
802
- ` ? Record<string, unknown>`,
803
- ` : VariablesOf<CmsDocument<Query>>`,
804
- ``
805
- ].join("\n")
806
- });
807
- nuxt.options.alias["#cms-graphql"] = graphqlTemplate.dst;
962
+ addCmsTypeTemplates(nuxt, cmsConfig);
963
+ const queryComposables = resolver.resolve("./runtime/app/composables/cms-query");
964
+ const entryComposables = resolver.resolve("./runtime/app/composables/cms-entry");
808
965
  addImports([
809
- { name: "useCms", from: resolver.resolve("./runtime/app/composables/cms-query") },
810
- { name: "$cmsQuery", from: resolver.resolve("./runtime/app/composables/cms-query") }
966
+ { name: "useCms", from: queryComposables },
967
+ { name: "$cmsQuery", from: queryComposables },
968
+ { name: "useCmsSingle", from: entryComposables },
969
+ { name: "useCmsCollection", from: entryComposables }
811
970
  ]);
812
971
  const {
813
972
  driver,
@@ -0,0 +1,19 @@
1
+ import type { AsyncData } from 'nuxt/app';
2
+ import type { CmsCollectionName, CmsCollectionTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
3
+ import type { CmsAsyncDataOptions } from './cms-query-disabled.js';
4
+ export interface CmsSortInput<Entry> {
5
+ field: Extract<keyof Entry, string>;
6
+ direction?: 'asc' | 'desc';
7
+ }
8
+ export interface CmsSingleOptions<ResT, DefaultT> extends CmsAsyncDataOptions<ResT, DefaultT> {
9
+ locale?: string;
10
+ }
11
+ export interface CmsCollectionOptions<ResT, DefaultT, Entry> extends CmsAsyncDataOptions<ResT, DefaultT> {
12
+ locale?: string;
13
+ filters?: Record<string, unknown>;
14
+ sort?: CmsSortInput<Entry>[];
15
+ limit?: number;
16
+ offset?: number;
17
+ }
18
+ 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
+ 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>;
@@ -0,0 +1,16 @@
1
+ import { useAsyncData } from "#imports";
2
+ export function useCmsSingle(name, options = {}) {
3
+ const { locale, key, default: fallback } = options;
4
+ return useAsyncData(
5
+ key ?? `cms-single:${String(name)}:${locale ?? ""}`,
6
+ async () => fallback ? fallback() : null
7
+ );
8
+ }
9
+ export function useCmsCollection(name, options = {}) {
10
+ const { locale, filters, sort, limit, offset, key, default: fallback } = options;
11
+ const variables = { locale, filters, sort, limit, offset };
12
+ return useAsyncData(
13
+ key ?? `cms-collection:${String(name)}:${JSON.stringify(variables)}`,
14
+ async () => fallback ? fallback() : []
15
+ );
16
+ }
@@ -0,0 +1,19 @@
1
+ import type { AsyncData } from 'nuxt/app';
2
+ import type { CmsCollectionName, CmsCollectionTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
3
+ import type { CmsAsyncDataOptions } from './cms-query.js';
4
+ export interface CmsSortInput<Entry> {
5
+ field: Extract<keyof Entry, string>;
6
+ direction?: 'asc' | 'desc';
7
+ }
8
+ export interface CmsSingleOptions<ResT, DefaultT> extends CmsAsyncDataOptions<ResT, DefaultT> {
9
+ locale?: string;
10
+ }
11
+ export interface CmsCollectionOptions<ResT, DefaultT, Entry> extends CmsAsyncDataOptions<ResT, DefaultT> {
12
+ locale?: string;
13
+ filters?: Record<string, unknown>;
14
+ sort?: CmsSortInput<Entry>[];
15
+ limit?: number;
16
+ offset?: number;
17
+ }
18
+ 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
+ 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>;
@@ -0,0 +1,50 @@
1
+ import { cmsCollectionQueries, cmsSingleQueries } from "#cms-queries";
2
+ import { useAsyncData } from "#imports";
3
+ import { $cmsQuery } from "./cms-query.js";
4
+ function unknownEntry(name) {
5
+ throw new Error(`[nuxt-cms] no generated query for "${name}"; check cms.config.ts`);
6
+ }
7
+ export function useCmsSingle(name, options = {}) {
8
+ const { locale, key, default: fallback, ...asyncDataOptions } = options;
9
+ const entryName = String(name);
10
+ const query = cmsSingleQueries[entryName];
11
+ return useAsyncData(
12
+ key ?? `cms-single:${entryName}:${locale ?? ""}`,
13
+ async () => {
14
+ if (!query) unknownEntry(entryName);
15
+ const result = await $cmsQuery(query, { locale });
16
+ return result?.[entryName] ?? null;
17
+ },
18
+ {
19
+ default: fallback ?? (() => null),
20
+ ...asyncDataOptions
21
+ }
22
+ );
23
+ }
24
+ export function useCmsCollection(name, options = {}) {
25
+ const {
26
+ locale,
27
+ filters,
28
+ sort,
29
+ limit,
30
+ offset,
31
+ key,
32
+ default: fallback,
33
+ ...asyncDataOptions
34
+ } = options;
35
+ const entryName = String(name);
36
+ const query = cmsCollectionQueries[entryName];
37
+ const variables = { locale, filters, sort, limit, offset };
38
+ return useAsyncData(
39
+ key ?? `cms-collection:${entryName}:${JSON.stringify(variables)}`,
40
+ async () => {
41
+ if (!query) unknownEntry(entryName);
42
+ const result = await $cmsQuery(query, variables);
43
+ return result?.[entryName] ?? [];
44
+ },
45
+ {
46
+ default: fallback ?? (() => []),
47
+ ...asyncDataOptions
48
+ }
49
+ );
50
+ }
@@ -1,6 +1,11 @@
1
- import type { AsyncData } from 'nuxt/app';
1
+ import type { AsyncData, AsyncDataOptions } from 'nuxt/app';
2
2
  type CmsDisabledResult = Record<string, any>;
3
3
  type CmsDisabledVariables = Record<string, any>;
4
+ export interface CmsAsyncDataOptions<ResT, DefaultT> extends Pick<AsyncDataOptions<ResT>, 'server' | 'lazy' | 'immediate' | 'deep' | 'dedupe' | 'watch'> {
5
+ key?: string;
6
+ default?: () => DefaultT;
7
+ }
8
+ export declare function cmsQueryKey(query: string, variables?: unknown): string;
4
9
  export declare function $cmsQuery<const Q extends string>(query: Q, variables?: CmsDisabledVariables): Promise<CmsDisabledResult>;
5
- export declare function useCms<const Q extends string>(query: Q, variables?: CmsDisabledVariables): AsyncData<CmsDisabledResult | undefined, Error | undefined>;
10
+ export declare function useCms<const Q extends string, DefaultT = undefined>(query: Q, variables?: CmsDisabledVariables, options?: CmsAsyncDataOptions<CmsDisabledResult, DefaultT>): AsyncData<CmsDisabledResult | DefaultT | undefined, Error | undefined>;
6
11
  export {};
@@ -1,10 +1,14 @@
1
1
  import { useAsyncData } from "#imports";
2
+ export function cmsQueryKey(query, variables) {
3
+ return `cms-gql:${query}:${JSON.stringify(variables ?? {})}`;
4
+ }
2
5
  export async function $cmsQuery(query, variables) {
3
6
  return {};
4
7
  }
5
- export function useCms(query, variables) {
8
+ export function useCms(query, variables, options = {}) {
9
+ const { key, default: defaultValue } = options;
6
10
  return useAsyncData(
7
- `cms-gql:${query}:${JSON.stringify(variables ?? {})}`,
8
- async () => null
11
+ key ?? cmsQueryKey(query, variables),
12
+ async () => defaultValue ? defaultValue() : null
9
13
  );
10
14
  }
@@ -1,4 +1,9 @@
1
1
  import type { CmsResult, CmsVariables } from '#cms-graphql';
2
- import type { AsyncData } from 'nuxt/app';
2
+ import type { AsyncData, AsyncDataOptions } from 'nuxt/app';
3
+ export interface CmsAsyncDataOptions<ResT, DefaultT> extends Pick<AsyncDataOptions<ResT>, 'server' | 'lazy' | 'immediate' | 'deep' | 'dedupe' | 'watch'> {
4
+ key?: string;
5
+ default?: () => DefaultT;
6
+ }
7
+ export declare function cmsQueryKey(query: string, variables?: unknown): string;
3
8
  export declare function $cmsQuery<const Q extends string>(query: Q, variables?: CmsVariables<Q>): Promise<CmsResult<Q>>;
4
- export declare function useCms<const Q extends string>(query: Q, variables?: CmsVariables<Q>): AsyncData<CmsResult<Q> | undefined, Error | undefined>;
9
+ export declare function useCms<const Q extends string, DefaultT = undefined>(query: Q, variables?: CmsVariables<Q>, options?: CmsAsyncDataOptions<CmsResult<Q>, DefaultT>): AsyncData<CmsResult<Q> | DefaultT | undefined, Error | undefined>;
@@ -1,5 +1,8 @@
1
1
  import { useAsyncData } from "#imports";
2
2
  const ENDPOINT = "/api/cms/graphql";
3
+ export function cmsQueryKey(query, variables) {
4
+ return `cms-gql:${query}:${JSON.stringify(variables ?? {})}`;
5
+ }
3
6
  export async function $cmsQuery(query, variables) {
4
7
  const res = await $fetch(ENDPOINT, {
5
8
  method: "POST",
@@ -10,9 +13,11 @@ export async function $cmsQuery(query, variables) {
10
13
  }
11
14
  return res.data;
12
15
  }
13
- export function useCms(query, variables) {
16
+ export function useCms(query, variables, options = {}) {
17
+ const { key, ...asyncDataOptions } = options;
14
18
  return useAsyncData(
15
- `cms-gql:${query}:${JSON.stringify(variables ?? {})}`,
16
- () => $cmsQuery(query, variables)
19
+ key ?? cmsQueryKey(query, variables),
20
+ () => $cmsQuery(query, variables),
21
+ asyncDataOptions
17
22
  );
18
23
  }
@@ -30,7 +30,12 @@ import {
30
30
  pickTranslatedMedia,
31
31
  translatableFieldKeys
32
32
  } from "../../shared/index.js";
33
- import { blockTypeName, blockUnionName, renderGraphqlSdl, typeName } from "../../shared/graphql-sdl.js";
33
+ import {
34
+ blockTypeName,
35
+ blocksFieldTypeName,
36
+ renderGraphqlSdl,
37
+ typeName
38
+ } from "../../shared/graphql-sdl.js";
34
39
  import { useMediaIndex } from "./media-index.js";
35
40
  import { getContentI18n, resolveTable, tableColumns } from "./registry.js";
36
41
  const MAX_LIMIT = 100;
@@ -285,7 +290,7 @@ function entryResolvers(config, name, entry) {
285
290
  }
286
291
  function blockResolvers(name, key, field) {
287
292
  const resolvers = {};
288
- resolvers[blockUnionName(name, key)] = {
293
+ resolvers[blocksFieldTypeName(name, key)] = {
289
294
  __resolveType: (value) => blockTypeName(name, key, String(value.type))
290
295
  };
291
296
  for (const [blockName, block] of Object.entries(field.blocks ?? {})) {
@@ -1,5 +1,4 @@
1
1
  import type { CmsConfig } from './index.js';
2
- export declare function typeName(name: string): string;
3
- export declare function blockUnionName(entryName: string, fieldKey: string): string;
4
- export declare function blockTypeName(entryName: string, fieldKey: string, blockName: string): string;
2
+ import { blockTypeName, blocksFieldTypeName, typeName } from './index.js';
3
+ export { blockTypeName, blocksFieldTypeName, typeName };
5
4
  export declare function renderGraphqlSdl(config: CmsConfig): string;
@@ -1,13 +1,12 @@
1
- import { isPrivateField, isRequiredField, isTranslatableField } from "./index.js";
2
- export function typeName(name) {
3
- return name.replace(/(?:^|_)([a-z0-9])/gi, (_, c) => c.toUpperCase());
4
- }
5
- export function blockUnionName(entryName, fieldKey) {
6
- return `${typeName(entryName)}${typeName(fieldKey)}Block`;
7
- }
8
- export function blockTypeName(entryName, fieldKey, blockName) {
9
- return `${typeName(entryName)}${typeName(fieldKey)}${typeName(blockName)}`;
10
- }
1
+ import {
2
+ blockTypeName,
3
+ blocksFieldTypeName,
4
+ isPrivateField,
5
+ isRequiredField,
6
+ isTranslatableField,
7
+ typeName
8
+ } from "./index.js";
9
+ export { blockTypeName, blocksFieldTypeName, typeName };
11
10
  function scalarFor(field) {
12
11
  switch (field.type) {
13
12
  case "number":
@@ -58,7 +57,7 @@ function fieldSdl(config, entryName, key, field) {
58
57
  if (field.type === "media") return ` ${key}: CmsMedia`;
59
58
  if (field.type === "select" && field.multiple) return ` ${key}: [String!]!`;
60
59
  if (field.type === "blocks")
61
- return ` ${key}: [${blockUnionName(entryName, key)}!]${isRequiredField(field) ? "!" : ""}`;
60
+ return ` ${key}: [${blocksFieldTypeName(entryName, key)}!]${isRequiredField(field) ? "!" : ""}`;
62
61
  return ` ${key}: ${scalarFor(field)}${isRequiredField(field) ? "!" : ""}`;
63
62
  }
64
63
  function entrySdl(config, name, entry) {
@@ -73,25 +72,44 @@ function entrySdl(config, name, entry) {
73
72
  ${lines.join("\n")}
74
73
  }`;
75
74
  }
75
+ function blockFieldSdl(field) {
76
+ if (field.type === "media") return { base: "CmsMedia", nonNull: false };
77
+ return { base: scalarFor(field), nonNull: !!field.required };
78
+ }
79
+ function sharedBlockFieldsSdl(field) {
80
+ const blocks = Object.values(field.blocks ?? {});
81
+ const first = blocks[0];
82
+ if (!first) return [];
83
+ const lines = [];
84
+ for (const key of Object.keys(first.fields)) {
85
+ const rendered = blocks.map((block) => block.fields[key]);
86
+ if (rendered.some((blockField) => !blockField)) continue;
87
+ const types = rendered.map((blockField) => blockFieldSdl(blockField));
88
+ if (types.some((type) => type.base !== types[0].base)) continue;
89
+ const nonNull = types.every((type) => type.nonNull);
90
+ lines.push(` ${key}: ${types[0].base}${nonNull ? "!" : ""}`);
91
+ }
92
+ return lines;
93
+ }
76
94
  function blocksSdl(name, key, field) {
77
- const defs = [];
78
- const members = [];
79
- for (const [blockName, block] of Object.entries(field.blocks ?? {})) {
80
- const gqlType = blockTypeName(name, key, blockName);
81
- members.push(gqlType);
95
+ const blocks = Object.entries(field.blocks ?? {});
96
+ if (!blocks.length) return [];
97
+ const interfaceName = blocksFieldTypeName(name, key);
98
+ const shared = [" type: String!", ...sharedBlockFieldsSdl(field)];
99
+ const defs = [`interface ${interfaceName} {
100
+ ${shared.join("\n")}
101
+ }`];
102
+ for (const [blockName, block] of blocks) {
82
103
  const lines = [" type: String!"];
83
104
  for (const [blockFieldKey, blockField] of Object.entries(block.fields)) {
84
- if (blockField.type === "media") {
85
- lines.push(` ${blockFieldKey}: CmsMedia`);
86
- continue;
87
- }
88
- lines.push(` ${blockFieldKey}: ${scalarFor(blockField)}${blockField.required ? "!" : ""}`);
105
+ const type = blockFieldSdl(blockField);
106
+ lines.push(` ${blockFieldKey}: ${type.base}${type.nonNull ? "!" : ""}`);
89
107
  }
90
- defs.push(`type ${gqlType} {
108
+ const gqlType = blockTypeName(name, key, blockName);
109
+ defs.push(`type ${gqlType} implements ${interfaceName} {
91
110
  ${lines.join("\n")}
92
111
  }`);
93
112
  }
94
- if (members.length) defs.push(`union ${blockUnionName(name, key)} = ${members.join(" | ")}`);
95
113
  return defs;
96
114
  }
97
115
  function filterSdl(name, entry) {
@@ -119,7 +137,8 @@ const COMMON_SDL = [
119
137
  "input FloatFilter {\n eq: Float\n neq: Float\n gt: Float\n gte: Float\n lt: Float\n lte: Float\n in: [Float!]\n isNull: Boolean\n}",
120
138
  "input StringFilter {\n eq: String\n neq: String\n gt: String\n gte: String\n lt: String\n lte: String\n like: String\n in: [String!]\n isNull: Boolean\n}",
121
139
  "input BooleanFilter {\n eq: Boolean\n neq: Boolean\n isNull: Boolean\n}",
122
- "type CmsMedia {\n key: String!\n url: String\n type: String!\n alt: String\n folder: String\n mime: String\n size: Int\n width: Int\n height: Int\n}"
140
+ "enum CmsMediaType {\n image\n video\n file\n}",
141
+ "type CmsMedia {\n key: String!\n url: String\n type: CmsMediaType!\n alt: String\n folder: String\n mime: String\n size: Int\n width: Int\n height: Int\n}"
123
142
  ];
124
143
  export function renderGraphqlSdl(config) {
125
144
  const queryLines = [];
@@ -98,6 +98,9 @@ export interface CmsEntry {
98
98
  table?: CmsTable;
99
99
  }
100
100
  export type CmsConfig = Record<string, CmsEntry>;
101
+ export declare function typeName(name: string): string;
102
+ export declare function blocksFieldTypeName(entryName: string, fieldKey: string): string;
103
+ export declare function blockTypeName(entryName: string, fieldKey: string, blockName: string): string;
101
104
  interface FieldInputBase {
102
105
  label: string;
103
106
  required?: boolean;
@@ -184,6 +184,15 @@ 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 function typeName(name) {
188
+ return name.replace(/(?:^|_)([a-z0-9])/gi, (_, c) => c.toUpperCase());
189
+ }
190
+ export function blocksFieldTypeName(entryName, fieldKey) {
191
+ return `${typeName(entryName)}${typeName(fieldKey)}Block`;
192
+ }
193
+ export function blockTypeName(entryName, fieldKey, blockName) {
194
+ return `${typeName(entryName)}${typeName(fieldKey)}${typeName(blockName)}`;
195
+ }
187
196
  export function defineCmsConfig(config) {
188
197
  return config;
189
198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
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)",