@xleddyl/nuxt-cms 0.1.39 → 0.1.40

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.39",
4
+ "version": "0.1.40",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -1,6 +1,6 @@
1
1
  import type { MediaType } from '#nuxt-cms';
2
2
  type __VLS_Props = {
3
- mediaType?: MediaType;
3
+ mediaType?: MediaType | MediaType[];
4
4
  accept?: string[];
5
5
  };
6
6
  type __VLS_ModelProps = {
@@ -1,6 +1,6 @@
1
1
  import type { MediaType } from '#nuxt-cms';
2
2
  type __VLS_Props = {
3
- mediaType?: MediaType;
3
+ mediaType?: MediaType | MediaType[];
4
4
  accept?: string[];
5
5
  };
6
6
  type __VLS_ModelProps = {
@@ -1,7 +1,7 @@
1
1
  import type { MediaType } from '#nuxt-cms';
2
2
  type __VLS_Props = {
3
3
  selectable?: boolean;
4
- mediaType?: MediaType;
4
+ mediaType?: MediaType | MediaType[];
5
5
  accept?: string[];
6
6
  };
7
7
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
@@ -243,7 +243,13 @@
243
243
 
244
244
  <script setup>
245
245
  import { computed, onMounted, ref, watch } from "#imports";
246
- import { MEDIA_TYPES, mediaFilename, mediaIconFor, normalizeMediaFolder } from "#nuxt-cms";
246
+ import {
247
+ MEDIA_TYPES,
248
+ mediaFilename,
249
+ mediaIconFor,
250
+ mediaTypeFilter,
251
+ normalizeMediaFolder
252
+ } from "#nuxt-cms";
247
253
  import { useCmsConfirm } from "../../composables/cms-confirm";
248
254
  import { useCmsRuntime } from "../../composables/cms-runtime";
249
255
  import { useCmsToast } from "../../composables/cms-toast";
@@ -298,12 +304,16 @@ const sourceHint = computed(() => {
298
304
  onMounted(reload);
299
305
  const notConfigured = computed(() => errorCode.value === 501);
300
306
  const loadError = computed(() => errorCode.value !== null && errorCode.value !== 501);
301
- const restricted = computed(
302
- () => props.mediaType && props.mediaType !== "file" ? props.mediaType : null
303
- );
304
- const filters = ["all", ...MEDIA_TYPES];
307
+ const allowedTypes = computed(() => mediaTypeFilter(props.mediaType));
308
+ const filters = computed(() => [
309
+ "all",
310
+ ...allowedTypes.value ?? MEDIA_TYPES
311
+ ]);
305
312
  const filter = ref("all");
306
- const showTypeFilters = computed(() => !restricted.value && items.value.length > 0);
313
+ const showTypeFilters = computed(() => filters.value.length > 2 && items.value.length > 0);
314
+ watch(filters, (list) => {
315
+ if (!list.includes(filter.value)) filter.value = "all";
316
+ });
307
317
  const search = ref("");
308
318
  const draftFolders = ref([]);
309
319
  onMounted(() => {
@@ -350,8 +360,9 @@ watch(usedFolders, (used) => {
350
360
  }
351
361
  });
352
362
  const visible = computed(() => {
353
- const active = restricted.value ?? (filter.value === "all" ? null : filter.value);
354
- let list = active ? items.value.filter((item) => item.type === active) : items.value;
363
+ const allowed = allowedTypes.value;
364
+ let list = allowed ? items.value.filter((item) => allowed.includes(item.type)) : items.value;
365
+ if (filter.value !== "all") list = list.filter((item) => item.type === filter.value);
355
366
  if (folder.value) list = list.filter((item) => item.folder === folder.value);
356
367
  const query = search.value.trim().toLowerCase();
357
368
  if (query) {
@@ -1,7 +1,7 @@
1
1
  import type { MediaType } from '#nuxt-cms';
2
2
  type __VLS_Props = {
3
3
  selectable?: boolean;
4
- mediaType?: MediaType;
4
+ mediaType?: MediaType | MediaType[];
5
5
  accept?: string[];
6
6
  };
7
7
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
@@ -1,7 +1,7 @@
1
1
  import type { MediaItem, MediaType } from '#nuxt-cms';
2
2
  type __VLS_Props = {
3
3
  multiple?: boolean;
4
- mediaType?: MediaType;
4
+ mediaType?: MediaType | MediaType[];
5
5
  accept?: string[];
6
6
  folder?: string | null;
7
7
  };
@@ -36,7 +36,7 @@
36
36
  </template>
37
37
 
38
38
  <script setup>
39
- import { formatFileSize } from "#nuxt-cms";
39
+ import { formatFileSize, mediaTypeAccept } from "#nuxt-cms";
40
40
  import { computed, ref } from "#imports";
41
41
  import { useCmsRuntime } from "../../composables/cms-runtime";
42
42
  import { useCmsToast } from "../../composables/cms-toast";
@@ -50,7 +50,6 @@ const emit = defineEmits(["uploaded"]);
50
50
  const toast = useCmsToast();
51
51
  const { mediaMaxFileSize } = useCmsRuntime();
52
52
  async function imageDimensions(file) {
53
- if (!file.type.startsWith("image/")) return {};
54
53
  try {
55
54
  const bitmap = await createImageBitmap(file);
56
55
  const dims = { width: bitmap.width, height: bitmap.height };
@@ -60,13 +59,35 @@ async function imageDimensions(file) {
60
59
  return {};
61
60
  }
62
61
  }
62
+ function videoDimensions(file) {
63
+ return new Promise((resolve) => {
64
+ const url = URL.createObjectURL(file);
65
+ const video = document.createElement("video");
66
+ const settle = (dims) => {
67
+ URL.revokeObjectURL(url);
68
+ resolve(dims);
69
+ };
70
+ video.preload = "metadata";
71
+ video.muted = true;
72
+ video.onloadedmetadata = () => settle(
73
+ video.videoWidth && video.videoHeight ? { width: video.videoWidth, height: video.videoHeight } : {}
74
+ );
75
+ video.onerror = () => settle({});
76
+ video.src = url;
77
+ });
78
+ }
79
+ async function mediaDimensions(file) {
80
+ if (file.type.startsWith("image/")) return imageDimensions(file);
81
+ if (file.type.startsWith("video/")) return videoDimensions(file);
82
+ return {};
83
+ }
63
84
  const input = ref(null);
64
85
  const dragOver = ref(false);
65
86
  const uploading = ref(false);
66
87
  const done = ref(0);
67
88
  const total = ref(0);
68
89
  const acceptAttr = computed(
69
- () => props.accept?.length ? props.accept.join(",") : props.mediaType === "image" ? "image/*" : props.mediaType === "video" ? "video/*" : void 0
90
+ () => (props.accept?.length ? props.accept : mediaTypeAccept(props.mediaType))?.join(",")
70
91
  );
71
92
  function matchesAccept(file) {
72
93
  if (!acceptAttr.value) return true;
@@ -100,7 +121,7 @@ async function uploadOne(file) {
100
121
  folder: presign.folder,
101
122
  mime: file.type || null,
102
123
  size: file.size,
103
- ...await imageDimensions(file)
124
+ ...await mediaDimensions(file)
104
125
  }
105
126
  });
106
127
  done.value++;
@@ -1,7 +1,7 @@
1
1
  import type { MediaItem, MediaType } from '#nuxt-cms';
2
2
  type __VLS_Props = {
3
3
  multiple?: boolean;
4
- mediaType?: MediaType;
4
+ mediaType?: MediaType | MediaType[];
5
5
  accept?: string[];
6
6
  folder?: string | null;
7
7
  };
@@ -24,5 +24,8 @@ export declare function jpegImageSize(bytes: Uint8Array): ImageSize | null;
24
24
  export declare function webpImageSize(bytes: Uint8Array): ImageSize | null;
25
25
  export declare function gifImageSize(bytes: Uint8Array): ImageSize | null;
26
26
  export declare function imageSizeFromBuffer(bytes: Uint8Array): ImageSize | null;
27
+ export declare function mp4VideoSize(bytes: Uint8Array): ImageSize | null;
28
+ export declare function matroskaVideoSize(bytes: Uint8Array): ImageSize | null;
29
+ export declare function videoSizeFromBuffer(bytes: Uint8Array): ImageSize | null;
27
30
  export declare function scanMediaDirectory(root: string): Promise<ScannedMediaFile[]>;
28
31
  export declare function readMediaFileMeta(root: string, file: ScannedMediaFile): Promise<MediaFileMeta>;
@@ -9,7 +9,9 @@ export const MEDIA_SYNC_MIME_TYPES = {
9
9
  gif: "image/gif",
10
10
  svg: "image/svg+xml",
11
11
  mp4: "video/mp4",
12
+ m4v: "video/x-m4v",
12
13
  webm: "video/webm",
14
+ mkv: "video/x-matroska",
13
15
  mov: "video/quicktime",
14
16
  mp3: "audio/mpeg",
15
17
  wav: "audio/wav",
@@ -18,6 +20,7 @@ export const MEDIA_SYNC_MIME_TYPES = {
18
20
  pdf: "application/pdf"
19
21
  };
20
22
  const HEADER_BYTES = 65536;
23
+ const MOOV_MAX_BYTES = 4 * 1024 * 1024;
21
24
  export function mediaSyncExtension(key) {
22
25
  const name = key.split("/").pop() ?? key;
23
26
  const dot = name.lastIndexOf(".");
@@ -157,14 +160,213 @@ export function gifImageSize(bytes) {
157
160
  export function imageSizeFromBuffer(bytes) {
158
161
  return pngImageSize(bytes) ?? gifImageSize(bytes) ?? webpImageSize(bytes) ?? jpegImageSize(bytes);
159
162
  }
163
+ const ISO_BMFF_TOP_LEVEL = /* @__PURE__ */ new Set([
164
+ "ftyp",
165
+ "styp",
166
+ "moov",
167
+ "moof",
168
+ "mdat",
169
+ "free",
170
+ "skip",
171
+ "wide",
172
+ "pnot",
173
+ "meta",
174
+ "uuid"
175
+ ]);
176
+ const FIXED_POINT_16_16 = 65536;
177
+ function readBoxHeader(bytes, offset) {
178
+ if (offset + 8 > bytes.length) return null;
179
+ const data = view(bytes);
180
+ const declared = data.getUint32(offset);
181
+ const type = ascii(bytes, offset + 4, 4);
182
+ if (declared === 1) {
183
+ if (offset + 16 > bytes.length) return null;
184
+ const size = data.getUint32(offset + 8) * 2 ** 32 + data.getUint32(offset + 12);
185
+ return { type, headerLength: 16, size };
186
+ }
187
+ return { type, headerLength: 8, size: declared === 0 ? null : declared };
188
+ }
189
+ function readBoxRange(bytes, offset, end) {
190
+ const box = readBoxHeader(bytes, offset);
191
+ if (!box) return null;
192
+ const size = box.size ?? end - offset;
193
+ if (size < box.headerLength) return null;
194
+ return {
195
+ type: box.type,
196
+ start: offset + box.headerLength,
197
+ end: Math.min(end, offset + size),
198
+ next: offset + size
199
+ };
200
+ }
201
+ function findBox(bytes, start, end, type) {
202
+ let offset = start;
203
+ while (offset + 8 <= end) {
204
+ const box = readBoxRange(bytes, offset, end);
205
+ if (!box || box.next <= offset) return null;
206
+ if (box.type === type) return box;
207
+ offset = box.next;
208
+ }
209
+ return null;
210
+ }
211
+ function trackHeaderSize(bytes, start, end) {
212
+ const version = bytes[start];
213
+ if (version === void 0) return null;
214
+ const matrixOffset = start + (version === 1 ? 52 : 40);
215
+ const sizeOffset = start + (version === 1 ? 88 : 76);
216
+ if (sizeOffset + 8 > end) return null;
217
+ const data = view(bytes);
218
+ const width = Math.round(data.getUint32(sizeOffset) / FIXED_POINT_16_16);
219
+ const height = Math.round(data.getUint32(sizeOffset + 4) / FIXED_POINT_16_16);
220
+ if (!width || !height) return null;
221
+ const quarterTurn = data.getInt32(matrixOffset) === 0 && Math.abs(data.getInt32(matrixOffset + 4)) === FIXED_POINT_16_16;
222
+ return quarterTurn ? { width: height, height: width } : { width, height };
223
+ }
224
+ function moovVideoSize(bytes, start, end) {
225
+ let offset = start;
226
+ while (offset + 8 <= end) {
227
+ const box = readBoxRange(bytes, offset, end);
228
+ if (!box || box.next <= offset) return null;
229
+ if (box.type === "trak") {
230
+ const header = findBox(bytes, box.start, box.end, "tkhd");
231
+ const size = header ? trackHeaderSize(bytes, header.start, header.end) : null;
232
+ if (size) return size;
233
+ }
234
+ offset = box.next;
235
+ }
236
+ return null;
237
+ }
238
+ export function mp4VideoSize(bytes) {
239
+ const first = readBoxHeader(bytes, 0);
240
+ if (!first || !ISO_BMFF_TOP_LEVEL.has(first.type)) return null;
241
+ const moov = findBox(bytes, 0, bytes.length, "moov");
242
+ return moov ? moovVideoSize(bytes, moov.start, moov.end) : null;
243
+ }
244
+ const EBML_SIGNATURE = [26, 69, 223, 163];
245
+ const EBML_SEGMENT = 408125543;
246
+ const EBML_TRACKS = 374648427;
247
+ const EBML_TRACK_ENTRY = 174;
248
+ const EBML_VIDEO = 224;
249
+ const EBML_PIXEL_WIDTH = 176;
250
+ const EBML_PIXEL_HEIGHT = 186;
251
+ const EBML_DISPLAY_WIDTH = 21680;
252
+ const EBML_DISPLAY_HEIGHT = 21690;
253
+ const EBML_PARENTS = /* @__PURE__ */ new Set([EBML_SEGMENT, EBML_TRACKS, EBML_TRACK_ENTRY]);
254
+ function readEbmlVint(bytes, offset, keepMarker) {
255
+ const first = bytes[offset];
256
+ if (!first) return null;
257
+ let length = 1;
258
+ let mask = 128;
259
+ while (length <= 8 && !(first & mask)) {
260
+ length++;
261
+ mask >>= 1;
262
+ }
263
+ if (length > 8 || offset + length > bytes.length) return null;
264
+ let value = keepMarker ? first : first & mask - 1;
265
+ for (let index = 1; index < length; index++) value = value * 256 + bytes[offset + index];
266
+ return { value, length, unknown: !keepMarker && value === 2 ** (7 * length) - 1 };
267
+ }
268
+ function readEbmlElement(bytes, offset, end) {
269
+ const id = readEbmlVint(bytes, offset, true);
270
+ if (!id) return null;
271
+ const size = readEbmlVint(bytes, offset + id.length, false);
272
+ if (!size) return null;
273
+ const start = offset + id.length + size.length;
274
+ const elementEnd = size.unknown ? end : Math.min(end, start + size.value);
275
+ return { id: id.value, start, end: elementEnd, next: Math.max(start, elementEnd) };
276
+ }
277
+ function readEbmlUint(bytes, start, end) {
278
+ let value = 0;
279
+ for (let index = start; index < end && index < bytes.length; index++) {
280
+ value = value * 256 + bytes[index];
281
+ }
282
+ return value;
283
+ }
284
+ function matroskaTrackSize(bytes, start, end) {
285
+ let pixelWidth = 0;
286
+ let pixelHeight = 0;
287
+ let displayWidth = 0;
288
+ let displayHeight = 0;
289
+ let offset = start;
290
+ while (offset < end) {
291
+ const element = readEbmlElement(bytes, offset, end);
292
+ if (!element || element.next <= offset) break;
293
+ if (element.id === EBML_PIXEL_WIDTH) {
294
+ pixelWidth = readEbmlUint(bytes, element.start, element.end);
295
+ } else if (element.id === EBML_PIXEL_HEIGHT) {
296
+ pixelHeight = readEbmlUint(bytes, element.start, element.end);
297
+ } else if (element.id === EBML_DISPLAY_WIDTH) {
298
+ displayWidth = readEbmlUint(bytes, element.start, element.end);
299
+ } else if (element.id === EBML_DISPLAY_HEIGHT) {
300
+ displayHeight = readEbmlUint(bytes, element.start, element.end);
301
+ }
302
+ offset = element.next;
303
+ }
304
+ const width = displayWidth || pixelWidth;
305
+ const height = displayHeight || pixelHeight;
306
+ return width && height ? { width, height } : null;
307
+ }
308
+ function matroskaVideoSizeIn(bytes, start, end) {
309
+ let offset = start;
310
+ while (offset < end) {
311
+ const element = readEbmlElement(bytes, offset, end);
312
+ if (!element || element.next <= offset) return null;
313
+ if (element.id === EBML_VIDEO) {
314
+ const size = matroskaTrackSize(bytes, element.start, element.end);
315
+ if (size) return size;
316
+ } else if (EBML_PARENTS.has(element.id)) {
317
+ const size = matroskaVideoSizeIn(bytes, element.start, element.end);
318
+ if (size) return size;
319
+ }
320
+ offset = element.next;
321
+ }
322
+ return null;
323
+ }
324
+ export function matroskaVideoSize(bytes) {
325
+ if (!startsWith(bytes, 0, EBML_SIGNATURE)) return null;
326
+ return matroskaVideoSizeIn(bytes, 0, bytes.length);
327
+ }
328
+ export function videoSizeFromBuffer(bytes) {
329
+ return mp4VideoSize(bytes) ?? matroskaVideoSize(bytes);
330
+ }
331
+ async function readChunk(handle, position, length) {
332
+ if (length <= 0) return new Uint8Array(0);
333
+ const buffer = new Uint8Array(length);
334
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
335
+ return buffer.subarray(0, bytesRead);
336
+ }
160
337
  async function readHeader(path, size) {
161
338
  const length = Math.min(size, HEADER_BYTES);
162
339
  if (length <= 0) return null;
163
340
  const handle = await open(path, "r");
164
341
  try {
165
- const buffer = new Uint8Array(length);
166
- const { bytesRead } = await handle.read(buffer, 0, length, 0);
167
- return buffer.subarray(0, bytesRead);
342
+ return await readChunk(handle, 0, length);
343
+ } finally {
344
+ await handle.close();
345
+ }
346
+ }
347
+ async function readMoovVideoSize(handle, fileSize) {
348
+ let position = 0;
349
+ while (position + 8 <= fileSize) {
350
+ const header = await readChunk(handle, position, 16);
351
+ const box = header.length >= 8 ? readBoxHeader(header, 0) : null;
352
+ if (!box) return null;
353
+ const size = box.size ?? fileSize - position;
354
+ if (size < box.headerLength) return null;
355
+ if (box.type === "moov") {
356
+ return mp4VideoSize(await readChunk(handle, position, Math.min(size, MOOV_MAX_BYTES)));
357
+ }
358
+ position += size;
359
+ }
360
+ return null;
361
+ }
362
+ async function readVideoSize(path, fileSize) {
363
+ if (fileSize <= 0) return null;
364
+ const handle = await open(path, "r");
365
+ try {
366
+ const head = await readChunk(handle, 0, Math.min(fileSize, HEADER_BYTES));
367
+ return videoSizeFromBuffer(head) ?? await readMoovVideoSize(handle, fileSize);
368
+ } catch {
369
+ return null;
168
370
  } finally {
169
371
  await handle.close();
170
372
  }
@@ -189,12 +391,23 @@ export async function scanMediaDirectory(root) {
189
391
  await walk(root, "");
190
392
  return files.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
191
393
  }
394
+ const IMAGE_SIZE_MIME_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
395
+ const VIDEO_SIZE_MIME_TYPES = /* @__PURE__ */ new Set([
396
+ "video/mp4",
397
+ "video/x-m4v",
398
+ "video/quicktime",
399
+ "video/webm",
400
+ "video/x-matroska"
401
+ ]);
192
402
  export async function readMediaFileMeta(root, file) {
193
403
  const mime = mediaMimeForKey(file.key);
404
+ const path = join(root, ...file.key.split("/"));
194
405
  let size = null;
195
- if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp" || mime === "image/gif") {
196
- const header = await readHeader(join(root, ...file.key.split("/")), file.size);
406
+ if (mime && IMAGE_SIZE_MIME_TYPES.has(mime)) {
407
+ const header = await readHeader(path, file.size);
197
408
  size = header ? imageSizeFromBuffer(header) : null;
409
+ } else if (mime && VIDEO_SIZE_MIME_TYPES.has(mime)) {
410
+ size = await readVideoSize(path, file.size);
198
411
  }
199
412
  return {
200
413
  key: file.key,
@@ -13,6 +13,8 @@ export declare function mediaTypeForKey(key: string): MediaType;
13
13
  export declare function mediaFilename(key: string): string;
14
14
  export declare function mediaTypeFor(mime: string | null | undefined, key: string): MediaType;
15
15
  export declare function mediaIconFor(type: MediaType): string;
16
+ export declare function mediaTypeFilter(mediaType: MediaType | MediaType[] | null | undefined): MediaType[] | null;
17
+ export declare function mediaTypeAccept(mediaType: MediaType | MediaType[] | null | undefined): string[] | null;
16
18
  export declare function mediaPublicUrl(baseUrl: string | null | undefined, key: string): string | null;
17
19
  export declare const DEFAULT_MEDIA_MAX_FILE_SIZE: number;
18
20
  export declare function formatFileSize(bytes: number): string;
@@ -60,7 +62,7 @@ export interface FieldConfig {
60
62
  multiple?: boolean;
61
63
  from?: string;
62
64
  blocks?: Record<string, BlockConfig>;
63
- mediaType?: MediaType;
65
+ mediaType?: MediaType | MediaType[];
64
66
  accept?: string[];
65
67
  to?: string;
66
68
  cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
@@ -138,7 +140,7 @@ export interface JsonFieldInput extends FieldInputBase {
138
140
  }
139
141
  export interface MediaFieldInput extends FieldInputBase {
140
142
  type: 'media';
141
- mediaType?: MediaType;
143
+ mediaType?: MediaType | MediaType[];
142
144
  accept?: string[];
143
145
  translatable?: boolean;
144
146
  }
@@ -38,6 +38,16 @@ export function mediaTypeFor(mime, key) {
38
38
  export function mediaIconFor(type) {
39
39
  return type === "image" ? "photo" : type === "video" ? "film" : "document";
40
40
  }
41
+ export function mediaTypeFilter(mediaType) {
42
+ const requested = Array.isArray(mediaType) ? mediaType : mediaType ? [mediaType] : [];
43
+ const types = [...new Set(requested.filter((type) => MEDIA_TYPES.includes(type)))];
44
+ if (!types.length || types.includes("file")) return null;
45
+ return types;
46
+ }
47
+ export function mediaTypeAccept(mediaType) {
48
+ const types = mediaTypeFilter(mediaType);
49
+ return types ? types.map((type) => `${type}/*`) : null;
50
+ }
41
51
  export function mediaPublicUrl(baseUrl, key) {
42
52
  return baseUrl ? `${baseUrl.replace(/\/+$/, "")}/${key}` : null;
43
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
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)",