@xleddyl/nuxt-cms 0.2.1 → 0.2.3

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 (110) hide show
  1. package/README.md +3 -1
  2. package/dist/module.d.mts +4 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +320 -33
  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/BrandMark.vue +2 -3
  20. package/dist/runtime/app/components/cms/ConfirmModal.vue +14 -1
  21. package/dist/runtime/app/components/cms/EntryForm.vue +9 -4
  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/MediaGallery.vue +89 -11
  26. package/dist/runtime/app/components/cms/Modal.vue +0 -2
  27. package/dist/runtime/app/components/cms/SettingsEntry.vue +3 -2
  28. package/dist/runtime/app/components/cms/SettingsModal.vue +2 -1
  29. package/dist/runtime/app/components/cms/StatusBadge.d.vue.ts +2 -0
  30. package/dist/runtime/app/components/cms/StatusBadge.vue +9 -3
  31. package/dist/runtime/app/components/cms/StatusBadge.vue.d.ts +2 -0
  32. package/dist/runtime/app/components/cms/VisualEditor.d.vue.ts +37 -0
  33. package/dist/runtime/app/components/cms/VisualEditor.vue +533 -0
  34. package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +37 -0
  35. package/dist/runtime/app/composables/cms-confirm.d.ts +16 -7
  36. package/dist/runtime/app/composables/cms-confirm.js +1 -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-media-library.d.ts +1 -2
  42. package/dist/runtime/app/composables/cms-media-library.js +12 -19
  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.vue +5 -0
  46. package/dist/runtime/app/layouts/cms-editor.d.vue.ts +13 -0
  47. package/dist/runtime/app/layouts/cms-editor.vue +14 -0
  48. package/dist/runtime/app/layouts/cms-editor.vue.d.ts +13 -0
  49. package/dist/runtime/app/pages/admin-collection.vue +33 -4
  50. package/dist/runtime/app/pages/admin-content.d.vue.ts +3 -0
  51. package/dist/runtime/app/pages/admin-content.vue +284 -0
  52. package/dist/runtime/app/pages/admin-content.vue.d.ts +3 -0
  53. package/dist/runtime/app/pages/admin-preview.d.vue.ts +3 -0
  54. package/dist/runtime/app/pages/admin-preview.vue +132 -0
  55. package/dist/runtime/app/pages/admin-preview.vue.d.ts +3 -0
  56. package/dist/runtime/app/preview/PreviewOverlay.d.vue.ts +15 -0
  57. package/dist/runtime/app/preview/PreviewOverlay.vue +362 -0
  58. package/dist/runtime/app/preview/PreviewOverlay.vue.d.ts +15 -0
  59. package/dist/runtime/app/utils/ui.d.ts +0 -2
  60. package/dist/runtime/app/utils/ui.js +1 -0
  61. package/dist/runtime/assets/main.css +1 -1
  62. package/dist/runtime/seed.js +11 -1
  63. package/dist/runtime/server/api/collection.get.js +3 -2
  64. package/dist/runtime/server/api/collection.post.js +24 -15
  65. package/dist/runtime/server/api/collection.put.js +12 -12
  66. package/dist/runtime/server/api/item.delete.js +2 -1
  67. package/dist/runtime/server/api/item.put.js +16 -14
  68. package/dist/runtime/server/api/media-folder.patch.js +10 -16
  69. package/dist/runtime/server/api/media-move.post.js +12 -8
  70. package/dist/runtime/server/api/media.delete.js +1 -1
  71. package/dist/runtime/server/api/media.post.js +2 -1
  72. package/dist/runtime/server/api/media.put.js +2 -1
  73. package/dist/runtime/server/plugins/migrate-sqlite.js +2 -2
  74. package/dist/runtime/server/routes/auth/login.post.js +28 -22
  75. package/dist/runtime/server/utils/db-d1.d.ts +0 -1
  76. package/dist/runtime/server/utils/db-d1.js +3 -4
  77. package/dist/runtime/server/utils/db-libsql.d.ts +0 -1
  78. package/dist/runtime/server/utils/db-libsql.js +3 -4
  79. package/dist/runtime/server/utils/db-postgres.d.ts +0 -1
  80. package/dist/runtime/server/utils/db-postgres.js +1 -4
  81. package/dist/runtime/server/utils/db-sqlite.d.ts +0 -1
  82. package/dist/runtime/server/utils/db-sqlite.js +4 -10
  83. package/dist/runtime/server/utils/graphql.js +25 -9
  84. package/dist/runtime/server/utils/libsql-migrations.d.ts +1 -0
  85. package/dist/runtime/server/utils/libsql-migrations.js +1 -1
  86. package/dist/runtime/server/utils/media-folders.js +14 -23
  87. package/dist/runtime/server/utils/media.js +2 -0
  88. package/dist/runtime/server/utils/migrate.d.ts +1 -0
  89. package/dist/runtime/server/utils/migrate.js +32 -0
  90. package/dist/runtime/server/utils/registry.d.ts +4 -0
  91. package/dist/runtime/server/utils/registry.js +16 -1
  92. package/dist/runtime/server/utils/relations.d.ts +1 -1
  93. package/dist/runtime/server/utils/relations.js +6 -9
  94. package/dist/runtime/server/utils/timestamps.d.ts +12 -0
  95. package/dist/runtime/server/utils/timestamps.js +48 -0
  96. package/dist/runtime/server/utils/users.js +3 -2
  97. package/dist/runtime/server/utils/visibility.d.ts +4 -0
  98. package/dist/runtime/server/utils/visibility.js +12 -0
  99. package/dist/runtime/server/utils/write-statements.d.ts +33 -0
  100. package/dist/runtime/server/utils/write-statements.js +75 -0
  101. package/dist/runtime/shared/graphql-sdl.js +4 -1
  102. package/dist/runtime/shared/index.d.ts +73 -5
  103. package/dist/runtime/shared/index.js +146 -0
  104. package/dist/runtime/shared/preview.d.ts +85 -0
  105. package/dist/runtime/shared/preview.js +219 -0
  106. package/dist/runtime/shared/timestamps.d.ts +4 -0
  107. package/dist/runtime/shared/timestamps.js +24 -0
  108. package/dist/runtime/shared/validation.d.ts +2 -0
  109. package/dist/runtime/shared/validation.js +15 -2
  110. package/package.json +1 -1
@@ -4,11 +4,13 @@ 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,
10
11
  pageRouteOf
11
12
  } from "../../shared/index.js";
13
+ import { normalizeTimestampFields, nowTimestamp } from "../../shared/timestamps.js";
12
14
  import { buildEntrySchema } from "../../shared/validation.js";
13
15
  let contentI18n;
14
16
  export function getContentI18n() {
@@ -30,9 +32,16 @@ export function idColumn(table) {
30
32
  }
31
33
  export function withUpdatedAt(table, values) {
32
34
  const set = { ...values };
33
- if ("updatedAt" in table) set.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
35
+ if ("updatedAt" in table) set.updatedAt = nowTimestamp();
34
36
  return set;
35
37
  }
38
+ export function withTimestamps(table, values) {
39
+ const now = nowTimestamp();
40
+ const stamped = { ...values };
41
+ if ("createdAt" in table) stamped.createdAt ??= now;
42
+ if ("updatedAt" in table) stamped.updatedAt ??= now;
43
+ return stamped;
44
+ }
36
45
  export function getRegistryEntry(event) {
37
46
  const name = getRouterParam(event, "collection");
38
47
  const config = cmsConfig;
@@ -58,8 +67,14 @@ export function encodeColumnValues(entry, values) {
58
67
  return encodeEntryTranslatableMedia(entry, values);
59
68
  }
60
69
  export function decodeRows(entry, rows) {
70
+ for (const row of rows) normalizeTimestampFields(row);
61
71
  return decodeEntryTranslatableMedia(entry, rows, getContentI18n().defaultLocale);
62
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
+ }
63
78
  export function parseId(event) {
64
79
  return z.string().min(1).parse(getRouterParam(event, "id"));
65
80
  }
@@ -8,7 +8,7 @@ export declare function splitRelationValues(entry: CmsEntry, body: Row): {
8
8
  lists: Record<string, string[]>;
9
9
  };
10
10
  export declare function assertRelationTargets(entry: CmsEntry, lists: Record<string, string[]>): Promise<void>;
11
- export declare function saveManyToMany(db: CmsDb, name: string, sourceId: string, lists: Record<string, string[]>): Promise<void>;
11
+ export declare function manyToManyStatements(db: CmsDb, name: string, sourceId: string, lists: Record<string, string[]>): PromiseLike<unknown>[];
12
12
  export declare function relationTitles(db: CmsDb, entry: CmsEntry, rows: Row[]): Promise<Record<string, Record<string, unknown>>>;
13
13
  export declare function attachManyToMany<T extends Row>(db: CmsDb, name: string, entry: CmsEntry, rows: T[]): Promise<T[]>;
14
14
  export {};
@@ -1,10 +1,11 @@
1
- import { asc, eq, inArray } from "drizzle-orm";
1
+ import { asc, inArray } from "drizzle-orm";
2
2
  import { createError } from "h3";
3
3
  import cmsConfig from "#cms-config";
4
4
  import { useDb } from "#cms-db";
5
5
  import * as cmsTables from "#cms-tables";
6
6
  import { entryFieldsFor } from "../../shared/index.js";
7
7
  import { resolveTable, tableColumns } from "./registry.js";
8
+ import { joinWriteStatements } from "./write-statements.js";
8
9
  function manyToManyKeys(entry) {
9
10
  return Object.entries(entryFieldsFor(entry)).filter(([, field]) => field.type === "relation" && field.cardinality === "many-to-many").map(([key]) => key);
10
11
  }
@@ -50,14 +51,10 @@ export async function assertRelationTargets(entry, lists) {
50
51
  }
51
52
  }
52
53
  }
53
- export async function saveManyToMany(db, name, sourceId, lists) {
54
- for (const [key, ids] of Object.entries(lists)) {
55
- const join = joinTable(name, key);
56
- await db.delete(join.table).where(eq(join.sourceId, sourceId));
57
- if (ids.length) {
58
- await db.insert(join.table).values(ids.map((targetId, position) => ({ sourceId, targetId, position })));
59
- }
60
- }
54
+ export function manyToManyStatements(db, name, sourceId, lists) {
55
+ return Object.entries(lists).flatMap(
56
+ ([key, ids]) => joinWriteStatements(db, joinTable(name, key), sourceId, ids)
57
+ );
61
58
  }
62
59
  export async function relationTitles(db, entry, rows) {
63
60
  const config = cmsConfig;
@@ -0,0 +1,12 @@
1
+ export interface TimestampColumn {
2
+ table: string;
3
+ column: string;
4
+ }
5
+ export interface TimestampBackfillDriver {
6
+ select: (query: string) => Promise<unknown[]>;
7
+ write: (statements: string[]) => Promise<unknown>;
8
+ }
9
+ export declare function timestampColumns(tables: Record<string, unknown>): TimestampColumn[];
10
+ export declare function timestampProbeQuery(columns: TimestampColumn[]): string;
11
+ export declare function timestampBackfillStatement({ table, column }: TimestampColumn): string;
12
+ export declare function backfillTimestamps(tables: Record<string, unknown>, driver: TimestampBackfillDriver): Promise<number>;
@@ -0,0 +1,48 @@
1
+ import { is } from "drizzle-orm";
2
+ import { SQLiteTable, getTableConfig } from "drizzle-orm/sqlite-core";
3
+ import { SQLITE_ISO_FORMAT } from "../../shared/timestamps.js";
4
+ const TIMESTAMP_COLUMNS = /* @__PURE__ */ new Set(["created_at", "updated_at"]);
5
+ function quoteIdentifier(name) {
6
+ return `"${name.replaceAll('"', '""')}"`;
7
+ }
8
+ function legacyCondition(column) {
9
+ const target = quoteIdentifier(column);
10
+ return `${target} NOT LIKE '%T%' AND strftime('${SQLITE_ISO_FORMAT}', ${target}) IS NOT NULL`;
11
+ }
12
+ export function timestampColumns(tables) {
13
+ const found = [];
14
+ for (const table of Object.values(tables)) {
15
+ if (!is(table, SQLiteTable)) continue;
16
+ const { name, columns } = getTableConfig(table);
17
+ for (const column of columns) {
18
+ if (TIMESTAMP_COLUMNS.has(column.name)) found.push({ table: name, column: column.name });
19
+ }
20
+ }
21
+ return found;
22
+ }
23
+ export function timestampProbeQuery(columns) {
24
+ return columns.map(
25
+ ({ table, column }, index) => `SELECT ${index} AS target WHERE EXISTS (SELECT 1 FROM ${quoteIdentifier(
26
+ table
27
+ )} WHERE ${legacyCondition(column)})`
28
+ ).join(" UNION ALL ");
29
+ }
30
+ export function timestampBackfillStatement({ table, column }) {
31
+ const target = quoteIdentifier(column);
32
+ return `UPDATE ${quoteIdentifier(
33
+ table
34
+ )} SET ${target} = strftime('${SQLITE_ISO_FORMAT}', ${target}) WHERE ${legacyCondition(column)}`;
35
+ }
36
+ function targetIndex(row) {
37
+ if (Array.isArray(row)) return Number(row[0]);
38
+ return Number(row.target);
39
+ }
40
+ export async function backfillTimestamps(tables, driver) {
41
+ const columns = timestampColumns(tables);
42
+ if (!columns.length) return 0;
43
+ const rows = await driver.select(timestampProbeQuery(columns));
44
+ const stale = rows.map((row) => columns[targetIndex(row)]).filter((column) => !!column);
45
+ if (!stale.length) return 0;
46
+ await driver.write(stale.map(timestampBackfillStatement));
47
+ return stale.length;
48
+ }
@@ -1,6 +1,7 @@
1
1
  import { eq } from "drizzle-orm";
2
2
  import { useDb } from "#cms-db";
3
3
  import { cms_users } from "#cms-tables";
4
+ import { normalizeTimestamp } from "../../shared/timestamps.js";
4
5
  export async function findUserByEmail(email) {
5
6
  const [row] = await useDb().select().from(cms_users).where(eq(cms_users.email, email.toLowerCase()));
6
7
  return row;
@@ -28,7 +29,7 @@ export function toAccount(row) {
28
29
  email: row.email,
29
30
  name: row.name ?? null,
30
31
  role: row.role,
31
- lastLoginAt: row.lastLoginAt ?? null,
32
- createdAt: String(row.createdAt)
32
+ lastLoginAt: normalizeTimestamp(row.lastLoginAt ?? null),
33
+ createdAt: String(normalizeTimestamp(row.createdAt))
33
34
  };
34
35
  }
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ import type { AnySQLiteColumn, BaseSQLiteDatabase, SQLiteTable } from 'drizzle-orm/sqlite-core';
2
+ export type WriteDb = Pick<BaseSQLiteDatabase<'sync' | 'async', unknown>, 'insert' | 'update' | 'delete'>;
3
+ export interface JoinTable {
4
+ table: SQLiteTable;
5
+ sourceId: AnySQLiteColumn;
6
+ targetId: AnySQLiteColumn;
7
+ position: AnySQLiteColumn;
8
+ }
9
+ export interface RebasedMediaRow {
10
+ key: string;
11
+ folder: string | null;
12
+ target: string | null;
13
+ }
14
+ export interface MediaFolderRename {
15
+ folders: [string, string | null][];
16
+ markers: string[];
17
+ staleMarkers: string[];
18
+ }
19
+ export declare const KEY_CHUNK = 90;
20
+ export declare function chunks<T>(items: T[], size: number): T[][];
21
+ export declare function joinWriteStatements(db: WriteDb, join: JoinTable, sourceId: string, ids: string[]): PromiseLike<unknown>[];
22
+ export declare function mediaFolderMarkerValues(folder: string): {
23
+ key: string;
24
+ mime: string;
25
+ size: number;
26
+ width: null;
27
+ height: null;
28
+ alt: null;
29
+ folder: string;
30
+ };
31
+ export declare function planMediaFolderRename(rows: RebasedMediaRow[]): MediaFolderRename;
32
+ export declare function mediaFolderRenameStatements(db: WriteDb, table: SQLiteTable, plan: MediaFolderRename): PromiseLike<unknown>[];
33
+ export declare function mediaDeleteStatements(db: WriteDb, table: SQLiteTable, keys: string[]): PromiseLike<unknown>[];
@@ -0,0 +1,75 @@
1
+ import { eq, getTableColumns, inArray } from "drizzle-orm";
2
+ import { isMediaFolderMarker, mediaFolderDepth, mediaFolderMarkerKey } from "../../shared/index.js";
3
+ import { nowTimestamp } from "../../shared/timestamps.js";
4
+ export const KEY_CHUNK = 90;
5
+ const JOIN_INSERT_CHUNK = 30;
6
+ export function chunks(items, size) {
7
+ const out = [];
8
+ for (let index = 0; index < items.length; index += size) {
9
+ out.push(items.slice(index, index + size));
10
+ }
11
+ return out;
12
+ }
13
+ export function joinWriteStatements(db, join, sourceId, ids) {
14
+ const statements = [
15
+ db.delete(join.table).where(eq(join.sourceId, sourceId))
16
+ ];
17
+ const rows = ids.map((targetId, position) => ({ sourceId, targetId, position }));
18
+ for (const chunk of chunks(rows, JOIN_INSERT_CHUNK)) {
19
+ statements.push(db.insert(join.table).values(chunk));
20
+ }
21
+ return statements;
22
+ }
23
+ export function mediaFolderMarkerValues(folder) {
24
+ return {
25
+ key: mediaFolderMarkerKey(folder),
26
+ mime: "application/x-empty",
27
+ size: 0,
28
+ width: null,
29
+ height: null,
30
+ alt: null,
31
+ folder
32
+ };
33
+ }
34
+ export function planMediaFolderRename(rows) {
35
+ const folders = /* @__PURE__ */ new Map();
36
+ const markers = /* @__PURE__ */ new Set();
37
+ for (const row of rows) {
38
+ if (!isMediaFolderMarker(row.key)) {
39
+ if (row.folder) folders.set(row.folder, row.target);
40
+ } else if (row.target) {
41
+ markers.add(row.target);
42
+ }
43
+ }
44
+ const markerKeys = new Set([...markers].map(mediaFolderMarkerKey));
45
+ return {
46
+ folders: [...folders].sort(([a], [b]) => mediaFolderDepth(a) - mediaFolderDepth(b)),
47
+ markers: [...markers],
48
+ staleMarkers: rows.filter((row) => isMediaFolderMarker(row.key) && !markerKeys.has(row.key)).map((row) => row.key)
49
+ };
50
+ }
51
+ function mediaColumns(table) {
52
+ const columns = getTableColumns(table);
53
+ return { key: columns.key, folder: columns.folder };
54
+ }
55
+ export function mediaFolderRenameStatements(db, table, plan) {
56
+ const columns = mediaColumns(table);
57
+ const statements = [];
58
+ for (const [folder, target] of plan.folders) {
59
+ statements.push(db.update(table).set({ folder: target }).where(eq(columns.folder, folder)));
60
+ }
61
+ for (const folder of plan.markers) {
62
+ const values = mediaFolderMarkerValues(folder);
63
+ statements.push(
64
+ db.insert(table).values({ ...values, createdAt: nowTimestamp() }).onConflictDoUpdate({ target: columns.key, set: values })
65
+ );
66
+ }
67
+ for (const keys of chunks(plan.staleMarkers, KEY_CHUNK)) {
68
+ statements.push(db.delete(table).where(inArray(columns.key, keys)));
69
+ }
70
+ return statements;
71
+ }
72
+ export function mediaDeleteStatements(db, table, keys) {
73
+ const { key } = mediaColumns(table);
74
+ return chunks(keys, KEY_CHUNK).map((chunk) => db.delete(table).where(inArray(key, chunk)));
75
+ }
@@ -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;
@@ -60,8 +60,25 @@ export interface MediaUsage {
60
60
  title: string | null;
61
61
  field: string;
62
62
  }
63
+ export interface MediaUsageEntry {
64
+ collection: string;
65
+ label: string;
66
+ kind: CmsEntryKind;
67
+ id: string | null;
68
+ title: string | null;
69
+ fields: string[];
70
+ }
71
+ export declare function groupMediaUsage(usage: MediaUsage[]): MediaUsageEntry[];
72
+ export interface MediaUsageGroup {
73
+ key: string;
74
+ entries: MediaUsageEntry[];
75
+ }
76
+ export declare function mediaUsageGroups(usage: Record<string, MediaUsage[]>, keys: string[]): MediaUsageGroup[];
63
77
  export interface BlockConfig {
64
78
  label: string;
79
+ component?: string;
80
+ icon?: string;
81
+ description?: string;
65
82
  fields: Record<string, FieldConfig>;
66
83
  }
67
84
  export type ConditionValue = string | number | boolean;
@@ -115,10 +132,14 @@ export declare function isMultiSelect(field: FieldConfig): boolean;
115
132
  export declare function translatableFieldKeys(entry: EntryLike): string[];
116
133
  export declare function translatableBlockFieldKeys(block: BlockConfig): string[];
117
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;
118
139
  export declare function localizeBlock(field: FieldConfig, item: unknown, locale: string, defaultLocale: string): unknown;
119
140
  export declare function localizeBlocks(field: FieldConfig, value: unknown, locale: string, defaultLocale: string): unknown;
120
141
  export declare const CMS_GRAPHQL_BATCH_LIMIT = 20;
121
- export type CmsEntryKind = 'collection' | 'single' | 'page';
142
+ export type CmsEntryKind = 'collection' | 'single' | 'page' | 'content';
122
143
  export interface CmsPageRoute {
123
144
  path: string;
124
145
  key: string;
@@ -144,10 +165,35 @@ export interface CmsEntry {
144
165
  icon?: string;
145
166
  layout?: CmsLayoutBlock[];
146
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;
147
179
  }
148
180
  export declare const MOBILE_MEDIA_SUFFIX = "Mobile";
149
181
  export declare function mobileMediaKey(key: string): string;
150
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;
151
197
  export declare function entryTabs(entry: Pick<CmsEntry, 'tabs'>): CmsTab[];
152
198
  export declare function fieldTab(field: FieldConfig, tabs: CmsTab[]): string | undefined;
153
199
  export declare function fieldsByTab(fields: Record<string, FieldConfig>, tabs: CmsTab[]): Record<string, Record<string, FieldConfig>>;
@@ -200,6 +246,9 @@ export interface BooleanFieldInput extends FieldInputBase {
200
246
  export interface DateFieldInput extends FieldInputBase {
201
247
  type: 'date';
202
248
  }
249
+ export interface DateTimeFieldInput extends FieldInputBase {
250
+ type: 'datetime';
251
+ }
203
252
  export interface EmailFieldInput extends FieldInputBase {
204
253
  type: 'email';
205
254
  }
@@ -229,16 +278,19 @@ export interface RelationFieldInput extends FieldInputBase {
229
278
  onDelete?: 'set null' | 'cascade' | 'restrict';
230
279
  }
231
280
  type BlockField<T> = Omit<T, 'private' | 'showIf' | 'tab'>;
232
- 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'>;
233
282
  export interface BlockInput {
234
283
  label: string;
284
+ component?: string;
285
+ icon?: string;
286
+ description?: string;
235
287
  fields: Record<string, BlockFieldInput>;
236
288
  }
237
289
  export interface BlocksFieldInput extends FieldInputBase {
238
290
  type: 'blocks';
239
291
  blocks: Record<string, BlockInput>;
240
292
  }
241
- 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;
242
294
  interface CmsEntryInputBase {
243
295
  id: string;
244
296
  label: string;
@@ -269,8 +321,24 @@ export interface CmsPageInput extends CmsEntryInputBase {
269
321
  overrides?: Record<string, Record<string, CmsFieldInput | null>>;
270
322
  columns?: string[];
271
323
  }
272
- 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;
273
340
  export type CmsConfigInput = Record<string, CmsEntryInput>;
274
341
  export declare function defineCmsConfig<T extends CmsConfigInput>(config: T): T;
342
+ export declare function defineCmsBlocks<T extends Record<string, BlockInput>>(blocks: T): T;
275
343
  export * from './layout.js';
276
344
  export * from './users.js';
@@ -110,6 +110,19 @@ export function expandMediaFolders(folders) {
110
110
  }
111
111
  return [...expanded].sort();
112
112
  }
113
+ export function groupMediaUsage(usage) {
114
+ const entries = /* @__PURE__ */ new Map();
115
+ for (const { field, ...rest } of usage) {
116
+ const id = `${rest.collection}\0${rest.id ?? ""}`;
117
+ const entry = entries.get(id);
118
+ if (!entry) entries.set(id, { ...rest, fields: [field] });
119
+ else if (!entry.fields.includes(field)) entry.fields.push(field);
120
+ }
121
+ return [...entries.values()];
122
+ }
123
+ export function mediaUsageGroups(usage, keys) {
124
+ return [...new Set(keys)].map((key) => ({ key, entries: groupMediaUsage(usage[key] ?? []) })).filter((group) => group.entries.length > 0);
125
+ }
113
126
  export function isPrivateField(field) {
114
127
  return !!field.private;
115
128
  }
@@ -206,6 +219,20 @@ export function hasTranslatableBlockFields(field) {
206
219
  (block) => Object.values(block.fields).some(isTranslatableField)
207
220
  );
208
221
  }
222
+ export const BLOCK_HIDDEN_KEY = "hidden";
223
+ export function declaresHiddenField(block) {
224
+ return !!block && Object.hasOwn(block.fields, BLOCK_HIDDEN_KEY);
225
+ }
226
+ export function isHiddenBlock(item) {
227
+ return !!item && typeof item === "object" && item.hidden === true;
228
+ }
229
+ export function publicBlocks(field, value) {
230
+ if (!Array.isArray(value)) return value;
231
+ return value.filter((item) => {
232
+ if (!isHiddenBlock(item)) return true;
233
+ return declaresHiddenField(field.blocks?.[String(item.type)]);
234
+ });
235
+ }
209
236
  export function localizeBlock(field, item, locale, defaultLocale) {
210
237
  if (!item || typeof item !== "object") return item;
211
238
  const block = field.blocks?.[String(item.type)];
@@ -276,6 +303,122 @@ export function expandMobileMedia(config) {
276
303
  }
277
304
  return config;
278
305
  }
306
+ export const CONTENT_TITLE_FIELD = "title";
307
+ export const CONTENT_SLUG_FIELD = "slug";
308
+ export const CONTENT_PUBLISHED_AT_FIELD = "publishedAt";
309
+ export const CONTENT_BODY_FIELD = "body";
310
+ export const CONTENT_SYSTEM_FIELDS = [
311
+ "title",
312
+ "slug",
313
+ "publishedAt",
314
+ "excerpt",
315
+ "cover",
316
+ "body",
317
+ "seoTitle",
318
+ "seoDescription",
319
+ "seoImage"
320
+ ];
321
+ export const CONTENT_LIST_COLUMNS = ["title", "status", "publishedAt"];
322
+ export function isContentEntry(entry) {
323
+ return entry.kind === "content";
324
+ }
325
+ export function isCollectionKind(entry) {
326
+ return entry.kind === "collection" || entry.kind === "content";
327
+ }
328
+ function contentSystemFields(blocks, labels, translatable) {
329
+ const label = (key, fallback) => labels[key] || fallback;
330
+ const localized = translatable ? { translatable: true } : {};
331
+ return {
332
+ title: { label: label("title", "Title"), type: "text", required: true, ...localized },
333
+ slug: {
334
+ label: label("slug", "Slug"),
335
+ type: "slug",
336
+ from: CONTENT_TITLE_FIELD,
337
+ required: true,
338
+ description: "Filled from the title in the default language."
339
+ },
340
+ publishedAt: {
341
+ label: label("publishedAt", "Published at"),
342
+ type: "datetime",
343
+ description: "Empty means now when you publish. A future date schedules the item."
344
+ },
345
+ excerpt: {
346
+ label: label("excerpt", "Excerpt"),
347
+ type: "text",
348
+ textarea: true,
349
+ ...localized
350
+ },
351
+ cover: { label: label("cover", "Cover"), type: "media", mediaType: "image" },
352
+ body: { label: label("body", "Body"), type: "blocks", blocks },
353
+ seoTitle: { label: label("seoTitle", "SEO title"), type: "text", ...localized },
354
+ seoDescription: {
355
+ label: label("seoDescription", "SEO description"),
356
+ type: "text",
357
+ textarea: true,
358
+ ...localized
359
+ },
360
+ seoImage: { label: label("seoImage", "SEO image"), type: "media", mediaType: "image" }
361
+ };
362
+ }
363
+ function normalizeContentEntry(entry, translatable) {
364
+ if (entry.declared) return entry;
365
+ const declared = entry.fields ?? {};
366
+ const system = contentSystemFields(entry.blocks ?? {}, entry.labels ?? {}, translatable);
367
+ const extra = Object.fromEntries(
368
+ Object.entries(declared).filter(([key]) => !Object.hasOwn(system, key))
369
+ );
370
+ const { body, seoTitle, seoDescription, seoImage, ...head } = system;
371
+ return Object.assign(entry, {
372
+ declared: {
373
+ fields: Object.keys(declared),
374
+ ...entry.titleField !== void 0 ? { titleField: entry.titleField } : {},
375
+ ...entry.drafts !== void 0 ? { drafts: entry.drafts } : {}
376
+ },
377
+ titleField: CONTENT_TITLE_FIELD,
378
+ drafts: true,
379
+ fields: { ...head, ...extra, body, seoTitle, seoDescription, seoImage },
380
+ list: entry.list ?? { columns: [...CONTENT_LIST_COLUMNS] }
381
+ });
382
+ }
383
+ export function normalizeContentEntries(config, i18n) {
384
+ const translatable = !!i18n?.locales.length;
385
+ for (const entry of Object.values(config)) {
386
+ if (entry?.kind === "content") normalizeContentEntry(entry, translatable);
387
+ }
388
+ return config;
389
+ }
390
+ export function normalizeCmsConfig(config, i18n) {
391
+ return expandMobileMedia(normalizeContentEntries(config, i18n));
392
+ }
393
+ export function slugSourceValue(source, value, defaultLocale) {
394
+ if (value == null) return null;
395
+ if (source && isTranslatableField(source) && typeof value === "object") {
396
+ return value[defaultLocale] ?? null;
397
+ }
398
+ return typeof value === "string" ? value : null;
399
+ }
400
+ export function isScheduledContent(row, now = /* @__PURE__ */ new Date()) {
401
+ if (!row || row.status !== "published") return false;
402
+ const at = row[CONTENT_PUBLISHED_AT_FIELD];
403
+ if (typeof at !== "string" || !at) return false;
404
+ const time = new Date(at).getTime();
405
+ return !Number.isNaN(time) && time > now.getTime();
406
+ }
407
+ export function resolveBlocksMedia(field, value, resolve) {
408
+ if (!Array.isArray(value)) return value;
409
+ return value.map((item) => {
410
+ if (!item || typeof item !== "object") return item;
411
+ const block = field.blocks?.[String(item.type)];
412
+ if (!block) return item;
413
+ const resolved = { ...item };
414
+ for (const [key, blockField] of Object.entries(block.fields)) {
415
+ if (blockField.type !== "media") continue;
416
+ const media = resolved[key];
417
+ resolved[key] = typeof media === "string" && media ? resolve(media) ?? null : null;
418
+ }
419
+ return resolved;
420
+ });
421
+ }
279
422
  export function entryTabs(entry) {
280
423
  return entry.tabs?.length ? entry.tabs : [];
281
424
  }
@@ -379,5 +522,8 @@ export function blockTypeName(entryName, fieldKey, blockName) {
379
522
  export function defineCmsConfig(config) {
380
523
  return config;
381
524
  }
525
+ export function defineCmsBlocks(blocks) {
526
+ return blocks;
527
+ }
382
528
  export * from "./layout.js";
383
529
  export * from "./users.js";