@xleddyl/nuxt-cms 0.7.1 → 0.8.1

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.7.1",
4
+ "version": "0.8.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -2,14 +2,14 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { extname, resolve, isAbsolute, join, relative, dirname } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { defineNuxtModule, createResolver, useLogger, addImports, addTemplate, addServerPlugin, addTypeTemplate, addComponentsDir, addRouteMiddleware, extendPages, addServerHandler, resolvePath, addComponent } from '@nuxt/kit';
5
+ import { pathToFileURL, fileURLToPath } from 'node:url';
6
+ import { defineNuxtModule, createResolver, useLogger, addImports, addTemplate, addServerPlugin, addTypeTemplate, addComponentsDir, tryResolveModule, addRouteMiddleware, extendPages, addServerHandler, resolvePath, addComponent } from '@nuxt/kit';
7
7
  import { introspectionFromSchema, buildSchema } from 'graphql';
8
8
  import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal';
9
9
  import { createJiti } from 'jiti';
10
10
  import { typeName, blockTypeName as blockTypeName$1, blocksFieldTypeName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
11
11
  import { migrationsDirFor } from '../dist/runtime/shared/migrations-dir.js';
12
- import { entryFieldsFor, isPrivateField, blockTypeName, pageColumnFields, isTranslatableField, isTranslatableMediaField, isMultiSelect, pageAllFields, normalizeContentEntries, PAGE_PATH_FIELD, pageRoutes, isCollectionKind, layoutErrors, listErrors, mobileMediaKey, fieldConditions, selectOptionValues, CONTENT_SLUG_FIELD, pageFieldsTableName, pageMediaTableName, CONTENT_SYSTEM_FIELDS, isRequiredField, pageFields, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE, normalizeCmsConfig } from '../dist/runtime/shared/index.js';
12
+ import { entryFieldsFor, isPrivateField, blockTypeName, pageColumnFields, isTranslatableField, isTranslatableMediaField, isStringListField, pageAllFields, normalizeContentEntries, PAGE_PATH_FIELD, pageRoutes, isCollectionKind, isMultiSelect, layoutErrors, listErrors, mobileMediaKey, fieldConditions, selectOptionValues, CONTENT_SLUG_FIELD, pageFieldsTableName, pageMediaTableName, CONTENT_SYSTEM_FIELDS, isRequiredField, pageFields, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE, normalizeCmsConfig } from '../dist/runtime/shared/index.js';
13
13
  import { previewPathErrors, DEFAULT_CMS_PREVIEW_PATH } from '../dist/runtime/shared/preview.js';
14
14
  import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
15
15
  import { createHash } from 'node:crypto';
@@ -264,7 +264,8 @@ const RESERVED_TYPE_NAMES = [
264
264
  "IntFilter",
265
265
  "FloatFilter",
266
266
  "StringFilter",
267
- "BooleanFilter"
267
+ "BooleanFilter",
268
+ "StringListFilter"
268
269
  ];
269
270
  function validateConfig(config, i18n) {
270
271
  const errors = [];
@@ -484,8 +485,13 @@ function validateConfig(config, i18n) {
484
485
  else if (isTranslatableField(source) && !(entry.kind === "content" && key === CONTENT_SLUG_FIELD))
485
486
  errors.push(`${fat}: slug source '${field.from}' cannot be translatable`);
486
487
  }
487
- if (field.type !== "blocks" && (field.min !== void 0 || field.max !== void 0))
488
- errors.push(`${fat}: min and max are only supported on blocks fields`);
488
+ if (field.type === "tags") {
489
+ errors.push(...tagsOptionErrors(fat, field));
490
+ } else if (field.suggestions !== void 0) {
491
+ errors.push(`${fat}: suggestions is only supported on tags fields`);
492
+ }
493
+ if (field.type !== "blocks" && field.type !== "tags" && (field.min !== void 0 || field.max !== void 0))
494
+ errors.push(`${fat}: min and max are only supported on blocks and tags fields`);
489
495
  if (field.type === "blocks") {
490
496
  if (field.translatable) errors.push(`${fat}: blocks fields cannot be translatable`);
491
497
  errors.push(...blocksCountErrors(fat, field));
@@ -505,7 +511,7 @@ function validateConfig(config, i18n) {
505
511
  errors.push(`${bfat}: key must be a valid identifier`);
506
512
  if (blockFieldKey === "type")
507
513
  errors.push(`${bfat}: 'type' is reserved inside blocks`);
508
- if (["relation", "blocks", "slug"].includes(blockField.type)) {
514
+ if (["relation", "blocks", "slug", "tags"].includes(blockField.type)) {
509
515
  errors.push(
510
516
  `${bfat}: ${blockField.type} fields are not supported inside blocks`
511
517
  );
@@ -659,6 +665,15 @@ function blocksCountErrors(at, field) {
659
665
  errors.push(`${at}: min (${field.min}) cannot be greater than max (${field.max})`);
660
666
  return errors;
661
667
  }
668
+ function tagsOptionErrors(at, field) {
669
+ const errors = [];
670
+ if (field.min !== void 0) errors.push(`${at}: min is not supported on tags fields`);
671
+ if (field.max !== void 0 && !(typeof field.max === "number" && Number.isInteger(field.max) && field.max >= 1))
672
+ errors.push(`${at}: max must be an integer of 1 or more`);
673
+ if (field.suggestions !== void 0 && typeof field.suggestions !== "boolean")
674
+ errors.push(`${at}: suggestions must be a boolean`);
675
+ return errors;
676
+ }
662
677
  function blockMetaErrors(at, block) {
663
678
  const errors = [];
664
679
  if (block.validate !== void 0 && typeof block.validate !== "function")
@@ -739,6 +754,9 @@ function columnExpr(key, field, dialect, table) {
739
754
  case "select":
740
755
  expr = field.multiple ? jsonExpr(col, dialect) : `text('${col}')`;
741
756
  break;
757
+ case "tags":
758
+ expr = jsonExpr(col, dialect);
759
+ break;
742
760
  case "slug":
743
761
  expr = `text('${col}').unique()`;
744
762
  break;
@@ -865,7 +883,7 @@ function renderSchemaFile(config, dialect, resolveImport = (s) => s) {
865
883
  if (pg && fields.some((f) => f.type === "date")) core.add("date");
866
884
  if (pg) core.add("boolean");
867
885
  if (pg && fields.some(
868
- (f) => f.type === "json" || f.type === "blocks" || isTranslatableField(f) && !isTranslatableMediaField(f) || isMultiSelect(f)
886
+ (f) => f.type === "json" || f.type === "blocks" || isTranslatableField(f) && !isTranslatableMediaField(f) || isStringListField(f)
869
887
  ))
870
888
  core.add("jsonb");
871
889
  if (pg) core.add("timestamp");
@@ -1049,6 +1067,7 @@ function fieldTsType(config, entryName, key, field) {
1049
1067
  return isRequiredField(field) && !config[field.to]?.drafts ? target : `${target} | null`;
1050
1068
  }
1051
1069
  if (field.type === "media") return `${mediaTsType(field)} | null`;
1070
+ if (field.type === "tags") return "string[]";
1052
1071
  if (field.type === "select" && field.multiple) {
1053
1072
  const union = selectOptionValues(field).map((o) => JSON.stringify(o)).join(" | ");
1054
1073
  return `(${union})[]`;
@@ -1529,6 +1548,9 @@ const module$1 = defineNuxtModule({
1529
1548
  "nuxt-auth-utils": {},
1530
1549
  "@xleddyl/443-components-vue/nuxt": {
1531
1550
  defaults: {
1551
+ composables: false,
1552
+ css: false,
1553
+ fonts: false,
1532
1554
  editor: true,
1533
1555
  icons: {
1534
1556
  collections: [
@@ -1899,11 +1921,6 @@ const module$1 = defineNuxtModule({
1899
1921
  getContents: () => renderBrandFile(resolveBrand(resolved.admin, brandPaths).brand)
1900
1922
  });
1901
1923
  nuxt.options.alias["#cms-brand"] = brandTemplate.dst;
1902
- nuxt.options.css.push(
1903
- resolveImport("@fontsource-variable/geist/index.css"),
1904
- resolveImport("@fontsource-variable/geist-mono/index.css"),
1905
- resolver.resolve("./runtime/assets/cms.css")
1906
- );
1907
1924
  nuxt.hook("app:templates", (app) => {
1908
1925
  app.layouts["cms-admin"] = {
1909
1926
  name: "cms-admin",
@@ -1915,6 +1932,23 @@ const module$1 = defineNuxtModule({
1915
1932
  };
1916
1933
  });
1917
1934
  addComponentsDir({ path: resolver.resolve("./runtime/app/components") });
1935
+ nuxt.hook("app:resolve", (app) => {
1936
+ app.plugins = app.plugins.filter(
1937
+ (plugin) => !/[\\/]443-components-vue[\\/]dist[\\/]nuxt[\\/]runtime[\\/](theme|locale)(\.mjs)?$/.test(
1938
+ plugin.src
1939
+ )
1940
+ );
1941
+ });
1942
+ nuxt.hook("modules:done", async () => {
1943
+ const include = nuxt.options.vite.optimizeDeps?.include;
1944
+ if (!include) return;
1945
+ const rootUrl = pathToFileURL(`${nuxt.options.rootDir}/`).href;
1946
+ nuxt.options.vite.optimizeDeps.include = await Promise.all(
1947
+ include.map(
1948
+ async (entry) => entry.startsWith("@tiptap/") && !await tryResolveModule(entry, rootUrl) ? `@xleddyl/nuxt-cms > ${entry}` : entry
1949
+ )
1950
+ );
1951
+ });
1918
1952
  addRouteMiddleware({
1919
1953
  name: "cms-auth",
1920
1954
  path: resolver.resolve("./runtime/app/middleware/cms-auth")
@@ -2104,6 +2138,11 @@ const module$1 = defineNuxtModule({
2104
2138
  method: "delete",
2105
2139
  handler: resolver.resolve("./runtime/server/api/settings.delete")
2106
2140
  });
2141
+ addServerHandler({
2142
+ route: "/api/cms/tags/:collection/:field",
2143
+ method: "get",
2144
+ handler: resolver.resolve("./runtime/server/api/tags.get")
2145
+ });
2107
2146
  addServerHandler({
2108
2147
  route: "/api/cms/export/:collection",
2109
2148
  method: "get",
@@ -36,6 +36,15 @@
36
36
  :options="selectItems"
37
37
  :placeholder="field.placeholder ?? 'Select\u2026'"
38
38
  />
39
+ <CmsTagsField
40
+ v-else-if="field.type === 'tags'"
41
+ v-model="model"
42
+ :max="field.max"
43
+ :placeholder="field.placeholder"
44
+ :suggestions="field.suggestions"
45
+ :entry-name="entryName"
46
+ :field-key="fieldKey"
47
+ />
39
48
  <KSelect
40
49
  v-else-if="field.type === 'select'"
41
50
  v-model="singleValue"
@@ -0,0 +1,18 @@
1
+ type __VLS_Props = {
2
+ max?: number;
3
+ placeholder?: string;
4
+ suggestions?: boolean;
5
+ entryName?: string;
6
+ fieldKey?: string;
7
+ };
8
+ type __VLS_ModelProps = {
9
+ modelValue: unknown;
10
+ };
11
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
12
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
13
+ "update:modelValue": (value: unknown) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
15
+ "onUpdate:modelValue"?: ((value: unknown) => any) | undefined;
16
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
17
+ declare const _default: typeof __VLS_export;
18
+ export default _default;
@@ -0,0 +1,72 @@
1
+ <template>
2
+ <KCombobox
3
+ v-model="tags"
4
+ multiple
5
+ open-on-focus
6
+ :search="search"
7
+ :debounce="0"
8
+ :max-selected="max"
9
+ :placeholder="placeholder ?? 'Add a tag\u2026'"
10
+ :empty-text="emptyText"
11
+ @input="onInput"
12
+ />
13
+ </template>
14
+
15
+ <script setup>
16
+ import { canAddTag, normalizeTags, tagKey } from "#nuxt-cms";
17
+ import { Combobox as KCombobox } from "@xleddyl/443-components-vue";
18
+ import { computed, onMounted, ref } from "#imports";
19
+ const SUGGESTION_LIMIT = 50;
20
+ const props = defineProps({
21
+ max: { type: Number, required: false },
22
+ placeholder: { type: String, required: false },
23
+ suggestions: { type: Boolean, required: false },
24
+ entryName: { type: String, required: false },
25
+ fieldKey: { type: String, required: false }
26
+ });
27
+ const model = defineModel({ type: null, ...{ required: true } });
28
+ const known = ref([]);
29
+ const tags = computed({
30
+ get: () => Array.isArray(model.value) ? normalizeTags(model.value) : [],
31
+ set: (value) => {
32
+ const next = normalizeTags(Array.isArray(value) ? value : []);
33
+ model.value = props.max === void 0 ? next : next.slice(0, props.max);
34
+ }
35
+ });
36
+ const full = computed(() => !canAddTag({ max: props.max }, tags.value.length));
37
+ const emptyText = computed(
38
+ () => full.value ? `At most ${props.max} tags` : "Type a tag and press Enter"
39
+ );
40
+ function search(query) {
41
+ if (full.value) return [];
42
+ const taken = new Set(tags.value.map(tagKey));
43
+ const typed = query.trim();
44
+ const needle = tagKey(typed);
45
+ const matches = known.value.filter((tag) => !taken.has(tagKey(tag)) && tagKey(tag).includes(needle)).filter((tag) => tagKey(tag) !== needle).slice(0, SUGGESTION_LIMIT);
46
+ const options = matches.map((tag) => ({ label: tag, value: tag }));
47
+ if (!needle || taken.has(needle)) return options;
48
+ const existing = known.value.find((tag) => tagKey(tag) === needle);
49
+ return [{ label: existing ?? typed, value: existing ?? typed }, ...options];
50
+ }
51
+ function onInput(event) {
52
+ const input = event.target;
53
+ if (!input.value.includes(",")) return;
54
+ const parts = input.value.split(",");
55
+ const rest = parts.pop() ?? "";
56
+ tags.value = [...tags.value, ...parts];
57
+ input.value = rest.trimStart();
58
+ input.dispatchEvent(new Event("input", { bubbles: true }));
59
+ }
60
+ onMounted(async () => {
61
+ if (props.suggestions === false || !props.entryName || !props.fieldKey) return;
62
+ try {
63
+ known.value = await $fetch(
64
+ `/api/cms/tags/${encodeURIComponent(props.entryName)}/${encodeURIComponent(
65
+ props.fieldKey
66
+ )}`
67
+ );
68
+ } catch {
69
+ known.value = [];
70
+ }
71
+ });
72
+ </script>
@@ -0,0 +1,18 @@
1
+ type __VLS_Props = {
2
+ max?: number;
3
+ placeholder?: string;
4
+ suggestions?: boolean;
5
+ entryName?: string;
6
+ fieldKey?: string;
7
+ };
8
+ type __VLS_ModelProps = {
9
+ modelValue: unknown;
10
+ };
11
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
12
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
13
+ "update:modelValue": (value: unknown) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
15
+ "onUpdate:modelValue"?: ((value: unknown) => any) | undefined;
16
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
17
+ declare const _default: typeof __VLS_export;
18
+ export default _default;
@@ -204,6 +204,8 @@
204
204
  :field="fields[key]"
205
205
  :locale="locale"
206
206
  :slug-source="slugSource(fields[key])"
207
+ :entry-name="entryName"
208
+ :field-key="key"
207
209
  @update:model-value="(value) => setValue(key, value)"
208
210
  />
209
211
  </KFormField>
@@ -223,3 +223,7 @@ async function logout() {
223
223
  await navigateTo("/cms/login");
224
224
  }
225
225
  </script>
226
+
227
+ <style src="../../assets/admin.css">
228
+
229
+ </style>
@@ -17,3 +17,7 @@ const theme = useCmsTheme();
17
17
  provideTheme(theme);
18
18
  await loadCmsSettings();
19
19
  </script>
20
+
21
+ <style src="../../assets/admin.css">
22
+
23
+ </style>
@@ -504,6 +504,8 @@ function displayValue(field, key, value) {
504
504
  return formatDateTime(value);
505
505
  case "select":
506
506
  return truncate(selectDisplayValue(field, value));
507
+ case "tags":
508
+ return Array.isArray(value) ? truncate(value.join(", ")) : "";
507
509
  case "blocks":
508
510
  return `${value.length} \u25A4`;
509
511
  case "relation": {
@@ -75,3 +75,7 @@ async function login(values) {
75
75
  }
76
76
  }
77
77
  </script>
78
+
79
+ <style src="../../assets/admin.css">
80
+
81
+ </style>
@@ -13,6 +13,7 @@ const FIELD_ICONS = {
13
13
  email: "at-symbol",
14
14
  slug: "link",
15
15
  select: "chevron-up-down",
16
+ tags: "tag",
16
17
  json: "code-bracket",
17
18
  media: "photo",
18
19
  relation: "arrows-right-left",
@@ -0,0 +1 @@
1
+ @layer theme, base, k, components, utilities;@import "@xleddyl/443-components-vue/style.css";@import "@xleddyl/443-components-vue/editor/style.css";@import "@fontsource-variable/geist/index.css";@import "@fontsource-variable/geist-mono/index.css";@import "./cms.css";
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<string[]>>;
2
+ export default _default;
@@ -0,0 +1,26 @@
1
+ import { createError, defineEventHandler, getRouterParam } from "h3";
2
+ import { useDb } from "#cms-db";
3
+ import { distinctTags, entryFieldsFor } from "../../shared/index.js";
4
+ import { readPages } from "../utils/page-storage.js";
5
+ import { getRegistryEntry, tableColumns } from "../utils/registry.js";
6
+ import { requireAdmin } from "../utils/require-admin.js";
7
+ export default defineEventHandler(async (event) => {
8
+ await requireAdmin(event);
9
+ const { name, entry, table } = getRegistryEntry(event);
10
+ const key = getRouterParam(event, "field") ?? "";
11
+ const fields = entryFieldsFor(entry);
12
+ const field = Object.hasOwn(fields, key) ? fields[key] : void 0;
13
+ if (!field || field.type !== "tags") {
14
+ throw createError({ statusCode: 404, statusMessage: `Unknown tags field: ${key}` });
15
+ }
16
+ if (field.suggestions === false) return [];
17
+ if (entry.kind === "page") {
18
+ const rows2 = await readPages(name, entry, table);
19
+ return distinctTags(rows2.map((row) => row[key]));
20
+ }
21
+ const columns = tableColumns(table);
22
+ if (!Object.hasOwn(columns, key)) return [];
23
+ const column = columns[key];
24
+ const rows = await useDb().select({ value: column }).from(table);
25
+ return distinctTags(rows.map((row) => row.value));
26
+ });
@@ -25,6 +25,7 @@ import {
25
25
  decodeTranslatableMedia,
26
26
  entryFieldsFor,
27
27
  hasTranslatableBlockFields,
28
+ isMultiValueField,
28
29
  isPrivateField,
29
30
  isTranslatableMediaField,
30
31
  localizeBlocks,
@@ -43,6 +44,12 @@ import {
43
44
  import { normalizeTimestampFields } from "../../shared/timestamps.js";
44
45
  import { useMediaIndex } from "./media-index.js";
45
46
  import { readPages } from "./page-storage.js";
47
+ import {
48
+ isListFilterOperator,
49
+ joinListCondition,
50
+ jsonListCondition,
51
+ listFilterValues
52
+ } from "./list-filter.js";
46
53
  import { getContentI18n, resolveTable, tableColumns } from "./registry.js";
47
54
  import { visibilityConditions } from "./visibility.js";
48
55
  const MAX_LIMIT = 100;
@@ -141,11 +148,42 @@ function columnFor(table, key) {
141
148
  const columns = tableColumns(table);
142
149
  return Object.hasOwn(columns, key) ? columns[key] : void 0;
143
150
  }
144
- function filterConditions(table, filters) {
151
+ function joinTableFor(name, key) {
152
+ const join = cmsTables[`${name}_${key}`];
153
+ if (!join) throw new GraphQLError(`No join table for ${name}.${key}`);
154
+ const columns = tableColumns(join);
155
+ return { table: join, sourceId: columns.sourceId, targetId: columns.targetId };
156
+ }
157
+ function listConditions(name, table, key, field, ops) {
158
+ const conditions = [];
159
+ for (const [op, value] of Object.entries(ops)) {
160
+ if (value === void 0 || value === null) continue;
161
+ if (!isListFilterOperator(op)) throw new GraphQLError(`Unknown operator for ${key}: ${op}`);
162
+ if (op !== "isEmpty" && !listFilterValues(value).length)
163
+ throw new GraphQLError(`Empty ${op} list for ${key}`);
164
+ if (field.type === "relation") {
165
+ const id = columnFor(table, "id");
166
+ if (!id) throw new GraphQLError(`Unknown filter field: ${key}`);
167
+ conditions.push(joinListCondition(joinTableFor(name, key), id, op, value));
168
+ continue;
169
+ }
170
+ const column = columnFor(table, key);
171
+ if (!column) throw new GraphQLError(`Unknown filter field: ${key}`);
172
+ conditions.push(jsonListCondition(cmsDialect, column, op, value));
173
+ }
174
+ return conditions;
175
+ }
176
+ function filterConditions(name, entry, table, filters) {
145
177
  if (!filters) return [];
146
178
  const conditions = [];
179
+ const fields = entryFieldsFor(entry);
147
180
  for (const [key, ops] of Object.entries(filters)) {
148
181
  if (!ops) continue;
182
+ const field = Object.hasOwn(fields, key) ? fields[key] : void 0;
183
+ if (field && isMultiValueField(field)) {
184
+ conditions.push(...listConditions(name, table, key, field, ops));
185
+ continue;
186
+ }
149
187
  const column = columnFor(table, key);
150
188
  if (!column) throw new GraphQLError(`Unknown filter field: ${key}`);
151
189
  for (const [op, value] of Object.entries(ops)) {
@@ -353,7 +391,10 @@ export function buildCmsSchema() {
353
391
  queryResolvers[name] = async (_, args) => {
354
392
  const locale = resolveLocaleArg(args.locale);
355
393
  const table = tableFor(name);
356
- const where = [...publishedFilter(entry, table), ...filterConditions(table, args.filters)];
394
+ const where = [
395
+ ...publishedFilter(entry, table),
396
+ ...filterConditions(name, entry, table, args.filters)
397
+ ];
357
398
  const limit = Math.min(MAX_LIMIT, Math.max(0, args.limit ?? DEFAULT_LIMIT));
358
399
  const offset = Math.max(0, args.offset ?? 0);
359
400
  const rows = await useDb().select().from(table).where(where.length ? and(...where) : void 0).orderBy(...sortOrder(entry, table, args.sort)).limit(limit).offset(offset);
@@ -376,7 +417,10 @@ export function buildCmsSchema() {
376
417
  }
377
418
  queryResolvers[`${name}Count`] = async (_, args) => {
378
419
  const table = tableFor(name);
379
- const where = [...publishedFilter(entry, table), ...filterConditions(table, args.filters)];
420
+ const where = [
421
+ ...publishedFilter(entry, table),
422
+ ...filterConditions(name, entry, table, args.filters)
423
+ ];
380
424
  const [counted] = await useDb().select({ total: sql`count(*)` }).from(table).where(where.length ? and(...where) : void 0);
381
425
  return counted?.total ?? 0;
382
426
  };
@@ -0,0 +1,14 @@
1
+ import type { SQL } from 'drizzle-orm';
2
+ import type { AnySQLiteColumn, SQLiteTable } from 'drizzle-orm/sqlite-core';
3
+ export type ListFilterDialect = 'sqlite' | 'postgres';
4
+ export declare const LIST_FILTER_OPERATORS: readonly ["hasAny", "hasAll", "isEmpty"];
5
+ export type ListFilterOperator = (typeof LIST_FILTER_OPERATORS)[number];
6
+ export interface JoinTable {
7
+ table: SQLiteTable;
8
+ sourceId: AnySQLiteColumn;
9
+ targetId: AnySQLiteColumn;
10
+ }
11
+ export declare function isListFilterOperator(op: string): op is ListFilterOperator;
12
+ export declare function listFilterValues(value: unknown): string[];
13
+ export declare function jsonListCondition(dialect: ListFilterDialect, column: AnySQLiteColumn, op: ListFilterOperator, value: unknown): SQL;
14
+ export declare function joinListCondition(join: JoinTable, id: AnySQLiteColumn, op: ListFilterOperator, value: unknown): SQL;
@@ -0,0 +1,47 @@
1
+ import { sql } from "drizzle-orm";
2
+ export const LIST_FILTER_OPERATORS = ["hasAny", "hasAll", "isEmpty"];
3
+ export function isListFilterOperator(op) {
4
+ return LIST_FILTER_OPERATORS.includes(op);
5
+ }
6
+ export function listFilterValues(value) {
7
+ if (!Array.isArray(value)) return [];
8
+ return [...new Set(value.map((item) => String(item)))];
9
+ }
10
+ function valueList(values) {
11
+ return sql`(${sql.join(
12
+ values.map((value) => sql`${value}`),
13
+ sql`, `
14
+ )})`;
15
+ }
16
+ export function jsonListCondition(dialect, column, op, value) {
17
+ if (op === "isEmpty") {
18
+ const length = dialect === "postgres" ? sql`coalesce(jsonb_array_length(${column}), 0)` : sql`coalesce(json_array_length(${column}), 0)`;
19
+ return value ? sql`${length} = 0` : sql`${length} > 0`;
20
+ }
21
+ const values = listFilterValues(value);
22
+ const list = valueList(values);
23
+ if (dialect === "postgres") {
24
+ if (op === "hasAll") return sql`${column} @> ${JSON.stringify(values)}::jsonb`;
25
+ const items2 = sql`jsonb_array_elements_text(${column}) as cms_item(value)`;
26
+ return sql`exists (select 1 from ${items2} where cms_item.value in ${list})`;
27
+ }
28
+ const items = sql`json_each(${column})`;
29
+ if (op === "hasAll") {
30
+ const count = sql`select count(distinct json_each.value) from ${items}`;
31
+ return sql`(${count} where json_each.value in ${list}) = ${sql.raw(String(values.length))}`;
32
+ }
33
+ return sql`exists (select 1 from ${items} where json_each.value in ${list})`;
34
+ }
35
+ export function joinListCondition(join, id, op, value) {
36
+ if (op === "isEmpty") {
37
+ const linked = sql`exists (select 1 from ${join.table} where ${join.sourceId} = ${id})`;
38
+ return value ? sql`not ${linked}` : linked;
39
+ }
40
+ const values = listFilterValues(value);
41
+ const matching = sql`${join.sourceId} = ${id} and ${join.targetId} in ${valueList(values)}`;
42
+ if (op === "hasAll") {
43
+ const count = sql`select count(distinct ${join.targetId}) from ${join.table}`;
44
+ return sql`(${count} where ${matching}) = ${sql.raw(String(values.length))}`;
45
+ }
46
+ return sql`exists (select 1 from ${join.table} where ${matching})`;
47
+ }
@@ -26,7 +26,16 @@ import {
26
26
  stripMarkup
27
27
  } from "../../shared/search.js";
28
28
  import { decodePageRows, selectPages } from "./page-rows.js";
29
- const VALUE_TYPES = /* @__PURE__ */ new Set(["text", "richtext", "email", "slug", "select", "json", "blocks"]);
29
+ const VALUE_TYPES = /* @__PURE__ */ new Set([
30
+ "text",
31
+ "richtext",
32
+ "email",
33
+ "slug",
34
+ "select",
35
+ "tags",
36
+ "json",
37
+ "blocks"
38
+ ]);
30
39
  function isSearchableField(field) {
31
40
  return VALUE_TYPES.has(field.type);
32
41
  }
@@ -57,6 +66,8 @@ function fieldTexts(field, value, defaultLocale) {
57
66
  }).join(", ");
58
67
  return [{ locale: null, text }];
59
68
  }
69
+ if (field.type === "tags" && Array.isArray(value))
70
+ return value.length ? [{ locale: null, text: value.map(String).join(", ") }] : [];
60
71
  if (typeof value === "string") return [{ locale: null, text: plainText(field, value) }];
61
72
  return [{ locale: null, text: JSON.stringify(value) }];
62
73
  }
@@ -1,4 +1,5 @@
1
1
  import type { CmsConfig } from './index.js';
2
2
  import { blockTypeName, blocksFieldTypeName, typeName } from './index.js';
3
3
  export { blockTypeName, blocksFieldTypeName, typeName };
4
+ export declare const LIST_FILTER = "StringListFilter";
4
5
  export declare function renderGraphqlSdl(config: CmsConfig): string;
@@ -3,6 +3,7 @@ import {
3
3
  blockTypeName,
4
4
  blocksFieldTypeName,
5
5
  isCollectionKind,
6
+ isMultiValueField,
6
7
  isPrivateField,
7
8
  isRequiredField,
8
9
  isTranslatableField,
@@ -13,6 +14,7 @@ export { blockTypeName, blocksFieldTypeName, typeName };
13
14
  function entryFields(entry) {
14
15
  return entry.kind === "page" ? pageAllFields(entry) : entry.fields;
15
16
  }
17
+ export const LIST_FILTER = "StringListFilter";
16
18
  function scalarFor(field) {
17
19
  switch (field.type) {
18
20
  case "number":
@@ -27,15 +29,12 @@ function scalarFor(field) {
27
29
  }
28
30
  function filterScalarFor(field) {
29
31
  if (isPrivateField(field) || isTranslatableField(field)) return null;
32
+ if (isMultiValueField(field)) return LIST_FILTER;
30
33
  switch (field.type) {
31
34
  case "json":
32
35
  return null;
33
36
  case "blocks":
34
37
  return null;
35
- case "select":
36
- return field.multiple ? null : "StringFilter";
37
- case "relation":
38
- return field.cardinality === "many-to-many" ? null : "StringFilter";
39
38
  case "number":
40
39
  return field.integer ? "IntFilter" : "FloatFilter";
41
40
  case "boolean":
@@ -61,7 +60,8 @@ function fieldSdl(config, entryName, key, field) {
61
60
  return ` ${key}: ${target}${nonNull ? "!" : ""}`;
62
61
  }
63
62
  if (field.type === "media") return ` ${key}: CmsMedia`;
64
- if (field.type === "select" && field.multiple) return ` ${key}: [String!]!`;
63
+ if (field.type === "tags" || field.type === "select" && field.multiple)
64
+ return ` ${key}: [String!]!`;
65
65
  if (field.type === "blocks")
66
66
  return ` ${key}: [${blocksFieldTypeName(entryName, key)}!]${isRequiredField(field) ? "!" : ""}`;
67
67
  return ` ${key}: ${scalarFor(field)}${isRequiredField(field) ? "!" : ""}`;
@@ -120,7 +120,7 @@ function filterSdl(name, entry) {
120
120
  const gqlType = typeName(name);
121
121
  const keys = filterableKeys(entry);
122
122
  const filters = keys.map(([key, scalar]) => ` ${key}: ${scalar}`).join("\n");
123
- const sortFields = keys.map(([key]) => ` ${key}`).join("\n");
123
+ const sortFields = keys.filter(([, scalar]) => scalar !== LIST_FILTER).map(([key]) => ` ${key}`).join("\n");
124
124
  return [
125
125
  `input ${gqlType}Filters {
126
126
  ${filters}
@@ -141,6 +141,11 @@ const COMMON_SDL = [
141
141
  "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}",
142
142
  "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}",
143
143
  "input BooleanFilter {\n eq: Boolean\n neq: Boolean\n isNull: Boolean\n}",
144
+ `input ${LIST_FILTER} {
145
+ hasAny: [String!]
146
+ hasAll: [String!]
147
+ isEmpty: Boolean
148
+ }`,
144
149
  "enum CmsMediaType {\n image\n video\n file\n}",
145
150
  "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}"
146
151
  ];
@@ -2,7 +2,7 @@ import type { PgTable } from 'drizzle-orm/pg-core';
2
2
  import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
3
3
  import type { CmsLayoutBlock, CmsListInput } from './layout.js';
4
4
  export type CmsTable = SQLiteTable | PgTable;
5
- export type FieldType = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'datetime' | 'email' | 'slug' | 'select' | 'json' | 'media' | 'relation' | 'blocks';
5
+ export type FieldType = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'datetime' | 'email' | 'slug' | 'select' | 'tags' | 'json' | 'media' | 'relation' | 'blocks';
6
6
  export interface CmsI18n {
7
7
  locales: string[];
8
8
  defaultLocale: string;
@@ -141,6 +141,7 @@ export interface FieldConfig {
141
141
  translatable?: boolean;
142
142
  options?: readonly SelectOptionInput[];
143
143
  multiple?: boolean;
144
+ suggestions?: boolean;
144
145
  from?: string;
145
146
  blocks?: Record<string, BlockConfig>;
146
147
  min?: number;
@@ -169,6 +170,12 @@ export declare function translatableMediaKeys(entry: EntryLike): string[];
169
170
  export declare function encodeEntryTranslatableMedia(entry: EntryLike, values: Record<string, unknown>): Record<string, unknown>;
170
171
  export declare function decodeEntryTranslatableMedia<T extends Record<string, unknown>>(entry: EntryLike, rows: T[], defaultLocale: string): T[];
171
172
  export declare function isMultiSelect(field: FieldConfig): boolean;
173
+ export declare function isStringListField(field: FieldConfig): boolean;
174
+ export declare function isMultiValueField(field: FieldConfig): boolean;
175
+ export declare function tagKey(value: string): string;
176
+ export declare function normalizeTags(values: readonly unknown[]): string[];
177
+ export declare function distinctTags(values: readonly unknown[]): string[];
178
+ export declare function canAddTag(field: Pick<FieldConfig, 'max'>, count: number): boolean;
172
179
  export declare function translatableFieldKeys(entry: EntryLike): string[];
173
180
  export declare function translatableBlockFieldKeys(block: BlockConfig): string[];
174
181
  export declare function hasTranslatableBlockFields(field: FieldConfig): boolean;
@@ -303,6 +310,11 @@ export interface SelectFieldInput extends FieldInputBase {
303
310
  options: readonly SelectOptionInput[];
304
311
  multiple?: boolean;
305
312
  }
313
+ export interface TagsFieldInput extends FieldInputBase {
314
+ type: 'tags';
315
+ max?: number;
316
+ suggestions?: boolean;
317
+ }
306
318
  export interface JsonFieldInput extends FieldInputBase {
307
319
  type: 'json';
308
320
  }
@@ -335,7 +347,7 @@ export interface BlocksFieldInput extends FieldInputBase {
335
347
  min?: number;
336
348
  max?: number;
337
349
  }
338
- export type CmsFieldInput = TextFieldInput | RichtextFieldInput | NumberFieldInput | BooleanFieldInput | DateFieldInput | DateTimeFieldInput | EmailFieldInput | SlugFieldInput | SelectFieldInput | JsonFieldInput | MediaFieldInput | RelationFieldInput | BlocksFieldInput;
350
+ export type CmsFieldInput = TextFieldInput | RichtextFieldInput | NumberFieldInput | BooleanFieldInput | DateFieldInput | DateTimeFieldInput | EmailFieldInput | SlugFieldInput | SelectFieldInput | TagsFieldInput | JsonFieldInput | MediaFieldInput | RelationFieldInput | BlocksFieldInput;
339
351
  interface CmsEntryInputBase {
340
352
  id: string;
341
353
  label: string;
@@ -237,6 +237,46 @@ export function decodeEntryTranslatableMedia(entry, rows, defaultLocale) {
237
237
  export function isMultiSelect(field) {
238
238
  return field.type === "select" && !!field.multiple;
239
239
  }
240
+ export function isStringListField(field) {
241
+ return field.type === "tags" || isMultiSelect(field);
242
+ }
243
+ export function isMultiValueField(field) {
244
+ return isStringListField(field) || field.type === "relation" && field.cardinality === "many-to-many";
245
+ }
246
+ export function tagKey(value) {
247
+ return value.trim().toLowerCase();
248
+ }
249
+ export function normalizeTags(values) {
250
+ const seen = /* @__PURE__ */ new Set();
251
+ const tags = [];
252
+ for (const value of values) {
253
+ if (typeof value !== "string") continue;
254
+ const tag = value.trim();
255
+ const key = tagKey(tag);
256
+ if (!key || seen.has(key)) continue;
257
+ seen.add(key);
258
+ tags.push(tag);
259
+ }
260
+ return tags;
261
+ }
262
+ export function distinctTags(values) {
263
+ const lists = values.map((value) => {
264
+ if (Array.isArray(value)) return value;
265
+ if (typeof value !== "string" || !value.startsWith("[")) return [];
266
+ try {
267
+ const parsed = JSON.parse(value);
268
+ return Array.isArray(parsed) ? parsed : [];
269
+ } catch {
270
+ return [];
271
+ }
272
+ });
273
+ return normalizeTags(lists.flat()).sort(
274
+ (a, b) => a.localeCompare(b, void 0, { sensitivity: "base" })
275
+ );
276
+ }
277
+ export function canAddTag(field, count) {
278
+ return field.max === void 0 || count < field.max;
279
+ }
240
280
  export function translatableFieldKeys(entry) {
241
281
  return Object.entries(entryFieldsFor(entry)).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
242
282
  }
@@ -1,4 +1,9 @@
1
- import { isCollectionKind, isMultiSelect, isTranslatableField, selectOptionLabel } from "./index.js";
1
+ import {
2
+ isCollectionKind,
3
+ isStringListField,
4
+ isTranslatableField,
5
+ selectOptionLabel
6
+ } from "./index.js";
2
7
  export const TRANSFER_FORMATS = ["json", "csv"];
3
8
  export const IMPORT_MODES = ["create", "upsert"];
4
9
  export const IMPORT_MAX_BYTES = 5 * 1024 * 1024;
@@ -93,13 +98,13 @@ export function parseCsv(text) {
93
98
  }
94
99
  function csvCellText(field, value) {
95
100
  if (value === null || value === void 0) return "";
96
- if (field?.type === "json" || isMultiSelectField(field)) return JSON.stringify(value);
101
+ if (field?.type === "json" || isStringList(field)) return JSON.stringify(value);
97
102
  if (typeof value === "boolean") return value ? "true" : "false";
98
103
  if (typeof value === "object") return JSON.stringify(value);
99
104
  return String(value);
100
105
  }
101
- function isMultiSelectField(field) {
102
- return !!field && isMultiSelect(field);
106
+ function isStringList(field) {
107
+ return !!field && isStringListField(field);
103
108
  }
104
109
  export function entryRowsToCsv(entry, rows, i18n) {
105
110
  const keys = transferKeys(entry);
@@ -130,7 +135,8 @@ export function csvCellValue(field, text) {
130
135
  if (FALSE_VALUES.includes(lower)) return { value: false };
131
136
  return { error: "Not a boolean (true or false)" };
132
137
  }
133
- if (field.type === "json" || isMultiSelect(field)) {
138
+ if (field.type === "tags" && !text.trim().startsWith("[")) return { value: text.split(",") };
139
+ if (field.type === "json" || isStringListField(field)) {
134
140
  try {
135
141
  return { value: JSON.parse(text) };
136
142
  } catch {
@@ -11,9 +11,11 @@ export interface ValidationMessages {
11
11
  requiredLocale: (locale: string) => string;
12
12
  minBlocks: (min: number) => string;
13
13
  maxBlocks: (max: number) => string;
14
+ maxTags: (max: number) => string;
14
15
  }
15
16
  export declare function datetimeSchema(message: string): z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
16
17
  export declare function blocksCountMessage(field: Pick<FieldConfig, 'min' | 'max'>, count: number, messages?: Partial<ValidationMessages>): string | null;
18
+ export declare function tagsCountMessage(field: Pick<FieldConfig, 'max'>, count: number, messages?: Partial<ValidationMessages>): string | null;
17
19
  export interface EntryValidationIssue {
18
20
  path: (string | number)[];
19
21
  message: string;
@@ -4,6 +4,7 @@ import {
4
4
  declaresHiddenField,
5
5
  isFieldVisible,
6
6
  isTranslatableField,
7
+ normalizeTags,
7
8
  selectOptionValues
8
9
  } from "./index.js";
9
10
  export const objectKeySchema = z.string().min(1).max(1024).refine((k) => !k.includes("..") && !k.startsWith("/"), "Invalid object key");
@@ -16,7 +17,8 @@ const DEFAULT_MESSAGES = {
16
17
  unknownLocale: "Unknown locale key",
17
18
  requiredLocale: (locale) => `Required field (locale '${locale}')`,
18
19
  minBlocks: (min) => `Add at least ${min} ${min === 1 ? "block" : "blocks"}`,
19
- maxBlocks: (max) => `Add at most ${max} ${max === 1 ? "block" : "blocks"}`
20
+ maxBlocks: (max) => `Add at most ${max} ${max === 1 ? "block" : "blocks"}`,
21
+ maxTags: (max) => `Add at most ${max} ${max === 1 ? "tag" : "tags"}`
20
22
  };
21
23
  const DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
22
24
  export function datetimeSchema(message) {
@@ -88,6 +90,21 @@ export function blocksCountMessage(field, count, messages) {
88
90
  if (field.min !== void 0 && count > 0 && count < field.min) return m.minBlocks(field.min);
89
91
  return null;
90
92
  }
93
+ export function tagsCountMessage(field, count, messages) {
94
+ const m = { ...DEFAULT_MESSAGES, ...messages };
95
+ if (field.max !== void 0 && count > field.max) return m.maxTags(field.max);
96
+ return null;
97
+ }
98
+ function countMessage(field, count, m) {
99
+ return field.type === "tags" ? tagsCountMessage(field, count, m) : blocksCountMessage(field, count, m);
100
+ }
101
+ function tagsSchema(field, bounded, m) {
102
+ return z.array(z.string()).nullish().transform((value) => normalizeTags(value ?? [])).superRefine((tags, ctx) => {
103
+ if (field.required && !tags.length) ctx.addIssue({ code: "custom", message: m.required });
104
+ const message = bounded ? tagsCountMessage(field, tags.length, m) : null;
105
+ if (message) ctx.addIssue({ code: "custom", message });
106
+ });
107
+ }
91
108
  function boundedBlocks(list, field, m) {
92
109
  if (field.min === void 0 && field.max === void 0) return list;
93
110
  return list.superRefine((items, ctx) => {
@@ -166,7 +183,7 @@ export function buildEntrySchema(entry, i18n, messages) {
166
183
  for (const [key, declared] of Object.entries(entry.fields)) {
167
184
  const field = declared.showIf ? { ...declared, required: false } : declared;
168
185
  if (declared.showIf && declared.required) conditional.push([key, declared]);
169
- if (declared.showIf && declared.type === "blocks" && (declared.min !== void 0 || declared.max !== void 0))
186
+ if (declared.showIf && (declared.type === "blocks" || declared.type === "tags") && (declared.min !== void 0 || declared.max !== void 0))
170
187
  conditionalBounds.push([key, declared]);
171
188
  if (field.type === "relation" && field.cardinality === "many-to-many") {
172
189
  const list = z.array(z.string().min(1));
@@ -178,6 +195,10 @@ export function buildEntrySchema(entry, i18n, messages) {
178
195
  shape[key] = field.required ? list.min(1, m.required) : list.nullish().transform((v) => v ?? []);
179
196
  continue;
180
197
  }
198
+ if (field.type === "tags") {
199
+ shape[key] = tagsSchema(field, !declared.showIf, m);
200
+ continue;
201
+ }
181
202
  if (field.type === "blocks") {
182
203
  const list = blocksSchema(field, i18n, m);
183
204
  const base = field.required ? list.min(1, m.required) : list;
@@ -212,7 +233,7 @@ export function buildEntrySchema(entry, i18n, messages) {
212
233
  for (const [key, field] of conditionalBounds) {
213
234
  const list = values[key];
214
235
  if (!isFieldVisible(field, values) || !Array.isArray(list)) continue;
215
- const message = blocksCountMessage(field, list.length, m);
236
+ const message = countMessage(field, list.length, m);
216
237
  if (message) ctx.addIssue({ code: "custom", path: [key], message });
217
238
  }
218
239
  if (!hooks) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
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)",