@ecomcx/ecomweb-theme-sdk 1.26.0 → 1.28.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/dist/api.d.ts +44 -0
- package/dist/api.js +13 -1
- package/dist/components.js +6 -2
- package/dist/content.js +5 -3
- package/dist/dynamic.js +2 -2
- package/dist/rich.d.ts +8 -0
- package/dist/rich.js +16 -2
- package/dist/template-blocks.js +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +19 -0
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -32,6 +32,37 @@ export declare const UNLOCK_HEADER = "X-CMS-Unlock";
|
|
|
32
32
|
/** unlock: giá trị cookie cms-unlock — có thì không dùng cache (nội dung riêng của người đã nhập mật khẩu). */
|
|
33
33
|
export declare const resolvePath: (path: string, query?: Record<string, string | string[] | undefined>, noStore?: boolean, unlock?: string) => Promise<ResolveResult>;
|
|
34
34
|
export declare const getPreview: (token: string) => Promise<ResolveResult>;
|
|
35
|
+
/** Một điều kiện lọc theo taxonomy: bài phải khớp (hoặc không khớp) các term này. */
|
|
36
|
+
export interface TaxQueryClause {
|
|
37
|
+
taxonomy: string;
|
|
38
|
+
terms: string[];
|
|
39
|
+
/** Mặc định "IN". "NOT IN" = loại bài thuộc các term này. */
|
|
40
|
+
operator?: "IN" | "NOT IN";
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Một điều kiện lọc theo trường tuỳ biến (post.meta). Giá trị so sánh phải đúng kiểu JSON (số là số).
|
|
44
|
+
*
|
|
45
|
+
* So bằng (`=`, `!=`, `IN`, `NOT IN`, `EXISTS`, `NOT EXISTS`) chạy trên chỉ mục GIN sẵn có — dùng được
|
|
46
|
+
* với mọi khoá meta.
|
|
47
|
+
*
|
|
48
|
+
* So khoảng (`>`, `>=`, `<`, `<=`, `BETWEEN`, `LIKE`) **chỉ dùng được với khoá đã khai báo
|
|
49
|
+
* `filterable`** (backend tạo chỉ mục B-tree cho khoá đó lúc khởi động); khoá chưa khai báo trả 422.
|
|
50
|
+
* Với khoá kiểu số, giá trị phải là số JSON (chuỗi số cũng được, core tự chuyển) — nếu không phép so
|
|
51
|
+
* sẽ so chuỗi ("100" < "20").
|
|
52
|
+
*/
|
|
53
|
+
export interface MetaQueryClause {
|
|
54
|
+
key: string;
|
|
55
|
+
compare: "=" | "!=" | "IN" | "NOT IN" | "EXISTS" | "NOT EXISTS" | ">" | ">=" | "<" | "<=" | "BETWEEN" | "LIKE";
|
|
56
|
+
/** Bỏ trống với EXISTS / NOT EXISTS. Với LIKE: mẫu có `%` / `_`. */
|
|
57
|
+
value?: string | number | boolean | (string | number | boolean)[];
|
|
58
|
+
/** Cận trên của BETWEEN (bắt buộc với BETWEEN). */
|
|
59
|
+
value2?: string | number;
|
|
60
|
+
}
|
|
61
|
+
export interface MetaQuery {
|
|
62
|
+
/** Mặc định "AND". */
|
|
63
|
+
relation?: "AND" | "OR";
|
|
64
|
+
clauses: MetaQueryClause[];
|
|
65
|
+
}
|
|
35
66
|
export interface PostQuery {
|
|
36
67
|
/** Mặc định: ngôn ngữ của request hiện tại. */
|
|
37
68
|
locale?: string;
|
|
@@ -40,6 +71,14 @@ export interface PostQuery {
|
|
|
40
71
|
term?: string;
|
|
41
72
|
authorId?: number;
|
|
42
73
|
search?: string;
|
|
74
|
+
/** Lọc nhiều post type cùng lúc (bài viết + trang + job...). Gộp với `type`. */
|
|
75
|
+
types?: string[];
|
|
76
|
+
/** Lọc nhiều taxonomy cùng lúc — thay cho taxonomy/term khi cần AND/OR nhiều điều kiện. */
|
|
77
|
+
taxQuery?: TaxQueryClause[];
|
|
78
|
+
/** Lọc theo trường tuỳ biến; chạy trong cơ sở dữ liệu trên chỉ mục GIN của posts.meta. */
|
|
79
|
+
metaQuery?: MetaQuery;
|
|
80
|
+
/** Chỉ lấy bài thuộc ngữ cảnh này (plugin tự lọc thêm qua filter posts.query). */
|
|
81
|
+
context?: string;
|
|
43
82
|
include?: number[];
|
|
44
83
|
exclude?: number[];
|
|
45
84
|
parentId?: number;
|
|
@@ -51,6 +90,11 @@ export interface PostQuery {
|
|
|
51
90
|
perPage?: number;
|
|
52
91
|
orderBy?: "date" | "title" | "modified" | "menu_order" | "comment_count";
|
|
53
92
|
order?: "asc" | "desc";
|
|
93
|
+
/**
|
|
94
|
+
* Sắp xếp theo một khoá meta đã khai báo `filterable` (vd `"gia"`, `"job_deadline"`) — thay cho `orderBy`.
|
|
95
|
+
* Khoá chưa khai báo trả 422 (không có chỉ mục thì sắp xếp phải quét toàn bảng).
|
|
96
|
+
*/
|
|
97
|
+
orderByMeta?: string;
|
|
54
98
|
}
|
|
55
99
|
export declare const getPosts: ({ withContent, ...q }?: PostQuery) => Promise<{
|
|
56
100
|
items: Post[];
|
package/dist/api.js
CHANGED
|
@@ -116,7 +116,19 @@ export const UNLOCK_HEADER = "X-CMS-Unlock";
|
|
|
116
116
|
/** unlock: giá trị cookie cms-unlock — có thì không dùng cache (nội dung riêng của người đã nhập mật khẩu). */
|
|
117
117
|
export const resolvePath = (path, query = {}, noStore = false, unlock) => cmsFetch("/resolve", { tags: ["posts"], noStore: noStore || !!unlock, query: clean({ ...query, path }), headers: unlock ? { [UNLOCK_HEADER]: unlock } : undefined });
|
|
118
118
|
export const getPreview = (token) => cmsFetch("/preview", { noStore: true, query: { token } });
|
|
119
|
-
export const getPosts = async ({ withContent, ...q } = {}) =>
|
|
119
|
+
export const getPosts = async ({ withContent, ...q } = {}) => {
|
|
120
|
+
// Bóc tách TRONG thân hàm, không phải trong tham số: chữ ký đã công bố của getPosts nằm trong baseline
|
|
121
|
+
// hợp đồng (api/theme-sdk.txt) nên thêm tên vào tham số là đổi một dòng đã ký (CI coi là phá hợp đồng).
|
|
122
|
+
const { taxQuery, metaQuery, ...rest } = q;
|
|
123
|
+
return cmsFetch("/posts", { tags: ["posts"], query: clean({
|
|
124
|
+
...rest,
|
|
125
|
+
// Bộ lọc phức tạp đi dạng JSON — core phân tích và áp thẳng vào câu SQL (không lọc bằng bộ nhớ).
|
|
126
|
+
taxQuery: taxQuery?.length ? JSON.stringify(taxQuery) : undefined,
|
|
127
|
+
metaQuery: metaQuery?.clauses?.length ? JSON.stringify(metaQuery) : undefined,
|
|
128
|
+
content: withContent ?? false,
|
|
129
|
+
locale: rest.locale ?? (await getRequestLocale()),
|
|
130
|
+
}) });
|
|
131
|
+
};
|
|
120
132
|
const loc = async (locale) => locale ?? (await getRequestLocale());
|
|
121
133
|
export const getPost = async (type, slug, locale) => cmsFetch(`/posts/${type}/${encodeURIComponent(slug)}`, { tags: ["posts"], query: clean({ locale: await loc(locale) }) }).catch(() => null);
|
|
122
134
|
export const getTerms = async (taxonomy, hideEmpty = true, locale) => cmsFetch(`/terms/${taxonomy}`, { tags: ["terms"], query: clean({ hideEmpty, locale: await loc(locale) }) });
|
package/dist/components.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { getArchives, getComments, getMenu, getMenuById, getPosts, getSite, getTerms, getWidgets } from "./api.js";
|
|
3
3
|
import { findWidget, getSlotItems } from "./runtime.js";
|
|
4
|
-
import { cx, formatDate, pageLink } from "./utils.js";
|
|
4
|
+
import { cx, formatDate, pageLink, pageLinkWithQuery } from "./utils.js";
|
|
5
5
|
import { Img } from "./image.js";
|
|
6
6
|
import { CommentForm, CommentList, CommentsMore, ExtensionBoundary, LanguageSwitcherClient, Link, SearchFormClient } from "./client.js";
|
|
7
7
|
import { getT } from "./i18n.js";
|
|
@@ -102,7 +102,11 @@ export async function Pagination({ data, prevLabel, nextLabel }) {
|
|
|
102
102
|
else if (pages[pages.length - 1] !== "…")
|
|
103
103
|
pages.push("…");
|
|
104
104
|
}
|
|
105
|
-
|
|
105
|
+
// Danh sách có bộ lọc (?phong-ban=…, ?muc-luong=…) → giữ nguyên bộ lọc khi chuyển trang
|
|
106
|
+
const hasFilters = Object.keys(data.query ?? {}).some((k) => k !== "s" && k !== "page");
|
|
107
|
+
const link = (p) => hasFilters
|
|
108
|
+
? pageLinkWithQuery(data.basePath, p, data.query)
|
|
109
|
+
: pageLink(data.basePath, p, data.kind === "search" ? data.search : null);
|
|
106
110
|
return (_jsxs("nav", { className: "pagination", "aria-label": t("pagination.label"), children: [data.page > 1 && _jsx(Link, { className: "prev", href: link(data.page - 1), children: prevLabel }), pages.map((p, i) => p === "…" ? _jsx("span", { className: "dots", children: "\u2026" }, `e${i}`)
|
|
107
111
|
: p === data.page ? _jsx("span", { className: "current", "aria-current": "page", children: p }, p)
|
|
108
112
|
: _jsx(Link, { href: link(p), children: p }, p)), data.page < data.totalPages && _jsx(Link, { className: "next", href: link(data.page + 1), children: nextLabel })] }));
|
package/dist/content.js
CHANGED
|
@@ -19,7 +19,7 @@ export async function PostContent({ post, content, className }) {
|
|
|
19
19
|
}
|
|
20
20
|
if (!doc?.blocks?.length)
|
|
21
21
|
return null;
|
|
22
|
-
const rendered = await renderContent(doc, await contentContext(site, post), (blocks) => applyFilters("post.blocks", blocks, { site, post }, site));
|
|
22
|
+
const rendered = await renderContent(doc, { ...(await contentContext(site, post)), source: post ? `post:${post.id}` : undefined }, (blocks) => applyFilters("post.blocks", blocks, { site, post }, site));
|
|
23
23
|
return _jsx("div", { className: cx(className ?? "entry-content", "ecx-content"), children: rendered });
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
@@ -31,7 +31,7 @@ export async function EditableRegion({ name, data, className, children }) {
|
|
|
31
31
|
const doc = (await getTemplates().catch(() => null))?.parts[name];
|
|
32
32
|
const site = await getSite();
|
|
33
33
|
const own = doc?.blocks?.length
|
|
34
|
-
? _jsx("div", { className: cx(`ecx-region ecx-region-${name}`, "ecx-content", className), children: await renderContent(doc, await contentContext(site, data?.post, data)) })
|
|
34
|
+
? _jsx("div", { className: cx(`ecx-region ecx-region-${name}`, "ecx-content", className), children: await renderContent(doc, { ...(await contentContext(site, data?.post, data)), source: `part:${name}` }) })
|
|
35
35
|
: _jsx(_Fragment, { children: children });
|
|
36
36
|
return _jsx(_Fragment, { children: await regionOverride(name, own, site, data) });
|
|
37
37
|
}
|
|
@@ -45,9 +45,11 @@ async function regionOverride(name, fallback, site, data) {
|
|
|
45
45
|
/** Ngữ cảnh vẽ nội dung dùng chung cho bài viết và template khối. */
|
|
46
46
|
export async function contentContext(site, post, data) {
|
|
47
47
|
const t = await getT();
|
|
48
|
+
// Bản nháp (xem trực tiếp / Trình dựng) không vào cache công khai → được gắn vị trí khối cho khung soạn
|
|
49
|
+
const markBlocks = await import("next/headers").then(async ({ draftMode }) => (await draftMode()).isEnabled).catch(() => false);
|
|
48
50
|
const blocks = (await getActiveExtensions(site)).flatMap((d) => d.blockTypes ?? []);
|
|
49
51
|
return {
|
|
50
|
-
site, post, data, blocks,
|
|
52
|
+
site, post, data, blocks, markBlocks,
|
|
51
53
|
resolve: { shortcode: (name) => findShortcode(name, site), block: (type) => findBlock(type, site) },
|
|
52
54
|
labels: { download: t("file.download"), toc: t("toc.title") },
|
|
53
55
|
boundary: (name, key, el) => _jsx(ExtensionBoundary, { name: name, children: el }, key),
|
package/dist/dynamic.js
CHANGED
|
@@ -112,14 +112,14 @@ export async function renderDynamic(node, key, ctx, children) {
|
|
|
112
112
|
const doc = await ctx.pattern(a.ref);
|
|
113
113
|
if (!doc)
|
|
114
114
|
return null;
|
|
115
|
-
return _jsx(Fragment, { children: await renderContent(doc, { ...ctx, partDepth: (ctx.partDepth ?? 0) + 1 }) }, key);
|
|
115
|
+
return _jsx(Fragment, { children: await renderContent(doc, { ...ctx, partDepth: (ctx.partDepth ?? 0) + 1, source: `pattern:${a.ref}` }) }, key);
|
|
116
116
|
}
|
|
117
117
|
case "templatePart": {
|
|
118
118
|
if (!a.slug || !ctx.parts || (ctx.partDepth ?? 0) >= 4)
|
|
119
119
|
return null;
|
|
120
120
|
const doc = await ctx.parts(String(a.slug));
|
|
121
121
|
const Tag = (["header", "footer", "aside", "section"].includes(a.tagName) ? a.tagName : "div");
|
|
122
|
-
const part = doc ? _jsx(Tag, { className: cx("ecx-template-part", `ecx-template-part-${a.slug}`), children: await renderContent(doc, { ...ctx, partDepth: (ctx.partDepth ?? 0) + 1 }) }) : null;
|
|
122
|
+
const part = doc ? _jsx(Tag, { className: cx("ecx-template-part", `ecx-template-part-${a.slug}`), children: await renderContent(doc, { ...ctx, partDepth: (ctx.partDepth ?? 0) + 1, source: `part:${a.slug}` }) }) : null;
|
|
123
123
|
const el = ctx.region ? await ctx.region(String(a.slug), part) : part;
|
|
124
124
|
return el == null ? null : _jsx(Fragment, { children: el }, key);
|
|
125
125
|
}
|
package/dist/rich.d.ts
CHANGED
|
@@ -48,6 +48,14 @@ export interface RenderContext {
|
|
|
48
48
|
id: string;
|
|
49
49
|
text: string;
|
|
50
50
|
}>;
|
|
51
|
+
/**
|
|
52
|
+
* Đang xem bản nháp (Trình dựng / xem trực tiếp): gắn data-ecx-doc + data-ecx-path cho từng khối để khung soạn ở admin
|
|
53
|
+
* biết người dùng bấm vào khối nào. `source` = tài liệu đang vẽ ("template:single", "part:header", "post:12", "pattern:3").
|
|
54
|
+
*/
|
|
55
|
+
markBlocks?: boolean;
|
|
56
|
+
source?: string;
|
|
57
|
+
/** Khoá của khối richtext đang vẽ — đường dẫn khối = phần khoá sau nó (do renderBlock đặt). */
|
|
58
|
+
pathRoot?: string;
|
|
51
59
|
}
|
|
52
60
|
type Node = Omit<RichNode, "attrs" | "content" | "marks"> & {
|
|
53
61
|
attrs?: Record<string, any>;
|
package/dist/rich.js
CHANGED
|
@@ -152,7 +152,21 @@ function decorate(el, a, key, ctx) {
|
|
|
152
152
|
async function renderNode(node, key, ctx) {
|
|
153
153
|
const el = await renderNodeInner(node, key, ctx);
|
|
154
154
|
const a = node.attrs;
|
|
155
|
-
|
|
155
|
+
const out = a && (a.style || a.blockStyle || a.className || a.anchor) ? decorate(el, a, key, ctx) : el;
|
|
156
|
+
return ctx.markBlocks && ctx.source && ctx.pathRoot != null && key.startsWith(ctx.pathRoot + "-") && !UNMARKED.has(node.type)
|
|
157
|
+
? markBlock(out, ctx.source, key.slice(ctx.pathRoot.length + 1), key)
|
|
158
|
+
: out;
|
|
159
|
+
}
|
|
160
|
+
/** Nút không chọn riêng được trong khung soạn (hoặc bọc thêm thẻ sẽ sai cấu trúc HTML: li trong ul, ô trong bảng). */
|
|
161
|
+
const UNMARKED = new Set(["text", "hardBreak", "listItem", "taskItem", "tableRow", "tableCell", "tableHeader"]);
|
|
162
|
+
/** Gắn vị trí khối: thẻ HTML thì thêm thuộc tính, component thì bọc thẻ display:contents (không đổi bố cục). */
|
|
163
|
+
function markBlock(el, source, path, key) {
|
|
164
|
+
if (el == null || el === false)
|
|
165
|
+
return el;
|
|
166
|
+
const attrs = { "data-ecx-doc": source, "data-ecx-path": path };
|
|
167
|
+
if (isValidElement(el) && typeof el.type === "string")
|
|
168
|
+
return cloneElement(el, attrs);
|
|
169
|
+
return _jsx("div", { style: { display: "contents" }, ...attrs, children: el }, key);
|
|
156
170
|
}
|
|
157
171
|
async function renderNodeInner(node, key, ctx) {
|
|
158
172
|
const children = async () => Promise.all((node.content ?? []).map((c, i) => renderNode(c, `${key}-${i}`, ctx)));
|
|
@@ -359,7 +373,7 @@ async function renderHtml(html, key, ctx) {
|
|
|
359
373
|
}
|
|
360
374
|
async function renderBlock(block, key, ctx) {
|
|
361
375
|
switch (block.type) {
|
|
362
|
-
case "richtext": return block.data ? await renderNode(block.data, key, ctx) : null;
|
|
376
|
+
case "richtext": return block.data ? await renderNode(block.data, key, ctx.markBlocks ? { ...ctx, pathRoot: key } : ctx) : null;
|
|
363
377
|
case "html": return await renderHtml(String(block.data?.html ?? ""), key, ctx);
|
|
364
378
|
default: {
|
|
365
379
|
const Comp = await ctx.resolve.block(block.type);
|
package/dist/template-blocks.js
CHANGED
|
@@ -7,5 +7,5 @@ export default async function BlockTemplate({ data, site, template }) {
|
|
|
7
7
|
const doc = (await getTemplates()).templates[template];
|
|
8
8
|
if (!doc)
|
|
9
9
|
return null;
|
|
10
|
-
return _jsx("div", { className: `ecx-template ecx-template-${template} ecx-content`, children: await renderContent(doc, await contentContext(site, data.post, data)) });
|
|
10
|
+
return _jsx("div", { className: `ecx-template ecx-template-${template} ecx-content`, children: await renderContent(doc, { ...(await contentContext(site, data.post, data)), source: `template:${template}` }) });
|
|
11
11
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -214,6 +214,12 @@ export interface ResolveResult {
|
|
|
214
214
|
} | null;
|
|
215
215
|
/** Không cho máy tìm kiếm index. */
|
|
216
216
|
noIndex?: boolean;
|
|
217
|
+
/**
|
|
218
|
+
* Tham số truy vấn của URL (searchParams) của request hiện tại — server render đọc trực tiếp để
|
|
219
|
+
* lọc danh sách ngay trên máy chủ. Dùng cho bộ lọc người đọc chọn: `getPosts({ metaQuery: … })`
|
|
220
|
+
* rồi truyền `data.query` vào để giữ bộ lọc khi chuyển trang (xem `pageLinkWithQuery`).
|
|
221
|
+
*/
|
|
222
|
+
query?: Record<string, string>;
|
|
217
223
|
}
|
|
218
224
|
/** Props mà mọi template của theme nhận được. */
|
|
219
225
|
export interface TemplateProps {
|
|
@@ -277,6 +283,8 @@ export interface ThemeManifest {
|
|
|
277
283
|
template?: string;
|
|
278
284
|
locked?: boolean;
|
|
279
285
|
}>;
|
|
286
|
+
/** Nguồn ngoài website theme cần nạp (CSP): { "style-src": ["https://fonts.googleapis.com"], "font-src": ["https://fonts.gstatic.com"] }. */
|
|
287
|
+
csp?: Partial<Record<"script-src" | "style-src" | "img-src" | "font-src" | "connect-src" | "frame-src" | "media-src" | "worker-src" | "form-action", string[]>>;
|
|
280
288
|
[key: string]: unknown;
|
|
281
289
|
}
|
|
282
290
|
type Loader<T> = () => Promise<{
|
package/dist/utils.d.ts
CHANGED
|
@@ -5,4 +5,11 @@ export declare function formatDate(value: string | null | undefined, site?: Pick
|
|
|
5
5
|
}): string;
|
|
6
6
|
export declare function setting<T = string>(settings: Record<string, unknown>, key: string, fallback: T): T;
|
|
7
7
|
export declare function pageLink(basePath: string, page: number, search?: string | null): string;
|
|
8
|
+
/**
|
|
9
|
+
* Link của một trang trong danh sách CÓ bộ lọc: giữ nguyên mọi tham số truy vấn hiện có (phòng ban,
|
|
10
|
+
* mức lương, từ khoá…) và chỉ đổi số trang. Truyền `data.query` của trang vào.
|
|
11
|
+
*
|
|
12
|
+
* <Link href={pageLinkWithQuery(data.basePath, 2, data.query)}>2</Link>
|
|
13
|
+
*/
|
|
14
|
+
export declare function pageLinkWithQuery(basePath: string, page: number, query?: Record<string, string> | null): string;
|
|
8
15
|
export declare function cx(...classes: Array<string | false | null | undefined>): string;
|
package/dist/utils.js
CHANGED
|
@@ -24,6 +24,25 @@ export function pageLink(basePath, page, search) {
|
|
|
24
24
|
const path = page <= 1 ? base || "/" : `${base}/page/${page}`;
|
|
25
25
|
return search ? `${path}${path.includes("?") ? "&" : "?"}s=${encodeURIComponent(search)}` : path;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Link của một trang trong danh sách CÓ bộ lọc: giữ nguyên mọi tham số truy vấn hiện có (phòng ban,
|
|
29
|
+
* mức lương, từ khoá…) và chỉ đổi số trang. Truyền `data.query` của trang vào.
|
|
30
|
+
*
|
|
31
|
+
* <Link href={pageLinkWithQuery(data.basePath, 2, data.query)}>2</Link>
|
|
32
|
+
*/
|
|
33
|
+
export function pageLinkWithQuery(basePath, page, query) {
|
|
34
|
+
const base = basePath === "/" ? "" : basePath.replace(/\/$/, "");
|
|
35
|
+
const path = page <= 1 ? base || "/" : `${base}/page/${page}`;
|
|
36
|
+
const params = new URLSearchParams();
|
|
37
|
+
for (const [k, v] of Object.entries(query ?? {})) {
|
|
38
|
+
// "page" do đường dẫn quyết định; tham số rỗng không cần giữ
|
|
39
|
+
if (k === "page" || v === "" || v === undefined || v === null)
|
|
40
|
+
continue;
|
|
41
|
+
params.set(k, v);
|
|
42
|
+
}
|
|
43
|
+
const qs = params.toString();
|
|
44
|
+
return qs ? `${path}${path.includes("?") ? "&" : "?"}${qs}` : path;
|
|
45
|
+
}
|
|
27
46
|
export function cx(...classes) {
|
|
28
47
|
return classes.filter(Boolean).join(" ");
|
|
29
48
|
}
|