@xleddyl/nuxt-cms 0.1.32 → 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/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.1.32",
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
@@ -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,16 @@ 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
+ }
172
181
  if (blockField.private)
173
182
  errors.push(`${bfat}: private fields are not supported inside blocks`);
174
183
  if (blockField.type === "select" && !blockField.options?.length) {
@@ -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,8 +21,10 @@ import * as cmsTables from "#cms-tables";
21
21
  import { useRuntimeConfig } from "#imports";
22
22
  import {
23
23
  decodeTranslatableMedia,
24
+ hasTranslatableBlockFields,
24
25
  isPrivateField,
25
26
  isTranslatableMediaField,
27
+ localizeBlocks,
26
28
  mediaPublicUrl,
27
29
  mediaTypeFor,
28
30
  pickTranslatedMedia,
@@ -104,6 +106,10 @@ function localizeRow(entry, row, locale) {
104
106
  const value = row[key];
105
107
  result[key] = value?.[locale] ?? value?.[defaultLocale] ?? null;
106
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
+ }
107
113
  return result;
108
114
  }
109
115
  const OPERATORS = {
@@ -63,7 +63,8 @@ export interface FieldConfig {
63
63
  export declare function isPrivateField(field: FieldConfig): boolean;
64
64
  export declare function isTranslatableField(field: FieldConfig): boolean;
65
65
  export declare function isTranslatableMediaField(field: FieldConfig): boolean;
66
- 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;
67
68
  export declare function encodeTranslatableMedia(value: unknown): string | null;
68
69
  export declare function pickTranslatedMedia(values: Record<string, string> | null | undefined, locale: string, defaultLocale: string): string | null;
69
70
  export declare function translatableMediaKeys(entry: Pick<CmsEntry, 'fields'>): string[];
@@ -71,6 +72,10 @@ export declare function encodeEntryTranslatableMedia(entry: Pick<CmsEntry, 'fiel
71
72
  export declare function decodeEntryTranslatableMedia<T extends Record<string, unknown>>(entry: Pick<CmsEntry, 'fields'>, rows: T[], defaultLocale: string): T[];
72
73
  export declare function isMultiSelect(field: FieldConfig): boolean;
73
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;
74
79
  export interface CmsEntry {
75
80
  id: string;
76
81
  label: string;
@@ -132,7 +137,7 @@ export interface RelationFieldInput extends FieldInputBase {
132
137
  cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
133
138
  onDelete?: 'set null' | 'cascade' | 'restrict';
134
139
  }
135
- export type BlockFieldInput = Omit<TextFieldInput, 'translatable' | 'private'> | Omit<RichtextFieldInput, 'translatable' | 'private'> | Omit<NumberFieldInput, 'private'> | Omit<BooleanFieldInput, 'private'> | Omit<DateFieldInput, 'private'> | Omit<EmailFieldInput, 'private'> | Omit<SelectFieldInput, 'private'> | Omit<JsonFieldInput, 'private'> | Omit<MediaFieldInput, 'translatable' | 'private'>;
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'>;
136
141
  export interface BlockInput {
137
142
  label: string;
138
143
  fields: Record<string, BlockFieldInput>;
@@ -81,13 +81,14 @@ function parseJsonObject(raw) {
81
81
  }
82
82
  return null;
83
83
  }
84
- export function decodeTranslatableMedia(value, defaultLocale) {
84
+ export function decodeTranslatableValue(value, defaultLocale) {
85
85
  if (value == null) return null;
86
86
  if (typeof value === "object") return value;
87
87
  const raw = String(value).trim();
88
88
  if (!raw) return null;
89
89
  return (raw.startsWith("{") ? parseJsonObject(raw) : null) ?? { [defaultLocale]: raw };
90
90
  }
91
+ export const decodeTranslatableMedia = decodeTranslatableValue;
91
92
  export function encodeTranslatableMedia(value) {
92
93
  if (value == null) return null;
93
94
  if (typeof value === "string") return value.trim() || null;
@@ -131,6 +132,32 @@ export function isMultiSelect(field) {
131
132
  export function translatableFieldKeys(entry) {
132
133
  return Object.entries(entry.fields).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
133
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
+ }
134
161
  export function defineCmsConfig(config) {
135
162
  return config;
136
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.32",
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)",