@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 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 } = {}) => cmsFetch("/posts", { tags: ["posts"], query: clean({ ...q, content: withContent ?? false, locale: q.locale ?? (await getRequestLocale()) }) });
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) }) });
@@ -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
- const link = (p) => pageLink(data.basePath, p, data.kind === "search" ? data.search : null);
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
- return a && (a.style || a.blockStyle || a.className || a.anchor) ? decorate(el, a, key, ctx) : el;
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);
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecomcx/ecomweb-theme-sdk",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
4
4
  "description": "SDK để viết theme và phần frontend của plugin cho Ecomweb",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",