@xleddyl/nuxt-cms 0.2.2 → 0.3.0

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.
Files changed (92) hide show
  1. package/README.md +4 -2
  2. package/dist/module.d.mts +4 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +329 -32
  5. package/dist/runtime/app/blocks/CmsBlocks.d.vue.ts +45 -0
  6. package/dist/runtime/app/blocks/CmsBlocks.vue +36 -0
  7. package/dist/runtime/app/blocks/CmsBlocks.vue.d.ts +45 -0
  8. package/dist/runtime/app/blocks/resolve.d.ts +21 -0
  9. package/dist/runtime/app/blocks/resolve.js +37 -0
  10. package/dist/runtime/app/components/cms/BlockOutline.d.vue.ts +17 -0
  11. package/dist/runtime/app/components/cms/BlockOutline.vue +99 -0
  12. package/dist/runtime/app/components/cms/BlockOutline.vue.d.ts +17 -0
  13. package/dist/runtime/app/components/cms/BlockPalette.d.vue.ts +12 -0
  14. package/dist/runtime/app/components/cms/BlockPalette.vue +29 -0
  15. package/dist/runtime/app/components/cms/BlockPalette.vue.d.ts +12 -0
  16. package/dist/runtime/app/components/cms/BlocksField.d.vue.ts +2 -0
  17. package/dist/runtime/app/components/cms/BlocksField.vue +83 -3
  18. package/dist/runtime/app/components/cms/BlocksField.vue.d.ts +2 -0
  19. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +2 -0
  20. package/dist/runtime/app/components/cms/EntryForm.vue +43 -7
  21. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +2 -0
  22. package/dist/runtime/app/components/cms/FieldInput.d.vue.ts +2 -0
  23. package/dist/runtime/app/components/cms/FieldInput.vue +18 -1
  24. package/dist/runtime/app/components/cms/FieldInput.vue.d.ts +2 -0
  25. package/dist/runtime/app/components/cms/MediaDetail.vue +25 -13
  26. package/dist/runtime/app/components/cms/Search.d.vue.ts +7 -0
  27. package/dist/runtime/app/components/cms/Search.vue +347 -0
  28. package/dist/runtime/app/components/cms/Search.vue.d.ts +7 -0
  29. package/dist/runtime/app/components/cms/SettingsEntry.vue +3 -2
  30. package/dist/runtime/app/components/cms/SettingsModal.vue +2 -1
  31. package/dist/runtime/app/components/cms/StatusBadge.d.vue.ts +2 -0
  32. package/dist/runtime/app/components/cms/StatusBadge.vue +9 -3
  33. package/dist/runtime/app/components/cms/StatusBadge.vue.d.ts +2 -0
  34. package/dist/runtime/app/components/cms/VisualEditor.d.vue.ts +39 -0
  35. package/dist/runtime/app/components/cms/VisualEditor.vue +553 -0
  36. package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +39 -0
  37. package/dist/runtime/app/composables/cms-entry-disabled.d.ts +7 -1
  38. package/dist/runtime/app/composables/cms-entry-disabled.js +15 -0
  39. package/dist/runtime/app/composables/cms-entry.d.ts +7 -1
  40. package/dist/runtime/app/composables/cms-entry.js +51 -1
  41. package/dist/runtime/app/composables/cms-field-focus.d.ts +4 -0
  42. package/dist/runtime/app/composables/cms-field-focus.js +20 -0
  43. package/dist/runtime/app/composables/cms-runtime.d.ts +1 -0
  44. package/dist/runtime/app/composables/cms-settings.js +8 -2
  45. package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
  46. package/dist/runtime/app/layouts/cms-admin.vue +7 -0
  47. package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
  48. package/dist/runtime/app/layouts/cms-editor.d.vue.ts +13 -0
  49. package/dist/runtime/app/layouts/cms-editor.vue +14 -0
  50. package/dist/runtime/app/layouts/cms-editor.vue.d.ts +13 -0
  51. package/dist/runtime/app/pages/admin-collection.vue +36 -4
  52. package/dist/runtime/app/pages/admin-content.d.vue.ts +3 -0
  53. package/dist/runtime/app/pages/admin-content.vue +287 -0
  54. package/dist/runtime/app/pages/admin-content.vue.d.ts +3 -0
  55. package/dist/runtime/app/pages/admin-entry.vue +3 -0
  56. package/dist/runtime/app/pages/admin-preview.d.vue.ts +3 -0
  57. package/dist/runtime/app/pages/admin-preview.vue +132 -0
  58. package/dist/runtime/app/pages/admin-preview.vue.d.ts +3 -0
  59. package/dist/runtime/app/preview/PreviewOverlay.d.vue.ts +15 -0
  60. package/dist/runtime/app/preview/PreviewOverlay.vue +362 -0
  61. package/dist/runtime/app/preview/PreviewOverlay.vue.d.ts +15 -0
  62. package/dist/runtime/app/utils/ui.js +1 -0
  63. package/dist/runtime/assets/main.css +1 -1
  64. package/dist/runtime/server/api/collection.get.js +4 -5
  65. package/dist/runtime/server/api/collection.post.js +4 -1
  66. package/dist/runtime/server/api/item.delete.js +2 -1
  67. package/dist/runtime/server/api/item.put.js +2 -1
  68. package/dist/runtime/server/api/media-download.get.d.ts +2 -0
  69. package/dist/runtime/server/api/media-download.get.js +90 -0
  70. package/dist/runtime/server/api/search.get.d.ts +3 -0
  71. package/dist/runtime/server/api/search.get.js +24 -0
  72. package/dist/runtime/server/utils/graphql.js +23 -8
  73. package/dist/runtime/server/utils/media-download.d.ts +1 -0
  74. package/dist/runtime/server/utils/media-download.js +8 -0
  75. package/dist/runtime/server/utils/media.d.ts +1 -0
  76. package/dist/runtime/server/utils/media.js +18 -3
  77. package/dist/runtime/server/utils/registry.d.ts +1 -0
  78. package/dist/runtime/server/utils/registry.js +6 -0
  79. package/dist/runtime/server/utils/search.d.ts +20 -0
  80. package/dist/runtime/server/utils/search.js +233 -0
  81. package/dist/runtime/server/utils/visibility.d.ts +4 -0
  82. package/dist/runtime/server/utils/visibility.js +12 -0
  83. package/dist/runtime/shared/graphql-sdl.js +4 -1
  84. package/dist/runtime/shared/index.d.ts +59 -5
  85. package/dist/runtime/shared/index.js +133 -0
  86. package/dist/runtime/shared/preview.d.ts +85 -0
  87. package/dist/runtime/shared/preview.js +219 -0
  88. package/dist/runtime/shared/search.d.ts +52 -0
  89. package/dist/runtime/shared/search.js +207 -0
  90. package/dist/runtime/shared/validation.d.ts +2 -0
  91. package/dist/runtime/shared/validation.js +15 -2
  92. package/package.json +2 -2
@@ -20,6 +20,8 @@ import { cmsDialect, useDb } from "#cms-db";
20
20
  import * as cmsTables from "#cms-tables";
21
21
  import { useRuntimeConfig } from "#imports";
22
22
  import {
23
+ CONTENT_SLUG_FIELD,
24
+ CONTENT_PUBLISHED_AT_FIELD,
23
25
  decodeTranslatableMedia,
24
26
  entryFieldsFor,
25
27
  hasTranslatableBlockFields,
@@ -29,6 +31,7 @@ import {
29
31
  mediaPublicUrl,
30
32
  mediaTypeFor,
31
33
  pickTranslatedMedia,
34
+ publicBlocks,
32
35
  translatableFieldKeys
33
36
  } from "../../shared/index.js";
34
37
  import {
@@ -41,6 +44,7 @@ import { normalizeTimestampFields } from "../../shared/timestamps.js";
41
44
  import { useMediaIndex } from "./media-index.js";
42
45
  import { readPages } from "./page-storage.js";
43
46
  import { getContentI18n, resolveTable, tableColumns } from "./registry.js";
47
+ import { visibilityConditions } from "./visibility.js";
44
48
  const MAX_LIMIT = 100;
45
49
  const DEFAULT_LIMIT = 50;
46
50
  const LOCALE = Symbol("locale");
@@ -85,9 +89,7 @@ function tableFor(name) {
85
89
  return table;
86
90
  }
87
91
  function publishedFilter(entry, table) {
88
- if (!entry.drafts) return [];
89
- const status = tableColumns(table).status;
90
- return status ? [eq(status, "published")] : [];
92
+ return visibilityConditions(entry, tableColumns(table));
91
93
  }
92
94
  function localeOf(parent) {
93
95
  return parent[LOCALE] ?? getContentI18n().defaultLocale;
@@ -116,8 +118,9 @@ function localizeRow(entry, row, locale) {
116
118
  result[key] = value?.[locale] ?? value?.[defaultLocale] ?? null;
117
119
  }
118
120
  for (const [key, field] of Object.entries(fields)) {
119
- if (isPrivateField(field) || !hasTranslatableBlockFields(field)) continue;
120
- result[key] = localizeBlocks(field, result[key], locale, defaultLocale);
121
+ if (isPrivateField(field) || field.type !== "blocks") continue;
122
+ const visible = publicBlocks(field, result[key]);
123
+ result[key] = hasTranslatableBlockFields(field) ? localizeBlocks(field, visible, locale, defaultLocale) : visible;
121
124
  }
122
125
  return result;
123
126
  }
@@ -162,7 +165,7 @@ function filterConditions(table, filters) {
162
165
  }
163
166
  return conditions;
164
167
  }
165
- function sortOrder(table, sort) {
168
+ function sortOrder(entry, table, sort) {
166
169
  const order = [];
167
170
  for (const part of sort ?? []) {
168
171
  const column = columnFor(table, part.field);
@@ -170,7 +173,10 @@ function sortOrder(table, sort) {
170
173
  order.push(part.direction === "desc" ? desc(column) : asc(column));
171
174
  }
172
175
  if (!order.length) {
173
- const fallback = tableColumns(table).createdAt ?? tableColumns(table).id;
176
+ const columns = tableColumns(table);
177
+ const published = entry.kind === "content" ? columns[CONTENT_PUBLISHED_AT_FIELD] : void 0;
178
+ if (published) order.push(desc(published));
179
+ const fallback = columns.createdAt ?? columns.id;
174
180
  if (fallback) order.push(desc(fallback));
175
181
  }
176
182
  return order;
@@ -350,7 +356,7 @@ export function buildCmsSchema() {
350
356
  const where = [...publishedFilter(entry, table), ...filterConditions(table, args.filters)];
351
357
  const limit = Math.min(MAX_LIMIT, Math.max(0, args.limit ?? DEFAULT_LIMIT));
352
358
  const offset = Math.max(0, args.offset ?? 0);
353
- const rows = await useDb().select().from(table).where(where.length ? and(...where) : void 0).orderBy(...sortOrder(table, args.sort)).limit(limit).offset(offset);
359
+ const rows = await useDb().select().from(table).where(where.length ? and(...where) : void 0).orderBy(...sortOrder(entry, table, args.sort)).limit(limit).offset(offset);
354
360
  return rows.map((row) => localizeRow(entry, row, locale));
355
361
  };
356
362
  queryResolvers[`${name}ById`] = async (_, args) => {
@@ -359,6 +365,15 @@ export function buildCmsSchema() {
359
365
  const [row] = await useDb().select().from(table).where(and(eq(tableColumns(table).id, args.id), ...publishedFilter(entry, table))).limit(1);
360
366
  return row ? localizeRow(entry, row, locale) : null;
361
367
  };
368
+ if (entry.kind === "content") {
369
+ queryResolvers[`${name}BySlug`] = async (_, args) => {
370
+ const locale = resolveLocaleArg(args.locale);
371
+ const table = tableFor(name);
372
+ const slug = tableColumns(table)[CONTENT_SLUG_FIELD];
373
+ const [row] = await useDb().select().from(table).where(and(eq(slug, args.slug), ...publishedFilter(entry, table))).limit(1);
374
+ return row ? localizeRow(entry, row, locale) : null;
375
+ };
376
+ }
362
377
  queryResolvers[`${name}Count`] = async (_, args) => {
363
378
  const table = tableFor(name);
364
379
  const where = [...publishedFilter(entry, table), ...filterConditions(table, args.filters)];
@@ -0,0 +1 @@
1
+ export declare function attachmentDisposition(filename: string): string;
@@ -0,0 +1,8 @@
1
+ export function attachmentDisposition(filename) {
2
+ const fallback = filename.replace(/[^\x20-\x7e]|["\\%]/g, "_");
3
+ const encoded = encodeURIComponent(filename).replace(
4
+ /['()*]/g,
5
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
6
+ );
7
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
8
+ }
@@ -32,6 +32,7 @@ export declare function useMediaConfig(event: H3Event): {
32
32
  publicUrl: (key: string) => string | null;
33
33
  };
34
34
  export declare function mediaUploadPath(key: string): string;
35
+ export declare function presignedDownloadUrl(media: MediaConfig, key: string, disposition: string): Promise<string>;
35
36
  export declare function useMediaStorage(event: H3Event): {
36
37
  media: MediaConfig;
37
38
  store: MediaStore;
@@ -94,14 +94,29 @@ function filesystemStore(dir) {
94
94
  remove: (key) => removeMediaFile(dir, key)
95
95
  };
96
96
  }
97
- function s3Store(media) {
98
- const client = new AwsClient({
97
+ function s3Client(media) {
98
+ return new AwsClient({
99
99
  accessKeyId: media.accessKeyId,
100
100
  secretAccessKey: media.secretAccessKey,
101
101
  region: media.region,
102
102
  service: "s3"
103
103
  });
104
- const bucketUrl = `${media.endpoint.replace(/\/+$/, "")}/${media.bucket}`;
104
+ }
105
+ function s3BucketUrl(media) {
106
+ return `${media.endpoint.replace(/\/+$/, "")}/${media.bucket}`;
107
+ }
108
+ export async function presignedDownloadUrl(media, key, disposition) {
109
+ const url = new URL(`${s3BucketUrl(media)}/${encodeKey(key)}`);
110
+ url.searchParams.set("X-Amz-Expires", String(media.presignExpiry));
111
+ url.searchParams.set("response-content-disposition", disposition);
112
+ const signed = await s3Client(media).sign(new Request(url, { method: "GET" }), {
113
+ aws: { signQuery: true }
114
+ });
115
+ return signed.url;
116
+ }
117
+ function s3Store(media) {
118
+ const client = s3Client(media);
119
+ const bucketUrl = s3BucketUrl(media);
105
120
  return {
106
121
  async uploadTarget(key, contentType, size) {
107
122
  const url = new URL(`${bucketUrl}/${key}`);
@@ -23,4 +23,5 @@ export declare function buildValidator(entry: CmsEntry, path?: string): z.ZodObj
23
23
  export declare function requirePageRoute(entry: CmsEntry, key: string): CmsPageRoute;
24
24
  export declare function encodeColumnValues(entry: CmsEntry, values: Record<string, unknown>): Record<string, unknown>;
25
25
  export declare function decodeRows<T extends Record<string, unknown>>(entry: CmsEntry, rows: T[]): T[];
26
+ export declare function withContentDefaults(entry: CmsEntry, values: Record<string, unknown>): Record<string, unknown>;
26
27
  export declare function parseId(event: H3Event): string;
@@ -4,6 +4,7 @@ import cmsConfig from "#cms-config";
4
4
  import * as cmsTables from "#cms-tables";
5
5
  import { useRuntimeConfig } from "#imports";
6
6
  import {
7
+ CONTENT_PUBLISHED_AT_FIELD,
7
8
  decodeEntryTranslatableMedia,
8
9
  encodeEntryTranslatableMedia,
9
10
  pageFields,
@@ -69,6 +70,11 @@ export function decodeRows(entry, rows) {
69
70
  for (const row of rows) normalizeTimestampFields(row);
70
71
  return decodeEntryTranslatableMedia(entry, rows, getContentI18n().defaultLocale);
71
72
  }
73
+ export function withContentDefaults(entry, values) {
74
+ if (entry.kind !== "content" || values.status !== "published") return values;
75
+ if (!values[CONTENT_PUBLISHED_AT_FIELD]) values[CONTENT_PUBLISHED_AT_FIELD] = nowTimestamp();
76
+ return values;
77
+ }
72
78
  export function parseId(event) {
73
79
  return z.string().min(1).parse(getRouterParam(event, "id"));
74
80
  }
@@ -0,0 +1,20 @@
1
+ import type { SQL } from 'drizzle-orm';
2
+ import type { AnySQLiteColumn, BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core';
3
+ import type { CmsConfig, CmsI18n } from '../../shared/index.js';
4
+ import type { CmsSearchHit, CmsSearchResponse } from '../../shared/search.js';
5
+ export type SearchDb = Pick<BaseSQLiteDatabase<'sync' | 'async', unknown>, 'select' | 'selectDistinct'>;
6
+ export type SearchDialect = 'sqlite' | 'postgres';
7
+ export interface CmsSearchOptions {
8
+ db: SearchDb;
9
+ dialect: SearchDialect;
10
+ config: CmsConfig;
11
+ tables: Record<string, unknown>;
12
+ i18n: CmsI18n;
13
+ query: string;
14
+ }
15
+ export declare function searchLikeCondition(dialect: SearchDialect, column: AnySQLiteColumn, pattern: string): SQL;
16
+ export declare function searchCmsValues(options: CmsSearchOptions): Promise<{
17
+ hits: CmsSearchHit[];
18
+ truncated: boolean;
19
+ }>;
20
+ export declare function searchCms(options: CmsSearchOptions): Promise<CmsSearchResponse>;
@@ -0,0 +1,233 @@
1
+ import { desc, inArray, or, sql } from "drizzle-orm";
2
+ import {
3
+ decodeTranslatableValue,
4
+ isMultiSelect,
5
+ isTranslatableField,
6
+ mediaFilename,
7
+ pageColumnFields,
8
+ pageFields,
9
+ pageFieldsTableName,
10
+ pageMediaTableName,
11
+ pageRouteOf
12
+ } from "../../shared/index.js";
13
+ import {
14
+ CMS_SEARCH_HITS_PER_ROW,
15
+ CMS_SEARCH_MIN_LENGTH,
16
+ CMS_SEARCH_ROWS_PER_ENTRY,
17
+ CMS_SEARCH_VALUE_LIMIT,
18
+ cmsSearchLink,
19
+ normalizeSearchQuery,
20
+ searchCmsLabels,
21
+ searchFieldPath,
22
+ searchLikePattern,
23
+ searchSnippet,
24
+ stripMarkup
25
+ } from "../../shared/search.js";
26
+ import { decodePageRows, selectPages } from "./page-rows.js";
27
+ const VALUE_TYPES = /* @__PURE__ */ new Set(["text", "richtext", "email", "slug", "select", "json", "blocks"]);
28
+ function isSearchableField(field) {
29
+ return VALUE_TYPES.has(field.type);
30
+ }
31
+ function columnsOf(table) {
32
+ return table;
33
+ }
34
+ function tableOf(context, name) {
35
+ const entry = context.config[name];
36
+ return entry?.table ?? context.tables[name];
37
+ }
38
+ export function searchLikeCondition(dialect, column, pattern) {
39
+ const text = dialect === "postgres" ? sql`cast(${column} as text)` : sql`${column}`;
40
+ return sql`lower(${text}) like ${pattern} escape '\\'`;
41
+ }
42
+ function plainText(field, value) {
43
+ return field.type === "richtext" ? stripMarkup(value) : value;
44
+ }
45
+ function fieldTexts(field, value, defaultLocale) {
46
+ if (value == null || value === "") return [];
47
+ if (isTranslatableField(field)) {
48
+ return Object.entries(decodeTranslatableValue(value, defaultLocale) ?? {}).filter((pair) => typeof pair[1] === "string" && !!pair[1]).map(([locale, text]) => ({ locale, text: plainText(field, text) }));
49
+ }
50
+ if (isMultiSelect(field) && Array.isArray(value)) {
51
+ return [{ locale: null, text: value.map(String).join(", ") }];
52
+ }
53
+ if (typeof value === "string") return [{ locale: null, text: plainText(field, value) }];
54
+ return [{ locale: null, text: JSON.stringify(value) }];
55
+ }
56
+ function rowHits(fields, row, term, defaultLocale) {
57
+ const hits = [];
58
+ const collect = (field, value, hit) => {
59
+ for (const { locale, text } of fieldTexts(field, value, defaultLocale)) {
60
+ if (hits.length >= CMS_SEARCH_HITS_PER_ROW) return;
61
+ const snippet = searchSnippet(text, term);
62
+ if (snippet) hits.push({ ...hit, locale, snippet });
63
+ }
64
+ };
65
+ for (const [key, field] of Object.entries(fields)) {
66
+ if (hits.length >= CMS_SEARCH_HITS_PER_ROW) break;
67
+ if (!isSearchableField(field)) continue;
68
+ const value = row[key];
69
+ if (field.type !== "blocks") {
70
+ collect(field, value, { field: key, block: null, fieldLabel: field.label });
71
+ continue;
72
+ }
73
+ if (!Array.isArray(value)) continue;
74
+ value.forEach((item, index) => {
75
+ const block = item ? field.blocks?.[String(item.type)] : void 0;
76
+ if (!item || !block) return;
77
+ for (const [blockKey, blockField] of Object.entries(block.fields)) {
78
+ if (!isSearchableField(blockField)) continue;
79
+ collect(blockField, item[blockKey], {
80
+ field: key,
81
+ block: index,
82
+ fieldLabel: searchFieldPath(field.label, block.label, blockField.label)
83
+ });
84
+ }
85
+ });
86
+ }
87
+ return hits;
88
+ }
89
+ function titleOf(entry, row, defaultLocale) {
90
+ if (entry.kind === "single" || !entry.titleField) return null;
91
+ const value = row[entry.titleField];
92
+ if (value == null) return null;
93
+ if (typeof value === "object") {
94
+ const values = value;
95
+ const text = [values[defaultLocale], ...Object.values(values)].find(
96
+ (candidate) => typeof candidate === "string" && !!candidate
97
+ );
98
+ return text ?? null;
99
+ }
100
+ return String(value) || null;
101
+ }
102
+ async function tableRows(context, entry, table) {
103
+ const columns = columnsOf(table);
104
+ const conditions = Object.entries(entry.fields).filter(([key, field]) => isSearchableField(field) && Object.hasOwn(columns, key)).map(([key]) => searchLikeCondition(context.dialect, columns[key], context.pattern));
105
+ if (!conditions.length) return [];
106
+ const query = context.db.select().from(table).where(or(...conditions));
107
+ const ordered = columns.updatedAt ? query.orderBy(desc(columns.updatedAt)) : query;
108
+ return await ordered.limit(CMS_SEARCH_ROWS_PER_ENTRY);
109
+ }
110
+ async function pageRows(context, name, entry, table) {
111
+ const fields = tableOf(context, pageFieldsTableName(name));
112
+ const media = tableOf(context, pageMediaTableName(name));
113
+ if (!fields || !media) return [];
114
+ const pageColumns = columnsOf(table);
115
+ const fieldColumns = columnsOf(fields);
116
+ const matched = await context.db.selectDistinct({ id: fieldColumns.pageId }).from(fields).where(searchLikeCondition(context.dialect, fieldColumns.value, context.pattern)).limit(CMS_SEARCH_ROWS_PER_ENTRY);
117
+ const conditions = Object.entries(pageColumnFields(entry)).filter(([key, field]) => isSearchableField(field) && Object.hasOwn(pageColumns, key)).map(([key]) => searchLikeCondition(context.dialect, pageColumns[key], context.pattern));
118
+ if (conditions.length) {
119
+ matched.push(
120
+ ...await context.db.select({ id: pageColumns.id }).from(table).where(or(...conditions)).limit(CMS_SEARCH_ROWS_PER_ENTRY)
121
+ );
122
+ }
123
+ const ids = [...new Set(matched.map((row) => String(row.id)))].slice(
124
+ 0,
125
+ CMS_SEARCH_ROWS_PER_ENTRY
126
+ );
127
+ if (!ids.length) return [];
128
+ const rows = await selectPages(context.db, context.dialect, {
129
+ page: table,
130
+ fields,
131
+ media
132
+ }).where(inArray(pageColumns.id, ids));
133
+ return decodePageRows(entry, rows);
134
+ }
135
+ function itemPath(name, entry, row) {
136
+ if (entry.kind === "single") return `/cms/${name}`;
137
+ if (entry.kind === "page") {
138
+ const route = pageRouteOf(entry, String(row.id));
139
+ return route ? `/cms/${name}/${route.key}` : null;
140
+ }
141
+ return row.id == null ? null : `/cms/${name}/${String(row.id)}`;
142
+ }
143
+ function entryHits(context, name, entry, rows) {
144
+ const { defaultLocale } = context.i18n;
145
+ const hits = [];
146
+ for (const row of rows) {
147
+ const path = itemPath(name, entry, row);
148
+ if (!path) continue;
149
+ const route = entry.kind === "page" ? pageRouteOf(entry, String(row.id)) : void 0;
150
+ const fields = route ? pageFields(entry, route.path) : entry.fields;
151
+ const title = route ? route.label : titleOf(entry, row, defaultLocale);
152
+ for (const hit of rowHits(fields, row, context.term, defaultLocale)) {
153
+ hits.push({
154
+ type: "value",
155
+ collection: name,
156
+ entryLabel: entry.label,
157
+ kind: entry.kind,
158
+ id: entry.kind === "single" ? null : String(row.id),
159
+ title,
160
+ field: hit.field,
161
+ fieldLabel: hit.fieldLabel,
162
+ locale: hit.locale,
163
+ snippet: hit.snippet,
164
+ to: cmsSearchLink(path, {
165
+ field: hit.field,
166
+ locale: hit.locale,
167
+ block: hit.block
168
+ })
169
+ });
170
+ }
171
+ }
172
+ return hits;
173
+ }
174
+ async function mediaHits(context) {
175
+ const table = context.tables.cms_media;
176
+ const columns = table ? columnsOf(table) : void 0;
177
+ if (!table || !columns?.alt || !columns.key || !columns.folder) return [];
178
+ const rows = await context.db.select({ key: columns.key, alt: columns.alt, folder: columns.folder }).from(table).where(searchLikeCondition(context.dialect, columns.alt, context.pattern)).limit(CMS_SEARCH_ROWS_PER_ENTRY);
179
+ return rows.flatMap((row) => {
180
+ const snippet = row.alt ? searchSnippet(row.alt, context.term) : null;
181
+ if (!snippet) return [];
182
+ const folder = row.folder ?? "";
183
+ return [
184
+ {
185
+ type: "media",
186
+ collection: null,
187
+ entryLabel: "Media",
188
+ kind: "media",
189
+ id: row.key,
190
+ title: mediaFilename(row.key),
191
+ field: "alt",
192
+ fieldLabel: "Alt text",
193
+ locale: null,
194
+ snippet,
195
+ to: folder ? `/cms/media?${new URLSearchParams({ folder })}` : "/cms/media"
196
+ }
197
+ ];
198
+ });
199
+ }
200
+ export async function searchCmsValues(options) {
201
+ const term = normalizeSearchQuery(options.query);
202
+ if (term.length < CMS_SEARCH_MIN_LENGTH) return { hits: [], truncated: false };
203
+ const context = {
204
+ ...options,
205
+ term,
206
+ pattern: searchLikePattern(term.toLowerCase())
207
+ };
208
+ const hits = [];
209
+ let truncated = false;
210
+ for (const [name, entry] of Object.entries(options.config)) {
211
+ const table = tableOf(context, name);
212
+ if (!table) continue;
213
+ const rows = entry.kind === "page" ? await pageRows(context, name, entry, table) : await tableRows(context, entry, table);
214
+ if (rows.length >= CMS_SEARCH_ROWS_PER_ENTRY) truncated = true;
215
+ hits.push(...entryHits(context, name, entry, rows));
216
+ }
217
+ const media = await mediaHits(context);
218
+ if (media.length >= CMS_SEARCH_ROWS_PER_ENTRY) truncated = true;
219
+ hits.push(...media);
220
+ if (hits.length > CMS_SEARCH_VALUE_LIMIT) truncated = true;
221
+ return { hits: hits.slice(0, CMS_SEARCH_VALUE_LIMIT), truncated };
222
+ }
223
+ export async function searchCms(options) {
224
+ const query = normalizeSearchQuery(options.query);
225
+ if (query.length < CMS_SEARCH_MIN_LENGTH) return { query, hits: [], truncated: false };
226
+ const labels = searchCmsLabels(options.config, query);
227
+ const values = await searchCmsValues({ ...options, query });
228
+ return {
229
+ query,
230
+ hits: [...labels.hits, ...values.hits],
231
+ truncated: labels.truncated || values.truncated
232
+ };
233
+ }
@@ -0,0 +1,4 @@
1
+ import type { SQL } from 'drizzle-orm';
2
+ import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core';
3
+ import type { CmsEntry } from '../../shared/index.js';
4
+ export declare function visibilityConditions(entry: Pick<CmsEntry, 'kind' | 'drafts'>, columns: Record<string, AnySQLiteColumn>, now?: string): SQL[];
@@ -0,0 +1,12 @@
1
+ import { eq, isNotNull, lte } from "drizzle-orm";
2
+ import { CONTENT_PUBLISHED_AT_FIELD } from "../../shared/index.js";
3
+ import { nowTimestamp } from "../../shared/timestamps.js";
4
+ export function visibilityConditions(entry, columns, now = nowTimestamp()) {
5
+ if (!entry.drafts) return [];
6
+ const conditions = [];
7
+ if (columns.status) conditions.push(eq(columns.status, "published"));
8
+ const publishedAt = columns[CONTENT_PUBLISHED_AT_FIELD];
9
+ if (entry.kind === "content" && publishedAt)
10
+ conditions.push(isNotNull(publishedAt), lte(publishedAt, now));
11
+ return conditions;
12
+ }
@@ -2,6 +2,7 @@ import {
2
2
  PAGE_PATH_FIELD,
3
3
  blockTypeName,
4
4
  blocksFieldTypeName,
5
+ isCollectionKind,
5
6
  isPrivateField,
6
7
  isRequiredField,
7
8
  isTranslatableField,
@@ -72,7 +73,7 @@ function entrySdl(config, name, entry) {
72
73
  if (isPrivateField(field)) continue;
73
74
  lines.push(fieldSdl(config, name, key, field));
74
75
  }
75
- if (entry.kind === "collection") lines.push(" createdAt: String!");
76
+ if (isCollectionKind(entry)) lines.push(" createdAt: String!");
76
77
  lines.push(" updatedAt: String!");
77
78
  return `type ${typeName(name)} {
78
79
  ${lines.join("\n")}
@@ -171,6 +172,8 @@ export function renderGraphqlSdl(config) {
171
172
  );
172
173
  queryLines.push(` ${name}ById(id: ID!, locale: String): ${gqlType}`);
173
174
  queryLines.push(` ${name}Count(filters: ${gqlType}Filters): Int!`);
175
+ if (entry.kind === "content")
176
+ queryLines.push(` ${name}BySlug(slug: String!, locale: String): ${gqlType}`);
174
177
  }
175
178
  return [
176
179
  ...COMMON_SDL,
@@ -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' | 'email' | 'slug' | 'select' | 'json' | 'media' | 'relation' | 'blocks';
5
+ export type FieldType = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'datetime' | 'email' | 'slug' | 'select' | 'json' | 'media' | 'relation' | 'blocks';
6
6
  export interface CmsI18n {
7
7
  locales: string[];
8
8
  defaultLocale: string;
@@ -76,6 +76,9 @@ export interface MediaUsageGroup {
76
76
  export declare function mediaUsageGroups(usage: Record<string, MediaUsage[]>, keys: string[]): MediaUsageGroup[];
77
77
  export interface BlockConfig {
78
78
  label: string;
79
+ component?: string;
80
+ icon?: string;
81
+ description?: string;
79
82
  fields: Record<string, FieldConfig>;
80
83
  }
81
84
  export type ConditionValue = string | number | boolean;
@@ -129,10 +132,14 @@ export declare function isMultiSelect(field: FieldConfig): boolean;
129
132
  export declare function translatableFieldKeys(entry: EntryLike): string[];
130
133
  export declare function translatableBlockFieldKeys(block: BlockConfig): string[];
131
134
  export declare function hasTranslatableBlockFields(field: FieldConfig): boolean;
135
+ export declare const BLOCK_HIDDEN_KEY = "hidden";
136
+ export declare function declaresHiddenField(block: BlockConfig | undefined): boolean;
137
+ export declare function isHiddenBlock(item: unknown): boolean;
138
+ export declare function publicBlocks(field: FieldConfig, value: unknown): unknown;
132
139
  export declare function localizeBlock(field: FieldConfig, item: unknown, locale: string, defaultLocale: string): unknown;
133
140
  export declare function localizeBlocks(field: FieldConfig, value: unknown, locale: string, defaultLocale: string): unknown;
134
141
  export declare const CMS_GRAPHQL_BATCH_LIMIT = 20;
135
- export type CmsEntryKind = 'collection' | 'single' | 'page';
142
+ export type CmsEntryKind = 'collection' | 'single' | 'page' | 'content';
136
143
  export interface CmsPageRoute {
137
144
  path: string;
138
145
  key: string;
@@ -158,10 +165,35 @@ export interface CmsEntry {
158
165
  icon?: string;
159
166
  layout?: CmsLayoutBlock[];
160
167
  list?: CmsListInput;
168
+ blocks?: Record<string, BlockConfig>;
169
+ preview?: CmsPreviewOptions;
170
+ declared?: CmsContentDeclared;
171
+ }
172
+ export interface CmsPreviewOptions {
173
+ component?: string;
174
+ }
175
+ export interface CmsContentDeclared {
176
+ fields: string[];
177
+ titleField?: unknown;
178
+ drafts?: unknown;
161
179
  }
162
180
  export declare const MOBILE_MEDIA_SUFFIX = "Mobile";
163
181
  export declare function mobileMediaKey(key: string): string;
164
182
  export declare function expandMobileMedia<T extends Record<string, unknown>>(config: T): T;
183
+ export declare const CONTENT_TITLE_FIELD = "title";
184
+ export declare const CONTENT_SLUG_FIELD = "slug";
185
+ export declare const CONTENT_PUBLISHED_AT_FIELD = "publishedAt";
186
+ export declare const CONTENT_BODY_FIELD = "body";
187
+ export declare const CONTENT_SYSTEM_FIELDS: readonly ["title", "slug", "publishedAt", "excerpt", "cover", "body", "seoTitle", "seoDescription", "seoImage"];
188
+ export type CmsContentSystemField = (typeof CONTENT_SYSTEM_FIELDS)[number];
189
+ export declare const CONTENT_LIST_COLUMNS: string[];
190
+ export declare function isContentEntry(entry: Pick<CmsEntry, 'kind'>): boolean;
191
+ export declare function isCollectionKind(entry: Pick<CmsEntry, 'kind'>): boolean;
192
+ export declare function normalizeContentEntries<T extends Record<string, unknown>>(config: T, i18n?: Pick<CmsI18n, 'locales'>): T;
193
+ export declare function normalizeCmsConfig<T extends Record<string, unknown>>(config: T, i18n?: Pick<CmsI18n, 'locales'>): T;
194
+ export declare function slugSourceValue(source: FieldConfig | undefined, value: unknown, defaultLocale: string): string | null;
195
+ export declare function isScheduledContent(row: Record<string, unknown> | null | undefined, now?: Date): boolean;
196
+ export declare function resolveBlocksMedia(field: FieldConfig, value: unknown, resolve: (key: string) => unknown): unknown;
165
197
  export declare function entryTabs(entry: Pick<CmsEntry, 'tabs'>): CmsTab[];
166
198
  export declare function fieldTab(field: FieldConfig, tabs: CmsTab[]): string | undefined;
167
199
  export declare function fieldsByTab(fields: Record<string, FieldConfig>, tabs: CmsTab[]): Record<string, Record<string, FieldConfig>>;
@@ -214,6 +246,9 @@ export interface BooleanFieldInput extends FieldInputBase {
214
246
  export interface DateFieldInput extends FieldInputBase {
215
247
  type: 'date';
216
248
  }
249
+ export interface DateTimeFieldInput extends FieldInputBase {
250
+ type: 'datetime';
251
+ }
217
252
  export interface EmailFieldInput extends FieldInputBase {
218
253
  type: 'email';
219
254
  }
@@ -243,16 +278,19 @@ export interface RelationFieldInput extends FieldInputBase {
243
278
  onDelete?: 'set null' | 'cascade' | 'restrict';
244
279
  }
245
280
  type BlockField<T> = Omit<T, 'private' | 'showIf' | 'tab'>;
246
- export type BlockFieldInput = BlockField<TextFieldInput> | BlockField<RichtextFieldInput> | BlockField<NumberFieldInput> | BlockField<BooleanFieldInput> | BlockField<DateFieldInput> | BlockField<EmailFieldInput> | BlockField<SelectFieldInput> | BlockField<JsonFieldInput> | Omit<BlockField<MediaFieldInput>, 'mobile'>;
281
+ export type BlockFieldInput = BlockField<TextFieldInput> | BlockField<RichtextFieldInput> | BlockField<NumberFieldInput> | BlockField<BooleanFieldInput> | BlockField<DateFieldInput> | BlockField<DateTimeFieldInput> | BlockField<EmailFieldInput> | BlockField<SelectFieldInput> | BlockField<JsonFieldInput> | Omit<BlockField<MediaFieldInput>, 'mobile'>;
247
282
  export interface BlockInput {
248
283
  label: string;
284
+ component?: string;
285
+ icon?: string;
286
+ description?: string;
249
287
  fields: Record<string, BlockFieldInput>;
250
288
  }
251
289
  export interface BlocksFieldInput extends FieldInputBase {
252
290
  type: 'blocks';
253
291
  blocks: Record<string, BlockInput>;
254
292
  }
255
- export type CmsFieldInput = TextFieldInput | RichtextFieldInput | NumberFieldInput | BooleanFieldInput | DateFieldInput | EmailFieldInput | SlugFieldInput | SelectFieldInput | JsonFieldInput | MediaFieldInput | RelationFieldInput | BlocksFieldInput;
293
+ export type CmsFieldInput = TextFieldInput | RichtextFieldInput | NumberFieldInput | BooleanFieldInput | DateFieldInput | DateTimeFieldInput | EmailFieldInput | SlugFieldInput | SelectFieldInput | JsonFieldInput | MediaFieldInput | RelationFieldInput | BlocksFieldInput;
256
294
  interface CmsEntryInputBase {
257
295
  id: string;
258
296
  label: string;
@@ -283,8 +321,24 @@ export interface CmsPageInput extends CmsEntryInputBase {
283
321
  overrides?: Record<string, Record<string, CmsFieldInput | null>>;
284
322
  columns?: string[];
285
323
  }
286
- export type CmsEntryInput = CmsCollectionInput | CmsSingleInput | CmsPageInput;
324
+ export interface CmsContentInput {
325
+ id: string;
326
+ label: string;
327
+ kind: 'content';
328
+ icon?: string;
329
+ blocks: Record<string, BlockInput>;
330
+ fields?: Record<string, CmsFieldInput>;
331
+ labels?: Partial<Record<CmsContentSystemField, string>>;
332
+ tabs?: CmsTab[];
333
+ layout?: CmsLayoutBlock[];
334
+ list?: CmsListInput;
335
+ preview?: CmsPreviewOptions;
336
+ titleField?: never;
337
+ drafts?: never;
338
+ }
339
+ export type CmsEntryInput = CmsCollectionInput | CmsSingleInput | CmsPageInput | CmsContentInput;
287
340
  export type CmsConfigInput = Record<string, CmsEntryInput>;
288
341
  export declare function defineCmsConfig<T extends CmsConfigInput>(config: T): T;
342
+ export declare function defineCmsBlocks<T extends Record<string, BlockInput>>(blocks: T): T;
289
343
  export * from './layout.js';
290
344
  export * from './users.js';