@xleddyl/nuxt-cms 0.2.3 → 0.4.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 +8 -1
- package/dist/module.d.mts +3 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +113 -3
- package/dist/runtime/app/components/cms/BrandMark.vue +16 -1
- 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/components/cms/WelcomeModal.vue +7 -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/layouts/cms-admin.d.vue.ts +2 -2
- package/dist/runtime/app/layouts/cms-admin.vue +8 -3
- 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,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
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { entryTabs, fieldTab, pageAllFields, pageRoutes } from "./index.js";
|
|
2
|
+
export const CMS_SEARCH_MIN_LENGTH = 2;
|
|
3
|
+
export const CMS_SEARCH_MAX_LENGTH = 200;
|
|
4
|
+
export const CMS_SEARCH_LABEL_LIMIT = 20;
|
|
5
|
+
export const CMS_SEARCH_VALUE_LIMIT = 50;
|
|
6
|
+
export const CMS_SEARCH_ROWS_PER_ENTRY = 10;
|
|
7
|
+
export const CMS_SEARCH_HITS_PER_ROW = 5;
|
|
8
|
+
export const CMS_SEARCH_PATH_SEPARATOR = " \u203A ";
|
|
9
|
+
const SNIPPET_BEFORE = 40;
|
|
10
|
+
const SNIPPET_AFTER = 90;
|
|
11
|
+
const ENTITIES = {
|
|
12
|
+
amp: "&",
|
|
13
|
+
lt: "<",
|
|
14
|
+
gt: ">",
|
|
15
|
+
quot: '"',
|
|
16
|
+
apos: "'",
|
|
17
|
+
nbsp: " "
|
|
18
|
+
};
|
|
19
|
+
export function normalizeSearchQuery(value) {
|
|
20
|
+
return value.replace(/\s+/g, " ").trim().slice(0, CMS_SEARCH_MAX_LENGTH);
|
|
21
|
+
}
|
|
22
|
+
export function isSearchableQuery(value) {
|
|
23
|
+
return normalizeSearchQuery(value).length >= CMS_SEARCH_MIN_LENGTH;
|
|
24
|
+
}
|
|
25
|
+
export function searchLikePattern(term) {
|
|
26
|
+
return `%${term.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
27
|
+
}
|
|
28
|
+
export function decodeHtmlEntities(value) {
|
|
29
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, code) => {
|
|
30
|
+
if (code[0] === "#") {
|
|
31
|
+
const point = code[1] === "x" || code[1] === "X" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
|
|
32
|
+
return Number.isFinite(point) && point > 0 && point <= 1114111 ? String.fromCodePoint(point) : entity;
|
|
33
|
+
}
|
|
34
|
+
return ENTITIES[code.toLowerCase()] ?? entity;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function stripMarkup(html) {
|
|
38
|
+
return decodeHtmlEntities(
|
|
39
|
+
html.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<\/?(p|div|li|ul|ol|h[1-6]|blockquote|pre|br|hr|tr|td|th)\b[^>]*>/gi, " ").replace(/<[^>]*>/g, "")
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
export function searchSnippet(text, term) {
|
|
43
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
44
|
+
const needle = normalizeSearchQuery(term).toLowerCase();
|
|
45
|
+
if (!needle) return null;
|
|
46
|
+
const index = normalized.toLowerCase().indexOf(needle);
|
|
47
|
+
if (index === -1) return null;
|
|
48
|
+
const end = index + needle.length;
|
|
49
|
+
const from = Math.max(0, index - SNIPPET_BEFORE);
|
|
50
|
+
const to = Math.min(normalized.length, end + SNIPPET_AFTER);
|
|
51
|
+
return {
|
|
52
|
+
before: `${from > 0 ? "\u2026" : ""}${normalized.slice(from, index).trimStart()}`,
|
|
53
|
+
match: normalized.slice(index, end),
|
|
54
|
+
after: `${normalized.slice(end, to).trimEnd()}${to < normalized.length ? "\u2026" : ""}`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function searchFieldPath(...labels) {
|
|
58
|
+
return labels.join(CMS_SEARCH_PATH_SEPARATOR);
|
|
59
|
+
}
|
|
60
|
+
export function cmsSearchLink(path, focus = {}) {
|
|
61
|
+
const params = new URLSearchParams();
|
|
62
|
+
if (focus.field) params.set("field", focus.field);
|
|
63
|
+
if (focus.locale) params.set("locale", focus.locale);
|
|
64
|
+
if (focus.block != null) params.set("block", String(focus.block));
|
|
65
|
+
if (focus.tab) params.set("tab", focus.tab);
|
|
66
|
+
const query = params.toString();
|
|
67
|
+
return query ? `${path}?${query}` : path;
|
|
68
|
+
}
|
|
69
|
+
export function readSearchFocus(query) {
|
|
70
|
+
const text = (value) => {
|
|
71
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
72
|
+
return typeof first === "string" && first ? first : void 0;
|
|
73
|
+
};
|
|
74
|
+
const field = text(query.field);
|
|
75
|
+
const tab = text(query.tab);
|
|
76
|
+
if (!field && !tab) return null;
|
|
77
|
+
const block = Number.parseInt(text(query.block) ?? "", 10);
|
|
78
|
+
return {
|
|
79
|
+
...field ? { field } : {},
|
|
80
|
+
...tab ? { tab } : {},
|
|
81
|
+
locale: text(query.locale) ?? null,
|
|
82
|
+
block: Number.isInteger(block) && block >= 0 ? block : null
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function entryPath(name) {
|
|
86
|
+
return `/cms/${name}`;
|
|
87
|
+
}
|
|
88
|
+
function fieldLink(name, entry, key, overridePaths) {
|
|
89
|
+
if (entry.kind === "single") {
|
|
90
|
+
return { to: cmsSearchLink(entryPath(name), { field: key }), id: null, title: null };
|
|
91
|
+
}
|
|
92
|
+
if (entry.kind === "page" && !Object.hasOwn(entry.fields, key) && overridePaths.length === 1) {
|
|
93
|
+
const route = pageRoutes(entry).find((candidate) => candidate.path === overridePaths[0]);
|
|
94
|
+
if (route) {
|
|
95
|
+
return {
|
|
96
|
+
to: cmsSearchLink(`${entryPath(name)}/${route.key}`, { field: key }),
|
|
97
|
+
id: route.key,
|
|
98
|
+
title: route.label
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { to: entryPath(name), id: null, title: null };
|
|
103
|
+
}
|
|
104
|
+
function overridePathsOf(entry, key) {
|
|
105
|
+
return Object.entries(entry.overrides ?? {}).filter(([, fields]) => !!fields[key]).map(([path]) => path);
|
|
106
|
+
}
|
|
107
|
+
function blockCandidates(key, field, link) {
|
|
108
|
+
const candidates = [];
|
|
109
|
+
for (const block of Object.values(field.blocks ?? {})) {
|
|
110
|
+
candidates.push({
|
|
111
|
+
type: "block",
|
|
112
|
+
...link,
|
|
113
|
+
field: key,
|
|
114
|
+
fieldLabel: searchFieldPath(field.label, block.label),
|
|
115
|
+
texts: [block.label]
|
|
116
|
+
});
|
|
117
|
+
for (const blockField of Object.values(block.fields)) {
|
|
118
|
+
candidates.push({
|
|
119
|
+
type: "field",
|
|
120
|
+
...link,
|
|
121
|
+
field: key,
|
|
122
|
+
fieldLabel: searchFieldPath(field.label, block.label, blockField.label),
|
|
123
|
+
texts: [blockField.label]
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return candidates;
|
|
128
|
+
}
|
|
129
|
+
function labelCandidates(name, entry) {
|
|
130
|
+
const candidates = [
|
|
131
|
+
{ type: "entry", texts: [entry.label], to: entryPath(name) }
|
|
132
|
+
];
|
|
133
|
+
if (entry.kind === "page") {
|
|
134
|
+
for (const route of pageRoutes(entry)) {
|
|
135
|
+
candidates.push({
|
|
136
|
+
type: "page",
|
|
137
|
+
id: route.key,
|
|
138
|
+
title: route.label,
|
|
139
|
+
texts: [route.label, route.path],
|
|
140
|
+
to: `${entryPath(name)}/${route.key}`
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const tab of entryTabs(entry)) {
|
|
145
|
+
candidates.push({
|
|
146
|
+
type: "tab",
|
|
147
|
+
fieldLabel: tab.label,
|
|
148
|
+
texts: [tab.label],
|
|
149
|
+
to: entry.kind === "single" ? cmsSearchLink(entryPath(name), { tab: tab.id }) : entryPath(name)
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const fields = entry.kind === "page" ? pageAllFields(entry) : entry.fields;
|
|
153
|
+
const tabs = entryTabs(entry);
|
|
154
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
155
|
+
const link = fieldLink(name, entry, key, overridePathsOf(entry, key));
|
|
156
|
+
candidates.push({
|
|
157
|
+
type: "field",
|
|
158
|
+
...link,
|
|
159
|
+
field: key,
|
|
160
|
+
fieldLabel: tabs.length ? searchFieldPath(
|
|
161
|
+
tabs.find((tab) => tab.id === fieldTab(field, tabs))?.label ?? "",
|
|
162
|
+
field.label
|
|
163
|
+
) : field.label,
|
|
164
|
+
texts: [field.label]
|
|
165
|
+
});
|
|
166
|
+
if (field.type === "blocks") candidates.push(...blockCandidates(key, field, link));
|
|
167
|
+
}
|
|
168
|
+
return candidates;
|
|
169
|
+
}
|
|
170
|
+
export function searchCmsLabels(config, query, limit = CMS_SEARCH_LABEL_LIMIT) {
|
|
171
|
+
const term = normalizeSearchQuery(query);
|
|
172
|
+
if (term.length < CMS_SEARCH_MIN_LENGTH) return { hits: [], truncated: false };
|
|
173
|
+
const needle = term.toLowerCase();
|
|
174
|
+
const scored = [];
|
|
175
|
+
const seen = /* @__PURE__ */ new Set();
|
|
176
|
+
for (const [name, entry] of Object.entries(config)) {
|
|
177
|
+
for (const candidate of labelCandidates(name, entry)) {
|
|
178
|
+
const text = candidate.texts.find((value) => value.toLowerCase().includes(needle));
|
|
179
|
+
if (!text) continue;
|
|
180
|
+
const snippet = searchSnippet(text, term);
|
|
181
|
+
if (!snippet) continue;
|
|
182
|
+
const identity = [candidate.type, name, candidate.fieldLabel ?? "", candidate.to].join(
|
|
183
|
+
"\0"
|
|
184
|
+
);
|
|
185
|
+
if (seen.has(identity)) continue;
|
|
186
|
+
seen.add(identity);
|
|
187
|
+
scored.push({
|
|
188
|
+
score: text.toLowerCase().startsWith(needle) ? 0 : 1,
|
|
189
|
+
hit: {
|
|
190
|
+
type: candidate.type,
|
|
191
|
+
collection: name,
|
|
192
|
+
entryLabel: entry.label,
|
|
193
|
+
kind: entry.kind,
|
|
194
|
+
id: candidate.id ?? null,
|
|
195
|
+
title: candidate.title ?? null,
|
|
196
|
+
field: candidate.field ?? null,
|
|
197
|
+
fieldLabel: candidate.fieldLabel ?? null,
|
|
198
|
+
locale: null,
|
|
199
|
+
snippet,
|
|
200
|
+
to: candidate.to
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const sorted = scored.map((item, index) => ({ ...item, index })).sort((a, b) => a.score - b.score || a.index - b.index).map((item) => item.hit);
|
|
206
|
+
return { hits: sorted.slice(0, limit), truncated: sorted.length > limit };
|
|
207
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Lightweight CMS that ships with your Nuxt app: runs on the Nitro server, content types defined in code, /cms admin panel, GraphQL API, SQLite or Postgres. No external CMS needed!",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Edoardo Alberti (https://github.com/xleddyl)",
|
|
@@ -124,6 +124,6 @@
|
|
|
124
124
|
"test:docker": "vitest run --config test/docker/vitest.config.ts",
|
|
125
125
|
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit",
|
|
126
126
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md,html,json,sql,vue}\" --log-level error",
|
|
127
|
-
"release": "
|
|
127
|
+
"release": "node scripts/release.mjs"
|
|
128
128
|
}
|
|
129
129
|
}
|