@xleddyl/nuxt-cms 0.2.3 → 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 +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +10 -0
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +34 -3
- package/dist/runtime/app/components/cms/EntryForm.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/VisualEditor.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/VisualEditor.vue +21 -1
- package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +2 -0
- 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/layouts/cms-admin.d.vue.ts +2 -2
- package/dist/runtime/app/layouts/cms-admin.vue +2 -0
- package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
- package/dist/runtime/app/pages/admin-collection.vue +3 -0
- package/dist/runtime/app/pages/admin-content.vue +3 -0
- package/dist/runtime/app/pages/admin-entry.vue +3 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/collection.get.js +2 -4
- 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/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/search.d.ts +20 -0
- package/dist/runtime/server/utils/search.js +233 -0
- package/dist/runtime/shared/search.d.ts +52 -0
- package/dist/runtime/shared/search.js +207 -0
- package/package.json +2 -2
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import {
|
|
5
|
+
createError,
|
|
6
|
+
defineEventHandler,
|
|
7
|
+
getRequestURL,
|
|
8
|
+
getValidatedQuery,
|
|
9
|
+
sendRedirect,
|
|
10
|
+
sendStream,
|
|
11
|
+
setResponseHeaders
|
|
12
|
+
} from "h3";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { useDb } from "#cms-db";
|
|
15
|
+
import { cms_media } from "#cms-tables";
|
|
16
|
+
import { useRuntimeConfig } from "#imports";
|
|
17
|
+
import { isMediaFolderMarker, mediaFilename, mediaPublicUrl } from "../../shared/index.js";
|
|
18
|
+
import { objectKeySchema } from "../../shared/validation.js";
|
|
19
|
+
import { encodeKey, presignedDownloadUrl, useMediaConfig } from "../utils/media.js";
|
|
20
|
+
import { attachmentDisposition } from "../utils/media-download.js";
|
|
21
|
+
import { mediaFilePath } from "../utils/media-fs.js";
|
|
22
|
+
import { useMediaIndex } from "../utils/media-index.js";
|
|
23
|
+
import { mediaMimeForKey } from "../utils/media-sync.js";
|
|
24
|
+
import { requireAdmin } from "../utils/require-admin.js";
|
|
25
|
+
const querySchema = z.object({ key: objectKeySchema });
|
|
26
|
+
function notFound() {
|
|
27
|
+
throw createError({ statusCode: 404, statusMessage: "Media not found" });
|
|
28
|
+
}
|
|
29
|
+
async function assertKnownRow(key) {
|
|
30
|
+
const [row] = await useDb().select({ mime: cms_media.mime }).from(cms_media).where(eq(cms_media.key, key)).limit(1);
|
|
31
|
+
if (!row) notFound();
|
|
32
|
+
return row.mime;
|
|
33
|
+
}
|
|
34
|
+
async function sendDiskFile(event, root, key, mime, disposition) {
|
|
35
|
+
const path = mediaFilePath(root, key);
|
|
36
|
+
if (!path) throw createError({ statusCode: 400, statusMessage: "Invalid object key" });
|
|
37
|
+
const info = await stat(path).catch(() => null);
|
|
38
|
+
if (!info?.isFile()) notFound();
|
|
39
|
+
setResponseHeaders(event, {
|
|
40
|
+
"content-type": mime ?? mediaMimeForKey(key) ?? "application/octet-stream",
|
|
41
|
+
"content-disposition": disposition,
|
|
42
|
+
"content-length": info.size,
|
|
43
|
+
"cache-control": "private, no-store",
|
|
44
|
+
"x-content-type-options": "nosniff"
|
|
45
|
+
});
|
|
46
|
+
return sendStream(event, createReadStream(path));
|
|
47
|
+
}
|
|
48
|
+
async function sendRemoteFile(event, baseUrl, key, disposition) {
|
|
49
|
+
const publicUrl = mediaPublicUrl(baseUrl, encodeKey(key));
|
|
50
|
+
if (!publicUrl) notFound();
|
|
51
|
+
const upstream = await fetch(new URL(publicUrl, getRequestURL(event).origin));
|
|
52
|
+
if (!upstream.ok || !upstream.body) {
|
|
53
|
+
throw createError({
|
|
54
|
+
statusCode: 502,
|
|
55
|
+
statusMessage: `Media source answered ${upstream.status}`
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const length = upstream.headers.get("content-length");
|
|
59
|
+
setResponseHeaders(event, {
|
|
60
|
+
"content-type": upstream.headers.get("content-type") ?? mediaMimeForKey(key) ?? "application/octet-stream",
|
|
61
|
+
"content-disposition": disposition,
|
|
62
|
+
...length ? { "content-length": Number(length) } : {},
|
|
63
|
+
"cache-control": "private, no-store",
|
|
64
|
+
"x-content-type-options": "nosniff"
|
|
65
|
+
});
|
|
66
|
+
return sendStream(event, upstream.body);
|
|
67
|
+
}
|
|
68
|
+
export default defineEventHandler(async (event) => {
|
|
69
|
+
await requireAdmin(event);
|
|
70
|
+
const { media } = useMediaConfig(event);
|
|
71
|
+
const { key } = await getValidatedQuery(event, querySchema.parse);
|
|
72
|
+
if (isMediaFolderMarker(key)) notFound();
|
|
73
|
+
const disposition = attachmentDisposition(mediaFilename(key));
|
|
74
|
+
if (media.storage === "s3") {
|
|
75
|
+
await assertKnownRow(key);
|
|
76
|
+
return sendRedirect(event, await presignedDownloadUrl(media, key, disposition), 302);
|
|
77
|
+
}
|
|
78
|
+
if (media.storage === "filesystem") {
|
|
79
|
+
const mime = await assertKnownRow(key);
|
|
80
|
+
return sendDiskFile(event, media.dir, key, mime, disposition);
|
|
81
|
+
}
|
|
82
|
+
const file = (await useMediaIndex()).get(key);
|
|
83
|
+
if (!file) notFound();
|
|
84
|
+
const { localRoot } = useRuntimeConfig(event).cms.media;
|
|
85
|
+
if (localRoot && existsSync(localRoot)) {
|
|
86
|
+
return sendDiskFile(event, localRoot, key, file.mime, disposition);
|
|
87
|
+
}
|
|
88
|
+
const { mediaBaseUrl } = useRuntimeConfig(event).public.cms;
|
|
89
|
+
return sendRemoteFile(event, mediaBaseUrl, key, disposition);
|
|
90
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineEventHandler, getValidatedQuery } from "h3";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import cmsConfig from "#cms-config";
|
|
4
|
+
import { cmsDialect, useDb } from "#cms-db";
|
|
5
|
+
import * as cmsTables from "#cms-tables";
|
|
6
|
+
import { CMS_SEARCH_MAX_LENGTH } from "../../shared/search.js";
|
|
7
|
+
import { getContentI18n } from "../utils/registry.js";
|
|
8
|
+
import { requireAdmin } from "../utils/require-admin.js";
|
|
9
|
+
import { searchCms } from "../utils/search.js";
|
|
10
|
+
const querySchema = z.object({
|
|
11
|
+
q: z.string().max(CMS_SEARCH_MAX_LENGTH).default("")
|
|
12
|
+
});
|
|
13
|
+
export default defineEventHandler(async (event) => {
|
|
14
|
+
await requireAdmin(event);
|
|
15
|
+
const { q } = await getValidatedQuery(event, querySchema.parse);
|
|
16
|
+
return searchCms({
|
|
17
|
+
db: useDb(),
|
|
18
|
+
dialect: cmsDialect,
|
|
19
|
+
config: cmsConfig,
|
|
20
|
+
tables: cmsTables,
|
|
21
|
+
i18n: getContentI18n(),
|
|
22
|
+
query: q
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -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}`);
|
|
@@ -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,52 @@
|
|
|
1
|
+
import type { CmsConfig, CmsEntryKind } from './index.js';
|
|
2
|
+
export declare const CMS_SEARCH_MIN_LENGTH = 2;
|
|
3
|
+
export declare const CMS_SEARCH_MAX_LENGTH = 200;
|
|
4
|
+
export declare const CMS_SEARCH_LABEL_LIMIT = 20;
|
|
5
|
+
export declare const CMS_SEARCH_VALUE_LIMIT = 50;
|
|
6
|
+
export declare const CMS_SEARCH_ROWS_PER_ENTRY = 10;
|
|
7
|
+
export declare const CMS_SEARCH_HITS_PER_ROW = 5;
|
|
8
|
+
export declare const CMS_SEARCH_PATH_SEPARATOR = " \u203A ";
|
|
9
|
+
export type CmsSearchHitType = 'entry' | 'page' | 'tab' | 'field' | 'block' | 'value' | 'media';
|
|
10
|
+
export type CmsSearchKind = CmsEntryKind | 'media';
|
|
11
|
+
export interface CmsSearchSnippet {
|
|
12
|
+
before: string;
|
|
13
|
+
match: string;
|
|
14
|
+
after: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CmsSearchHit {
|
|
17
|
+
type: CmsSearchHitType;
|
|
18
|
+
collection: string | null;
|
|
19
|
+
entryLabel: string;
|
|
20
|
+
kind: CmsSearchKind;
|
|
21
|
+
id: string | null;
|
|
22
|
+
title: string | null;
|
|
23
|
+
field: string | null;
|
|
24
|
+
fieldLabel: string | null;
|
|
25
|
+
locale: string | null;
|
|
26
|
+
snippet: CmsSearchSnippet;
|
|
27
|
+
to: string;
|
|
28
|
+
}
|
|
29
|
+
export interface CmsSearchResponse {
|
|
30
|
+
query: string;
|
|
31
|
+
hits: CmsSearchHit[];
|
|
32
|
+
truncated: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface CmsSearchFocus {
|
|
35
|
+
field?: string;
|
|
36
|
+
locale?: string | null;
|
|
37
|
+
block?: number | null;
|
|
38
|
+
tab?: string;
|
|
39
|
+
}
|
|
40
|
+
export declare function normalizeSearchQuery(value: string): string;
|
|
41
|
+
export declare function isSearchableQuery(value: string): boolean;
|
|
42
|
+
export declare function searchLikePattern(term: string): string;
|
|
43
|
+
export declare function decodeHtmlEntities(value: string): string;
|
|
44
|
+
export declare function stripMarkup(html: string): string;
|
|
45
|
+
export declare function searchSnippet(text: string, term: string): CmsSearchSnippet | null;
|
|
46
|
+
export declare function searchFieldPath(...labels: string[]): string;
|
|
47
|
+
export declare function cmsSearchLink(path: string, focus?: CmsSearchFocus): string;
|
|
48
|
+
export declare function readSearchFocus(query: Record<string, unknown>): CmsSearchFocus | null;
|
|
49
|
+
export declare function searchCmsLabels(config: CmsConfig, query: string, limit?: number): {
|
|
50
|
+
hits: CmsSearchHit[];
|
|
51
|
+
truncated: boolean;
|
|
52
|
+
};
|