@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.
- package/README.md +4 -2
- package/dist/module.d.mts +4 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +329 -32
- package/dist/runtime/app/blocks/CmsBlocks.d.vue.ts +45 -0
- package/dist/runtime/app/blocks/CmsBlocks.vue +36 -0
- package/dist/runtime/app/blocks/CmsBlocks.vue.d.ts +45 -0
- package/dist/runtime/app/blocks/resolve.d.ts +21 -0
- package/dist/runtime/app/blocks/resolve.js +37 -0
- package/dist/runtime/app/components/cms/BlockOutline.d.vue.ts +17 -0
- package/dist/runtime/app/components/cms/BlockOutline.vue +99 -0
- package/dist/runtime/app/components/cms/BlockOutline.vue.d.ts +17 -0
- package/dist/runtime/app/components/cms/BlockPalette.d.vue.ts +12 -0
- package/dist/runtime/app/components/cms/BlockPalette.vue +29 -0
- package/dist/runtime/app/components/cms/BlockPalette.vue.d.ts +12 -0
- package/dist/runtime/app/components/cms/BlocksField.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/BlocksField.vue +83 -3
- package/dist/runtime/app/components/cms/BlocksField.vue.d.ts +2 -0
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +43 -7
- package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +2 -0
- package/dist/runtime/app/components/cms/FieldInput.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/FieldInput.vue +18 -1
- package/dist/runtime/app/components/cms/FieldInput.vue.d.ts +2 -0
- package/dist/runtime/app/components/cms/MediaDetail.vue +25 -13
- package/dist/runtime/app/components/cms/Search.d.vue.ts +7 -0
- package/dist/runtime/app/components/cms/Search.vue +347 -0
- package/dist/runtime/app/components/cms/Search.vue.d.ts +7 -0
- package/dist/runtime/app/components/cms/SettingsEntry.vue +3 -2
- package/dist/runtime/app/components/cms/SettingsModal.vue +2 -1
- package/dist/runtime/app/components/cms/StatusBadge.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/StatusBadge.vue +9 -3
- package/dist/runtime/app/components/cms/StatusBadge.vue.d.ts +2 -0
- package/dist/runtime/app/components/cms/VisualEditor.d.vue.ts +39 -0
- package/dist/runtime/app/components/cms/VisualEditor.vue +553 -0
- package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +39 -0
- package/dist/runtime/app/composables/cms-entry-disabled.d.ts +7 -1
- package/dist/runtime/app/composables/cms-entry-disabled.js +15 -0
- package/dist/runtime/app/composables/cms-entry.d.ts +7 -1
- package/dist/runtime/app/composables/cms-entry.js +51 -1
- package/dist/runtime/app/composables/cms-field-focus.d.ts +4 -0
- package/dist/runtime/app/composables/cms-field-focus.js +20 -0
- package/dist/runtime/app/composables/cms-runtime.d.ts +1 -0
- package/dist/runtime/app/composables/cms-settings.js +8 -2
- package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
- package/dist/runtime/app/layouts/cms-admin.vue +7 -0
- package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
- package/dist/runtime/app/layouts/cms-editor.d.vue.ts +13 -0
- package/dist/runtime/app/layouts/cms-editor.vue +14 -0
- package/dist/runtime/app/layouts/cms-editor.vue.d.ts +13 -0
- package/dist/runtime/app/pages/admin-collection.vue +36 -4
- package/dist/runtime/app/pages/admin-content.d.vue.ts +3 -0
- package/dist/runtime/app/pages/admin-content.vue +287 -0
- package/dist/runtime/app/pages/admin-content.vue.d.ts +3 -0
- package/dist/runtime/app/pages/admin-entry.vue +3 -0
- package/dist/runtime/app/pages/admin-preview.d.vue.ts +3 -0
- package/dist/runtime/app/pages/admin-preview.vue +132 -0
- package/dist/runtime/app/pages/admin-preview.vue.d.ts +3 -0
- package/dist/runtime/app/preview/PreviewOverlay.d.vue.ts +15 -0
- package/dist/runtime/app/preview/PreviewOverlay.vue +362 -0
- package/dist/runtime/app/preview/PreviewOverlay.vue.d.ts +15 -0
- package/dist/runtime/app/utils/ui.js +1 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/collection.get.js +4 -5
- package/dist/runtime/server/api/collection.post.js +4 -1
- package/dist/runtime/server/api/item.delete.js +2 -1
- package/dist/runtime/server/api/item.put.js +2 -1
- package/dist/runtime/server/api/media-download.get.d.ts +2 -0
- package/dist/runtime/server/api/media-download.get.js +90 -0
- package/dist/runtime/server/api/search.get.d.ts +3 -0
- package/dist/runtime/server/api/search.get.js +24 -0
- package/dist/runtime/server/utils/graphql.js +23 -8
- package/dist/runtime/server/utils/media-download.d.ts +1 -0
- package/dist/runtime/server/utils/media-download.js +8 -0
- package/dist/runtime/server/utils/media.d.ts +1 -0
- package/dist/runtime/server/utils/media.js +18 -3
- package/dist/runtime/server/utils/registry.d.ts +1 -0
- package/dist/runtime/server/utils/registry.js +6 -0
- package/dist/runtime/server/utils/search.d.ts +20 -0
- package/dist/runtime/server/utils/search.js +233 -0
- package/dist/runtime/server/utils/visibility.d.ts +4 -0
- package/dist/runtime/server/utils/visibility.js +12 -0
- package/dist/runtime/shared/graphql-sdl.js +4 -1
- package/dist/runtime/shared/index.d.ts +59 -5
- package/dist/runtime/shared/index.js +133 -0
- package/dist/runtime/shared/preview.d.ts +85 -0
- package/dist/runtime/shared/preview.js +219 -0
- package/dist/runtime/shared/search.d.ts +52 -0
- package/dist/runtime/shared/search.js +207 -0
- package/dist/runtime/shared/validation.d.ts +2 -0
- package/dist/runtime/shared/validation.js +15 -2
- 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
|
-
|
|
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) ||
|
|
120
|
-
|
|
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
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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';
|