@xleddyl/nuxt-cms 0.1.32 → 0.1.34

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.34",
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, isPrivateField, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
13
+ import { isMultiSelect, fieldConditions, 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,8 @@ 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"];
48
+ const CONDITION_FIELD_TYPES = ["select", "boolean", "text", "number", "date", "email", "slug"];
47
49
  const RESERVED_TYPE_NAMES = [
48
50
  "Query",
49
51
  "Mutation",
@@ -124,7 +126,7 @@ function validateConfig(config, i18n) {
124
126
  columnNames.add(column);
125
127
  }
126
128
  if (field.translatable) {
127
- if (field.type !== "text" && field.type !== "richtext" && field.type !== "media")
129
+ if (!TRANSLATABLE_FIELD_TYPES.includes(field.type))
128
130
  errors.push(
129
131
  `${fat}: translatable is only supported on text, richtext and media fields`
130
132
  );
@@ -137,6 +139,48 @@ function validateConfig(config, i18n) {
137
139
  else if (new Set(field.options).size !== field.options.length)
138
140
  errors.push(`${fat}: select options must be unique`);
139
141
  }
142
+ if (field.showIf) {
143
+ if (entry.titleField === key)
144
+ errors.push(`${fat}: the titleField cannot be conditional`);
145
+ for (const condition of fieldConditions(field)) {
146
+ const cat = `${fat}, showIf on '${condition.field}'`;
147
+ const target = entry.fields?.[condition.field];
148
+ if (!condition.field || !target) {
149
+ errors.push(`${cat}: '${condition.field}' is not a declared field`);
150
+ continue;
151
+ }
152
+ if (condition.field === key) {
153
+ errors.push(`${cat}: a field cannot depend on itself`);
154
+ continue;
155
+ }
156
+ if (!CONDITION_FIELD_TYPES.includes(target.type) || isMultiSelect(target)) {
157
+ errors.push(
158
+ `${cat}: showIf can only depend on ${CONDITION_FIELD_TYPES.join(
159
+ ", "
160
+ )} fields (got '${target.type}')`
161
+ );
162
+ continue;
163
+ }
164
+ if (isTranslatableField(target)) {
165
+ errors.push(`${cat}: showIf cannot depend on a translatable field`);
166
+ continue;
167
+ }
168
+ const values = condition.in ?? (condition.eq === void 0 ? null : [condition.eq]);
169
+ if (!values || !values.length) {
170
+ errors.push(`${cat}: showIf requires 'eq' or a non-empty 'in'`);
171
+ continue;
172
+ }
173
+ if (condition.in && condition.eq !== void 0)
174
+ errors.push(`${cat}: showIf accepts either 'eq' or 'in', not both`);
175
+ if (target.type === "select") {
176
+ const unknown = values.filter((value) => !target.options?.includes(String(value)));
177
+ if (unknown.length)
178
+ errors.push(
179
+ `${cat}: ${unknown.map((value) => `'${String(value)}'`).join(", ")} not in the options of '${condition.field}'`
180
+ );
181
+ }
182
+ }
183
+ }
140
184
  if (field.type === "slug" && field.from) {
141
185
  const source = entry.fields?.[field.from];
142
186
  if (!source) errors.push(`${fat}: slug source '${field.from}' is not a declared field`);
@@ -167,10 +211,20 @@ function validateConfig(config, i18n) {
167
211
  `${bfat}: ${blockField.type} fields are not supported inside blocks`
168
212
  );
169
213
  }
170
- if (blockField.translatable)
171
- errors.push(`${bfat}: translatable fields are not supported inside blocks`);
214
+ if (blockField.translatable) {
215
+ if (!TRANSLATABLE_FIELD_TYPES.includes(blockField.type))
216
+ errors.push(
217
+ `${bfat}: translatable is only supported on text, richtext and media fields`
218
+ );
219
+ if (!locales.length)
220
+ errors.push(
221
+ `${bfat}: translatable requires cms.i18n.locales in nuxt.config`
222
+ );
223
+ }
172
224
  if (blockField.private)
173
225
  errors.push(`${bfat}: private fields are not supported inside blocks`);
226
+ if (blockField.showIf)
227
+ errors.push(`${bfat}: showIf is not supported inside blocks`);
174
228
  if (blockField.type === "select" && !blockField.options?.length) {
175
229
  errors.push(`${bfat}: select requires a non-empty options array`);
176
230
  }
@@ -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;
@@ -7,16 +7,18 @@
7
7
  @error="emit('error')"
8
8
  >
9
9
  <CmsFormField
10
- v-for="(field, key) in fields"
10
+ v-for="(field, key) in visibleFields"
11
11
  :key="key"
12
12
  :label="field.label"
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, isFieldVisible, 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,14 @@ 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
+ const visibleFields = computed(
62
+ () => Object.fromEntries(
63
+ Object.entries(props.fields).filter(([, field]) => isFieldVisible(field, state.value))
64
+ )
65
+ );
66
+ function hasLocaleSwitch(field) {
67
+ return isTranslatableField(field) || hasTranslatableBlockFields(field);
68
+ }
59
69
  function localeFor(key) {
60
70
  return activeLocale.value[key] ?? i18n.defaultLocale;
61
71
  }
@@ -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 = {
@@ -42,6 +42,12 @@ export interface BlockConfig {
42
42
  label: string;
43
43
  fields: Record<string, FieldConfig>;
44
44
  }
45
+ export type ConditionValue = string | number | boolean;
46
+ export interface FieldCondition {
47
+ field: string;
48
+ eq?: ConditionValue;
49
+ in?: ConditionValue[];
50
+ }
45
51
  export interface FieldConfig {
46
52
  label: string;
47
53
  type: FieldType;
@@ -59,11 +65,15 @@ export interface FieldConfig {
59
65
  to?: string;
60
66
  cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
61
67
  onDelete?: 'set null' | 'cascade' | 'restrict';
68
+ showIf?: FieldCondition | FieldCondition[];
62
69
  }
63
70
  export declare function isPrivateField(field: FieldConfig): boolean;
71
+ export declare function fieldConditions(field: FieldConfig): FieldCondition[];
72
+ export declare function isFieldVisible(field: FieldConfig, values: Record<string, unknown> | null | undefined): boolean;
64
73
  export declare function isTranslatableField(field: FieldConfig): boolean;
65
74
  export declare function isTranslatableMediaField(field: FieldConfig): boolean;
66
- export declare function decodeTranslatableMedia(value: unknown, defaultLocale: string): Record<string, string> | null;
75
+ export declare function decodeTranslatableValue(value: unknown, defaultLocale: string): Record<string, string> | null;
76
+ export declare const decodeTranslatableMedia: typeof decodeTranslatableValue;
67
77
  export declare function encodeTranslatableMedia(value: unknown): string | null;
68
78
  export declare function pickTranslatedMedia(values: Record<string, string> | null | undefined, locale: string, defaultLocale: string): string | null;
69
79
  export declare function translatableMediaKeys(entry: Pick<CmsEntry, 'fields'>): string[];
@@ -71,6 +81,10 @@ export declare function encodeEntryTranslatableMedia(entry: Pick<CmsEntry, 'fiel
71
81
  export declare function decodeEntryTranslatableMedia<T extends Record<string, unknown>>(entry: Pick<CmsEntry, 'fields'>, rows: T[], defaultLocale: string): T[];
72
82
  export declare function isMultiSelect(field: FieldConfig): boolean;
73
83
  export declare function translatableFieldKeys(entry: CmsEntry): string[];
84
+ export declare function translatableBlockFieldKeys(block: BlockConfig): string[];
85
+ export declare function hasTranslatableBlockFields(field: FieldConfig): boolean;
86
+ export declare function localizeBlock(field: FieldConfig, item: unknown, locale: string, defaultLocale: string): unknown;
87
+ export declare function localizeBlocks(field: FieldConfig, value: unknown, locale: string, defaultLocale: string): unknown;
74
88
  export interface CmsEntry {
75
89
  id: string;
76
90
  label: string;
@@ -85,6 +99,7 @@ interface FieldInputBase {
85
99
  label: string;
86
100
  required?: boolean;
87
101
  private?: boolean;
102
+ showIf?: FieldCondition | FieldCondition[];
88
103
  }
89
104
  export interface TextFieldInput extends FieldInputBase {
90
105
  type: 'text';
@@ -132,7 +147,8 @@ export interface RelationFieldInput extends FieldInputBase {
132
147
  cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
133
148
  onDelete?: 'set null' | 'cascade' | 'restrict';
134
149
  }
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'>;
150
+ type BlockField<T> = Omit<T, 'private' | 'showIf'>;
151
+ export type BlockFieldInput = BlockField<TextFieldInput> | BlockField<RichtextFieldInput> | BlockField<NumberFieldInput> | BlockField<BooleanFieldInput> | BlockField<DateFieldInput> | BlockField<EmailFieldInput> | BlockField<SelectFieldInput> | BlockField<JsonFieldInput> | BlockField<MediaFieldInput>;
136
152
  export interface BlockInput {
137
153
  label: string;
138
154
  fields: Record<string, BlockFieldInput>;
@@ -64,6 +64,19 @@ export function normalizeMediaFolder(value) {
64
64
  export function isPrivateField(field) {
65
65
  return !!field.private;
66
66
  }
67
+ export function fieldConditions(field) {
68
+ if (!field.showIf) return [];
69
+ return Array.isArray(field.showIf) ? field.showIf : [field.showIf];
70
+ }
71
+ function matchesCondition(condition, value) {
72
+ if (condition.in) return condition.in.some((option) => option === value);
73
+ return condition.eq === value;
74
+ }
75
+ export function isFieldVisible(field, values) {
76
+ const conditions = fieldConditions(field);
77
+ if (!conditions.length) return true;
78
+ return conditions.every((condition) => matchesCondition(condition, values?.[condition.field]));
79
+ }
67
80
  export function isTranslatableField(field) {
68
81
  return !!field.translatable && (field.type === "text" || field.type === "richtext" || field.type === "media");
69
82
  }
@@ -81,13 +94,14 @@ function parseJsonObject(raw) {
81
94
  }
82
95
  return null;
83
96
  }
84
- export function decodeTranslatableMedia(value, defaultLocale) {
97
+ export function decodeTranslatableValue(value, defaultLocale) {
85
98
  if (value == null) return null;
86
99
  if (typeof value === "object") return value;
87
100
  const raw = String(value).trim();
88
101
  if (!raw) return null;
89
102
  return (raw.startsWith("{") ? parseJsonObject(raw) : null) ?? { [defaultLocale]: raw };
90
103
  }
104
+ export const decodeTranslatableMedia = decodeTranslatableValue;
91
105
  export function encodeTranslatableMedia(value) {
92
106
  if (value == null) return null;
93
107
  if (typeof value === "string") return value.trim() || null;
@@ -131,6 +145,32 @@ export function isMultiSelect(field) {
131
145
  export function translatableFieldKeys(entry) {
132
146
  return Object.entries(entry.fields).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
133
147
  }
148
+ export function translatableBlockFieldKeys(block) {
149
+ return Object.entries(block.fields).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
150
+ }
151
+ export function hasTranslatableBlockFields(field) {
152
+ if (field.type !== "blocks") return false;
153
+ return Object.values(field.blocks ?? {}).some(
154
+ (block) => Object.values(block.fields).some(isTranslatableField)
155
+ );
156
+ }
157
+ export function localizeBlock(field, item, locale, defaultLocale) {
158
+ if (!item || typeof item !== "object") return item;
159
+ const block = field.blocks?.[String(item.type)];
160
+ if (!block) return item;
161
+ const keys = translatableBlockFieldKeys(block);
162
+ if (!keys.length) return item;
163
+ const localized = { ...item };
164
+ for (const key of keys) {
165
+ const values = decodeTranslatableValue(localized[key], defaultLocale);
166
+ localized[key] = isTranslatableMediaField(block.fields[key]) ? pickTranslatedMedia(values, locale, defaultLocale) : values?.[locale] ?? values?.[defaultLocale] ?? null;
167
+ }
168
+ return localized;
169
+ }
170
+ export function localizeBlocks(field, value, locale, defaultLocale) {
171
+ if (!Array.isArray(value)) return value;
172
+ return value.map((item) => localizeBlock(field, item, locale, defaultLocale));
173
+ }
134
174
  export function defineCmsConfig(config) {
135
175
  return config;
136
176
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { isTranslatableField } from "./index.js";
2
+ import { isFieldVisible, isTranslatableField } from "./index.js";
3
3
  export const objectKeySchema = z.string().min(1).max(1024).refine((k) => !k.includes("..") && !k.startsWith("/"), "Invalid object key");
4
4
  const DEFAULT_MESSAGES = {
5
5
  required: "Required field",
@@ -34,25 +34,47 @@ 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
  })
48
58
  );
49
59
  return z.array(z.discriminatedUnion("type", variants));
50
60
  }
61
+ function isEmptyValue(field, value, defaultLocale) {
62
+ if (value == null) return true;
63
+ if (Array.isArray(value)) return !value.length;
64
+ if (isTranslatableField(field)) {
65
+ const values = value;
66
+ return !values[defaultLocale]?.trim();
67
+ }
68
+ if (typeof value === "string") return !value.trim();
69
+ return false;
70
+ }
51
71
  export function buildEntrySchema(entry, i18n, messages) {
52
72
  const m = { ...DEFAULT_MESSAGES, ...messages };
53
- const { locales, defaultLocale } = i18n;
54
73
  const shape = {};
55
- for (const [key, field] of Object.entries(entry.fields)) {
74
+ const conditional = [];
75
+ for (const [key, declared] of Object.entries(entry.fields)) {
76
+ const field = declared.showIf ? { ...declared, required: false } : declared;
77
+ if (declared.showIf && declared.required) conditional.push([key, declared]);
56
78
  if (field.type === "relation" && field.cardinality === "many-to-many") {
57
79
  const list = z.array(z.string().min(1));
58
80
  shape[key] = field.required ? list.min(1, m.required) : list.nullish().transform((v) => v ?? []);
@@ -64,13 +86,12 @@ export function buildEntrySchema(entry, i18n, messages) {
64
86
  continue;
65
87
  }
66
88
  if (field.type === "blocks") {
67
- const list = blocksSchema(field, m);
89
+ const list = blocksSchema(field, i18n, m);
68
90
  shape[key] = field.required ? list.min(1, m.required) : list.nullish().transform((v) => v ?? null);
69
91
  continue;
70
92
  }
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);
93
+ if (isTranslatableField(field) && i18n.locales.length) {
94
+ shape[key] = translatableSchema(field, i18n, m);
74
95
  continue;
75
96
  }
76
97
  if (field.type === "json") {
@@ -83,5 +104,14 @@ export function buildEntrySchema(entry, i18n, messages) {
83
104
  if (entry.drafts) {
84
105
  shape.status = z.enum(["draft", "published"]).nullish().transform((v) => v ?? void 0);
85
106
  }
86
- return z.object(shape);
107
+ const schema = z.object(shape);
108
+ if (!conditional.length) return schema;
109
+ return schema.superRefine((values, ctx) => {
110
+ for (const [key, field] of conditional) {
111
+ if (!isFieldVisible(field, values)) continue;
112
+ if (!isEmptyValue(field, values[key], i18n.defaultLocale))
113
+ continue;
114
+ ctx.addIssue({ code: "custom", path: [key], message: m.required });
115
+ }
116
+ });
87
117
  }
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.34",
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)",