@xleddyl/nuxt-cms 0.1.31 → 0.1.33

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
@@ -5,7 +5,7 @@ nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight
5
5
  - **Zero extra infrastructure**: the CMS runs inside your app's Nitro server; you deploy one thing.
6
6
  - **Content types in code**: a `cms.config.ts` with `defineCmsConfig()` declares collections, single documents, relations, blocks and translatable fields; database schema, migrations and TypeScript types are generated from it.
7
7
  - **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, or a local mode backed directly by your `public/` folder), single-admin auth from env credentials.
8
- - **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination.
8
+ - **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination; fields marked `private` stay out of it.
9
9
  - **SQLite, Postgres or libSQL/Turso**: a local file database by default, one config line to switch (including remote SQLite over the network).
10
10
 
11
11
  ## Installation
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.31",
4
+ "version": "0.1.33",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -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, blockUnionName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
13
- import { isMultiSelect, isTranslatableField, isTranslatableMediaField, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
13
+ import { isMultiSelect, isTranslatableField, isTranslatableMediaField, isPrivateField, 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
 
16
16
  async function collectMediaManifest(root) {
@@ -44,6 +44,7 @@ const IDENTIFIER = /^[a-z_]\w*$/i;
44
44
  const RESERVED_ENTRY_KEYS = ["admin", "auth", "login", "media", "graphql", "cms_media"];
45
45
  const RESERVED_COLUMNS = ["id", "status", "created_at", "updated_at"];
46
46
  const TITLE_FIELD_TYPES = ["text", "slug", "email", "number", "date", "select"];
47
+ const TRANSLATABLE_FIELD_TYPES = ["text", "richtext", "media"];
47
48
  const RESERVED_TYPE_NAMES = [
48
49
  "Query",
49
50
  "Mutation",
@@ -124,7 +125,7 @@ function validateConfig(config, i18n) {
124
125
  columnNames.add(column);
125
126
  }
126
127
  if (field.translatable) {
127
- if (field.type !== "text" && field.type !== "richtext" && field.type !== "media")
128
+ if (!TRANSLATABLE_FIELD_TYPES.includes(field.type))
128
129
  errors.push(
129
130
  `${fat}: translatable is only supported on text, richtext and media fields`
130
131
  );
@@ -167,8 +168,18 @@ function validateConfig(config, i18n) {
167
168
  `${bfat}: ${blockField.type} fields are not supported inside blocks`
168
169
  );
169
170
  }
170
- if (blockField.translatable)
171
- errors.push(`${bfat}: translatable fields are not supported inside blocks`);
171
+ if (blockField.translatable) {
172
+ if (!TRANSLATABLE_FIELD_TYPES.includes(blockField.type))
173
+ errors.push(
174
+ `${bfat}: translatable is only supported on text, richtext and media fields`
175
+ );
176
+ if (!locales.length)
177
+ errors.push(
178
+ `${bfat}: translatable requires cms.i18n.locales in nuxt.config`
179
+ );
180
+ }
181
+ if (blockField.private)
182
+ errors.push(`${bfat}: private fields are not supported inside blocks`);
172
183
  if (blockField.type === "select" && !blockField.options?.length) {
173
184
  errors.push(`${bfat}: select requires a non-empty options array`);
174
185
  }
@@ -416,6 +427,7 @@ ${lines.join("\n")}
416
427
  function entryTs(config, name, entry) {
417
428
  const lines = [" id: string"];
418
429
  for (const [key, field] of Object.entries(entry.fields)) {
430
+ if (isPrivateField(field)) continue;
419
431
  lines.push(` ${key}: ${fieldTsType(config, name, key, field)}`);
420
432
  }
421
433
  if (entry.kind === "collection") lines.push(" createdAt: string");
@@ -440,7 +452,8 @@ function renderTypesFile(config) {
440
452
  ];
441
453
  for (const [name, entry] of Object.entries(config)) {
442
454
  for (const [key, field] of Object.entries(entry.fields)) {
443
- if (field.type === "blocks") parts.push(...blockTypesTs(name, key, field));
455
+ if (field.type === "blocks" && !isPrivateField(field))
456
+ parts.push(...blockTypesTs(name, key, field));
444
457
  }
445
458
  parts.push(entryTs(config, name, entry));
446
459
  }
@@ -1,6 +1,7 @@
1
1
  import type { FieldConfig } from '#nuxt-cms';
2
2
  type __VLS_Props = {
3
3
  field: FieldConfig;
4
+ locale?: string;
4
5
  };
5
6
  type __VLS_ModelProps = {
6
7
  modelValue: Record<string, unknown>[] | null;
@@ -63,6 +63,7 @@
63
63
  <CmsFieldInput
64
64
  :model-value="item[blockKey]"
65
65
  :field="blockField"
66
+ :locale="locale"
66
67
  @update:model-value="(value) => updateField(index, blockKey, value)"
67
68
  />
68
69
  </CmsFormField>
@@ -77,7 +78,8 @@
77
78
  <script setup>
78
79
  import { computed, ref } from "#imports";
79
80
  const props = defineProps({
80
- field: { type: Object, required: true }
81
+ field: { type: Object, required: true },
82
+ locale: { type: String, required: false }
81
83
  });
82
84
  const model = defineModel({ type: [Array, null], ...{ required: true } });
83
85
  const blocks = computed(() => props.field.blocks ?? {});
@@ -1,6 +1,7 @@
1
1
  import type { FieldConfig } from '#nuxt-cms';
2
2
  type __VLS_Props = {
3
3
  field: FieldConfig;
4
+ locale?: string;
4
5
  };
5
6
  type __VLS_ModelProps = {
6
7
  modelValue: Record<string, unknown>[] | null;
@@ -13,10 +13,12 @@
13
13
  :name="key"
14
14
  :required="field.required"
15
15
  >
16
- <template v-if="isTranslatableField(field) && i18n.locales.length > 1" #label-actions>
16
+ <template v-if="hasLocaleSwitch(field) && i18n.locales.length > 1" #label-actions>
17
17
  <CmsLocaleSwitch
18
18
  :model-value="localeFor(key)"
19
- :value="state[key]"
19
+ :value="
20
+ isTranslatableField(field) ? state[key] : null
21
+ "
20
22
  @update:model-value="(locale) => setLocale(key, locale)"
21
23
  />
22
24
  </template>
@@ -41,7 +43,7 @@
41
43
  </template>
42
44
 
43
45
  <script setup>
44
- import { isTranslatableField } from "#nuxt-cms";
46
+ import { hasTranslatableBlockFields, isTranslatableField } from "#nuxt-cms";
45
47
  import { computed, ref } from "#imports";
46
48
  import { buildEntrySchema } from "../../../shared/validation";
47
49
  import { useCmsRuntime } from "../../composables/cms-runtime";
@@ -56,6 +58,9 @@ const state = defineModel({ type: Object, ...{ required: true } });
56
58
  const emit = defineEmits(["submit", "error"]);
57
59
  const { i18n } = useCmsRuntime();
58
60
  const activeLocale = ref({});
61
+ function hasLocaleSwitch(field) {
62
+ return isTranslatableField(field) || hasTranslatableBlockFields(field);
63
+ }
59
64
  function localeFor(key) {
60
65
  return activeLocale.value[key] ?? i18n.defaultLocale;
61
66
  }
@@ -5,7 +5,12 @@
5
5
  :field="field"
6
6
  :locale="locale"
7
7
  />
8
- <CmsBlocksField v-else-if="field.type === 'blocks'" v-model="blocksValue" :field="field" />
8
+ <CmsBlocksField
9
+ v-else-if="field.type === 'blocks'"
10
+ v-model="blocksValue"
11
+ :field="field"
12
+ :locale="locale"
13
+ />
9
14
  <CmsRichTextField v-else-if="field.type === 'richtext'" v-model="strOrNull" />
10
15
  <CmsTextarea v-else-if="field.type === 'text' && field.textarea" v-model="str" :rows="8" />
11
16
  <CmsInput v-else-if="field.type === 'text'" v-model="str" />
@@ -21,7 +21,10 @@ import * as cmsTables from "#cms-tables";
21
21
  import { useRuntimeConfig } from "#imports";
22
22
  import {
23
23
  decodeTranslatableMedia,
24
+ hasTranslatableBlockFields,
25
+ isPrivateField,
24
26
  isTranslatableMediaField,
27
+ localizeBlocks,
25
28
  mediaPublicUrl,
26
29
  mediaTypeFor,
27
30
  pickTranslatedMedia,
@@ -103,6 +106,10 @@ function localizeRow(entry, row, locale) {
103
106
  const value = row[key];
104
107
  result[key] = value?.[locale] ?? value?.[defaultLocale] ?? null;
105
108
  }
109
+ for (const [key, field] of Object.entries(entry.fields)) {
110
+ if (isPrivateField(field) || !hasTranslatableBlockFields(field)) continue;
111
+ result[key] = localizeBlocks(field, result[key], locale, defaultLocale);
112
+ }
106
113
  return result;
107
114
  }
108
115
  const OPERATORS = {
@@ -257,6 +264,7 @@ function mediaObject(key, row) {
257
264
  function entryResolvers(config, name, entry) {
258
265
  const resolvers = {};
259
266
  for (const [key, field] of Object.entries(entry.fields)) {
267
+ if (isPrivateField(field)) continue;
260
268
  if (field.type === "relation" && field.cardinality === "many-to-many") {
261
269
  resolvers[key] = async (parent, _args, ctx) => {
262
270
  const rows = await loadManyToMany(ctx, name, key, field).load(parent.id);
@@ -301,7 +309,8 @@ export function buildCmsSchema() {
301
309
  const fieldLevel = entryResolvers(config, name, entry);
302
310
  if (Object.keys(fieldLevel).length) typeResolvers[gqlType] = fieldLevel;
303
311
  for (const [key, field] of Object.entries(entry.fields)) {
304
- if (field.type === "blocks") Object.assign(typeResolvers, blockResolvers(name, key, field));
312
+ if (field.type === "blocks" && !isPrivateField(field))
313
+ Object.assign(typeResolvers, blockResolvers(name, key, field));
305
314
  }
306
315
  if (entry.kind === "single") {
307
316
  queryResolvers[name] = async (_, args) => {
@@ -1,4 +1,4 @@
1
- import { isTranslatableField } from "./index.js";
1
+ import { isPrivateField, isTranslatableField } from "./index.js";
2
2
  export function typeName(name) {
3
3
  return name.replace(/(?:^|_)([a-z0-9])/gi, (_, c) => c.toUpperCase());
4
4
  }
@@ -21,7 +21,7 @@ function scalarFor(field) {
21
21
  }
22
22
  }
23
23
  function filterScalarFor(field) {
24
- if (isTranslatableField(field)) return null;
24
+ if (isPrivateField(field) || isTranslatableField(field)) return null;
25
25
  switch (field.type) {
26
26
  case "json":
27
27
  return null;
@@ -64,6 +64,7 @@ function fieldSdl(config, entryName, key, field) {
64
64
  function entrySdl(config, name, entry) {
65
65
  const lines = [" id: ID!"];
66
66
  for (const [key, field] of Object.entries(entry.fields)) {
67
+ if (isPrivateField(field)) continue;
67
68
  lines.push(fieldSdl(config, name, key, field));
68
69
  }
69
70
  if (entry.kind === "collection") lines.push(" createdAt: String!");
@@ -127,7 +128,8 @@ export function renderGraphqlSdl(config) {
127
128
  const gqlType = typeName(name);
128
129
  types.push(entrySdl(config, name, entry));
129
130
  for (const [key, field] of Object.entries(entry.fields)) {
130
- if (field.type === "blocks") types.push(...blocksSdl(name, key, field));
131
+ if (field.type === "blocks" && !isPrivateField(field))
132
+ types.push(...blocksSdl(name, key, field));
131
133
  }
132
134
  if (entry.kind === "single") {
133
135
  queryLines.push(` ${name}(locale: String): ${gqlType}`);
@@ -46,6 +46,7 @@ export interface FieldConfig {
46
46
  label: string;
47
47
  type: FieldType;
48
48
  required?: boolean;
49
+ private?: boolean;
49
50
  textarea?: boolean;
50
51
  integer?: boolean;
51
52
  translatable?: boolean;
@@ -59,9 +60,11 @@ export interface FieldConfig {
59
60
  cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
60
61
  onDelete?: 'set null' | 'cascade' | 'restrict';
61
62
  }
63
+ export declare function isPrivateField(field: FieldConfig): boolean;
62
64
  export declare function isTranslatableField(field: FieldConfig): boolean;
63
65
  export declare function isTranslatableMediaField(field: FieldConfig): boolean;
64
- export declare function decodeTranslatableMedia(value: unknown, defaultLocale: string): Record<string, string> | null;
66
+ export declare function decodeTranslatableValue(value: unknown, defaultLocale: string): Record<string, string> | null;
67
+ export declare const decodeTranslatableMedia: typeof decodeTranslatableValue;
65
68
  export declare function encodeTranslatableMedia(value: unknown): string | null;
66
69
  export declare function pickTranslatedMedia(values: Record<string, string> | null | undefined, locale: string, defaultLocale: string): string | null;
67
70
  export declare function translatableMediaKeys(entry: Pick<CmsEntry, 'fields'>): string[];
@@ -69,6 +72,10 @@ export declare function encodeEntryTranslatableMedia(entry: Pick<CmsEntry, 'fiel
69
72
  export declare function decodeEntryTranslatableMedia<T extends Record<string, unknown>>(entry: Pick<CmsEntry, 'fields'>, rows: T[], defaultLocale: string): T[];
70
73
  export declare function isMultiSelect(field: FieldConfig): boolean;
71
74
  export declare function translatableFieldKeys(entry: CmsEntry): string[];
75
+ export declare function translatableBlockFieldKeys(block: BlockConfig): string[];
76
+ export declare function hasTranslatableBlockFields(field: FieldConfig): boolean;
77
+ export declare function localizeBlock(field: FieldConfig, item: unknown, locale: string, defaultLocale: string): unknown;
78
+ export declare function localizeBlocks(field: FieldConfig, value: unknown, locale: string, defaultLocale: string): unknown;
72
79
  export interface CmsEntry {
73
80
  id: string;
74
81
  label: string;
@@ -82,6 +89,7 @@ export type CmsConfig = Record<string, CmsEntry>;
82
89
  interface FieldInputBase {
83
90
  label: string;
84
91
  required?: boolean;
92
+ private?: boolean;
85
93
  }
86
94
  export interface TextFieldInput extends FieldInputBase {
87
95
  type: 'text';
@@ -129,7 +137,7 @@ export interface RelationFieldInput extends FieldInputBase {
129
137
  cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
130
138
  onDelete?: 'set null' | 'cascade' | 'restrict';
131
139
  }
132
- export type BlockFieldInput = Omit<TextFieldInput, 'translatable'> | Omit<RichtextFieldInput, 'translatable'> | NumberFieldInput | BooleanFieldInput | DateFieldInput | EmailFieldInput | SelectFieldInput | JsonFieldInput | Omit<MediaFieldInput, 'translatable'>;
140
+ export type BlockFieldInput = Omit<TextFieldInput, 'private'> | Omit<RichtextFieldInput, 'private'> | Omit<NumberFieldInput, 'private'> | Omit<BooleanFieldInput, 'private'> | Omit<DateFieldInput, 'private'> | Omit<EmailFieldInput, 'private'> | Omit<SelectFieldInput, 'private'> | Omit<JsonFieldInput, 'private'> | Omit<MediaFieldInput, 'private'>;
133
141
  export interface BlockInput {
134
142
  label: string;
135
143
  fields: Record<string, BlockFieldInput>;
@@ -61,6 +61,9 @@ export function normalizeMediaFolder(value) {
61
61
  const segments = value.split("/").map(slugify).filter(Boolean).slice(0, MEDIA_FOLDER_MAX_DEPTH);
62
62
  return segments.length ? segments.join("/") : null;
63
63
  }
64
+ export function isPrivateField(field) {
65
+ return !!field.private;
66
+ }
64
67
  export function isTranslatableField(field) {
65
68
  return !!field.translatable && (field.type === "text" || field.type === "richtext" || field.type === "media");
66
69
  }
@@ -78,13 +81,14 @@ function parseJsonObject(raw) {
78
81
  }
79
82
  return null;
80
83
  }
81
- export function decodeTranslatableMedia(value, defaultLocale) {
84
+ export function decodeTranslatableValue(value, defaultLocale) {
82
85
  if (value == null) return null;
83
86
  if (typeof value === "object") return value;
84
87
  const raw = String(value).trim();
85
88
  if (!raw) return null;
86
89
  return (raw.startsWith("{") ? parseJsonObject(raw) : null) ?? { [defaultLocale]: raw };
87
90
  }
91
+ export const decodeTranslatableMedia = decodeTranslatableValue;
88
92
  export function encodeTranslatableMedia(value) {
89
93
  if (value == null) return null;
90
94
  if (typeof value === "string") return value.trim() || null;
@@ -128,6 +132,32 @@ export function isMultiSelect(field) {
128
132
  export function translatableFieldKeys(entry) {
129
133
  return Object.entries(entry.fields).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
130
134
  }
135
+ export function translatableBlockFieldKeys(block) {
136
+ return Object.entries(block.fields).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
137
+ }
138
+ export function hasTranslatableBlockFields(field) {
139
+ if (field.type !== "blocks") return false;
140
+ return Object.values(field.blocks ?? {}).some(
141
+ (block) => Object.values(block.fields).some(isTranslatableField)
142
+ );
143
+ }
144
+ export function localizeBlock(field, item, locale, defaultLocale) {
145
+ if (!item || typeof item !== "object") return item;
146
+ const block = field.blocks?.[String(item.type)];
147
+ if (!block) return item;
148
+ const keys = translatableBlockFieldKeys(block);
149
+ if (!keys.length) return item;
150
+ const localized = { ...item };
151
+ for (const key of keys) {
152
+ const values = decodeTranslatableValue(localized[key], defaultLocale);
153
+ localized[key] = isTranslatableMediaField(block.fields[key]) ? pickTranslatedMedia(values, locale, defaultLocale) : values?.[locale] ?? values?.[defaultLocale] ?? null;
154
+ }
155
+ return localized;
156
+ }
157
+ export function localizeBlocks(field, value, locale, defaultLocale) {
158
+ if (!Array.isArray(value)) return value;
159
+ return value.map((item) => localizeBlock(field, item, locale, defaultLocale));
160
+ }
131
161
  export function defineCmsConfig(config) {
132
162
  return config;
133
163
  }
@@ -34,14 +34,24 @@ function scalarSchema(field, m) {
34
34
  function optionalize(field, base) {
35
35
  return field.required ? base : base.nullish().transform((v) => v ?? null);
36
36
  }
37
- function blocksSchema(field, m) {
37
+ function translatableSchema(field, i18n, m) {
38
+ const { locales, defaultLocale } = i18n;
39
+ const record = z.record(z.string(), field.type === "media" ? objectKeySchema : z.string()).refine((v) => Object.keys(v).every((k) => locales.includes(k)), m.unknownLocale);
40
+ return field.required ? record.refine((v) => !!v[defaultLocale]?.trim(), m.requiredLocale(defaultLocale)) : record.nullish().transform((v) => v ?? null);
41
+ }
42
+ function blockFieldSchema(blockField, i18n, m) {
43
+ if (isTranslatableField(blockField) && i18n.locales.length)
44
+ return translatableSchema(blockField, i18n, m);
45
+ return optionalize(blockField, scalarSchema(blockField, m));
46
+ }
47
+ function blocksSchema(field, i18n, m) {
38
48
  const variants = Object.entries(field.blocks ?? {}).map(
39
49
  ([type, block]) => z.object({
40
50
  type: z.literal(type),
41
51
  ...Object.fromEntries(
42
52
  Object.entries(block.fields).map(([key, blockField]) => [
43
53
  key,
44
- optionalize(blockField, scalarSchema(blockField, m))
54
+ blockFieldSchema(blockField, i18n, m)
45
55
  ])
46
56
  )
47
57
  })
@@ -50,7 +60,6 @@ function blocksSchema(field, m) {
50
60
  }
51
61
  export function buildEntrySchema(entry, i18n, messages) {
52
62
  const m = { ...DEFAULT_MESSAGES, ...messages };
53
- const { locales, defaultLocale } = i18n;
54
63
  const shape = {};
55
64
  for (const [key, field] of Object.entries(entry.fields)) {
56
65
  if (field.type === "relation" && field.cardinality === "many-to-many") {
@@ -64,13 +73,12 @@ export function buildEntrySchema(entry, i18n, messages) {
64
73
  continue;
65
74
  }
66
75
  if (field.type === "blocks") {
67
- const list = blocksSchema(field, m);
76
+ const list = blocksSchema(field, i18n, m);
68
77
  shape[key] = field.required ? list.min(1, m.required) : list.nullish().transform((v) => v ?? null);
69
78
  continue;
70
79
  }
71
- if (isTranslatableField(field) && locales.length) {
72
- const record = z.record(z.string(), field.type === "media" ? objectKeySchema : z.string()).refine((v) => Object.keys(v).every((k) => locales.includes(k)), m.unknownLocale);
73
- shape[key] = field.required ? record.refine((v) => !!v[defaultLocale]?.trim(), m.requiredLocale(defaultLocale)) : record.nullish().transform((v) => v ?? null);
80
+ if (isTranslatableField(field) && i18n.locales.length) {
81
+ shape[key] = translatableSchema(field, i18n, m);
74
82
  continue;
75
83
  }
76
84
  if (field.type === "json") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
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)",