@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 +1 -1
- package/dist/module.mjs +13 -8
- package/dist/runtime/app/components/cms/MediaField.d.vue.ts +1 -1
- package/dist/runtime/app/components/cms/MediaField.vue.d.ts +1 -1
- package/dist/runtime/app/components/cms/MediaGallery.d.vue.ts +1 -1
- package/dist/runtime/app/components/cms/MediaGallery.vue +19 -8
- package/dist/runtime/app/components/cms/MediaGallery.vue.d.ts +1 -1
- package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -1
- package/dist/runtime/app/components/cms/MediaUpload.vue +25 -4
- package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -1
- package/dist/runtime/app/composables/cms-query-disabled.d.ts +7 -2
- package/dist/runtime/app/composables/cms-query-disabled.js +7 -3
- package/dist/runtime/app/composables/cms-query.d.ts +7 -2
- package/dist/runtime/app/composables/cms-query.js +8 -3
- package/dist/runtime/server/utils/graphql.js +7 -2
- package/dist/runtime/server/utils/media-sync.d.ts +3 -0
- package/dist/runtime/server/utils/media-sync.js +218 -5
- package/dist/runtime/shared/graphql-sdl.d.ts +2 -3
- package/dist/runtime/shared/graphql-sdl.js +43 -24
- package/dist/runtime/shared/index.d.ts +7 -2
- package/dist/runtime/shared/index.js +19 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
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,
|
|
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
|
|
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 =
|
|
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}:
|
|
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 ${
|
|
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
|
|
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:
|
|
531
|
+
type: T
|
|
527
532
|
alt: string | null
|
|
528
533
|
folder: string | null
|
|
529
534
|
mime: string | null
|
|
@@ -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 {
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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(() =>
|
|
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
|
|
354
|
-
let list =
|
|
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, {
|
|
@@ -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
|
|
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
|
|
124
|
+
...await mediaDimensions(file)
|
|
104
125
|
}
|
|
105
126
|
});
|
|
106
127
|
done.value++;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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[
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
196
|
-
const header = await readHeader(
|
|
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
|
-
|
|
3
|
-
export
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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}: [${
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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.
|
|
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)",
|