@xleddyl/nuxt-cms 0.1.39 → 0.1.41

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.41",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -9,8 +9,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 { isMultiSelect, fieldConditions, isTranslatableField, isTranslatableMediaField, 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,10 @@ ${[mediaTableExpr(dialect), ...tables, ...joins].join(
450
450
  `;
451
451
  }
452
452
 
453
+ function mediaTsType(field) {
454
+ const types = mediaTypeFilter(field.mediaType);
455
+ return types ? `CmsMedia<${types.map((type) => `'${type}'`).join(" | ")}>` : "CmsMedia";
456
+ }
453
457
  function scalarTsType(field) {
454
458
  switch (field.type) {
455
459
  case "number":
@@ -470,13 +474,13 @@ function fieldTsType(config, entryName, key, field) {
470
474
  if (field.cardinality === "many-to-many") return `${target}[]`;
471
475
  return isRequiredField(field) && !config[field.to]?.drafts ? target : `${target} | null`;
472
476
  }
473
- if (field.type === "media") return "CmsMedia | null";
477
+ if (field.type === "media") return `${mediaTsType(field)} | null`;
474
478
  if (field.type === "select" && field.multiple) {
475
479
  const union = field.options.map((o) => JSON.stringify(o)).join(" | ");
476
480
  return `(${union})[]`;
477
481
  }
478
482
  if (field.type === "blocks") {
479
- const union = blockUnionName(entryName, key);
483
+ const union = blocksFieldTypeName(entryName, key);
480
484
  return isRequiredField(field) ? `${union}[]` : `${union}[] | null`;
481
485
  }
482
486
  if (isTranslatableField(field)) return isRequiredField(field) ? "string" : "string | null";
@@ -492,7 +496,7 @@ function blockTypesTs(entryName, key, field) {
492
496
  const lines = [` __typename?: '${name}'`, ` type: '${blockName}'`];
493
497
  for (const [blockFieldKey, blockField] of Object.entries(block.fields)) {
494
498
  if (blockField.type === "media") {
495
- lines.push(` ${blockFieldKey}: CmsMedia | null`);
499
+ lines.push(` ${blockFieldKey}: ${mediaTsType(blockField)} | null`);
496
500
  continue;
497
501
  }
498
502
  const base = scalarTsType(blockField);
@@ -503,7 +507,7 @@ ${lines.join("\n")}
503
507
  }`);
504
508
  }
505
509
  if (members.length)
506
- defs.push(`export type ${blockUnionName(entryName, key)} = ${members.join(" | ")}`);
510
+ defs.push(`export type ${blocksFieldTypeName(entryName, key)} = ${members.join(" | ")}`);
507
511
  return defs;
508
512
  }
509
513
  function entryTs(config, name, entry) {
@@ -520,10 +524,11 @@ ${lines.join("\n")}
520
524
  }
521
525
  function renderTypesFile(config) {
522
526
  const parts = [
523
- `export interface CmsMedia {
527
+ `export type CmsMediaType = 'image' | 'video' | 'file'`,
528
+ `export interface CmsMedia<T extends CmsMediaType = CmsMediaType> {
524
529
  key: string
525
530
  url: string | null
526
- type: 'image' | 'video' | 'file'
531
+ type: T
527
532
  alt: string | null
528
533
  folder: string | null
529
534
  mime: string | null
@@ -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
  };
@@ -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 ?? {})) {
@@ -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,
@@ -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 = [];
@@ -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';
@@ -96,6 +98,9 @@ export interface CmsEntry {
96
98
  table?: CmsTable;
97
99
  }
98
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;
99
104
  interface FieldInputBase {
100
105
  label: string;
101
106
  required?: boolean;
@@ -138,7 +143,7 @@ export interface JsonFieldInput extends FieldInputBase {
138
143
  }
139
144
  export interface MediaFieldInput extends FieldInputBase {
140
145
  type: 'media';
141
- mediaType?: MediaType;
146
+ mediaType?: MediaType | MediaType[];
142
147
  accept?: string[];
143
148
  translatable?: boolean;
144
149
  }
@@ -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
  }
@@ -174,6 +184,15 @@ export function localizeBlocks(field, value, locale, defaultLocale) {
174
184
  if (!Array.isArray(value)) return value;
175
185
  return value.map((item) => localizeBlock(field, item, locale, defaultLocale));
176
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
+ }
177
196
  export function defineCmsConfig(config) {
178
197
  return config;
179
198
  }
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.41",
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)",