@ecomcx/ecomweb-theme-sdk 1.29.0 → 1.31.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
@@ -203,6 +203,8 @@ export interface MediaQuery {
203
203
  /** Danh sách media (thư viện ảnh, tài liệu tải về) — plugin giới hạn / mở rộng qua filter media.query. */
204
204
  export declare const getMedia: ({ params, ...q }?: MediaQuery) => Promise<ListPage<Media>>;
205
205
  export declare const getPost: (type: string, slug: string, locale?: string) => Promise<Post | null>;
206
+ /** Bài đã đăng theo id (core ≥ 1.20.0 có hàm này; endpoint có từ trước). Không có / chưa đăng → null. */
207
+ export declare const getPostById: (id: number) => Promise<Post | null>;
206
208
  export declare const getTerms: (taxonomy: string, hideEmpty?: boolean, locale?: string) => Promise<Term[]>;
207
209
  /** Cookie giữ token bản nháp của Trình dựng (website đang ở chế độ xem trước). */
208
210
  export declare const TEMPLATE_DRAFT_COOKIE = "cms-template-draft";
package/dist/api.js CHANGED
@@ -161,6 +161,8 @@ export const getAuthors = async ({ params, ...q } = {}) => cmsFetch("/authors",
161
161
  /** Danh sách media (thư viện ảnh, tài liệu tải về) — plugin giới hạn / mở rộng qua filter media.query. */
162
162
  export const getMedia = async ({ params, ...q } = {}) => cmsFetch("/media", { tags: ["media"], query: clean({ ...params, ...q }) });
163
163
  export const getPost = async (type, slug, locale) => cmsFetch(`/posts/${type}/${encodeURIComponent(slug)}`, { tags: ["posts"], query: clean({ locale: await loc(locale) }) }).catch(() => null);
164
+ /** Bài đã đăng theo id (core ≥ 1.20.0 có hàm này; endpoint có từ trước). Không có / chưa đăng → null. */
165
+ export const getPostById = async (id) => cmsFetch(`/posts/id/${id}`, { tags: ["posts", `post:${id}`] }).catch(() => null);
164
166
  export const getTerms = async (taxonomy, hideEmpty = true, locale) => cmsFetch(`/terms/${taxonomy}`, { tags: ["terms"], query: clean({ hideEmpty, locale: await loc(locale) }) });
165
167
  /** Cookie giữ token bản nháp của Trình dựng (website đang ở chế độ xem trước). */
166
168
  export const TEMPLATE_DRAFT_COOKIE = "cms-template-draft";
@@ -28,6 +28,15 @@ export declare const AUTH_LABELS: {
28
28
  login2: string;
29
29
  registerClosed: string;
30
30
  missingToken: string;
31
+ bio: string;
32
+ currentPassword: string;
33
+ currentPasswordForEmail: string;
34
+ submitProfile: string;
35
+ submitPassword: string;
36
+ notSignedIn: string;
37
+ deleteWarning: string;
38
+ deleteConfirm: string;
39
+ submitDelete: string;
31
40
  };
32
41
  export type AuthLabels = typeof AUTH_LABELS;
33
42
  /**
@@ -52,6 +61,13 @@ export declare function LoginForm({ redirectTo, labels, links, paths, fields }:
52
61
  paths?: Partial<AuthPaths>;
53
62
  fields?: ReactNode;
54
63
  }): import("react").JSX.Element;
64
+ /** Nút "Đăng nhập bằng …" của plugin (ISignInProvider, core ≥ 1.20.0). Core cũ không trả providers → không có nút. */
65
+ export interface SignInProvider {
66
+ id: string;
67
+ label: string;
68
+ iconUrl?: string | null;
69
+ url: string;
70
+ }
55
71
  /** Đăng ký thành viên (chỉ khi quản trị viên bật "Ai cũng có thể đăng ký"). Vai trò = vai trò mặc định trong Cài đặt. */
56
72
  export declare function RegisterForm({ labels, links, paths, fields }: {
57
73
  labels?: Partial<AuthLabels>;
@@ -81,3 +97,24 @@ export declare function LogoutButton({ redirectTo, className, children }: {
81
97
  * Server Component nên dùng getCurrentUser() để khỏi tốn thêm một request.
82
98
  */
83
99
  export declare function useCurrentUser(): CurrentUser | null | undefined;
100
+ /**
101
+ * Sửa hồ sơ của thành viên đang đăng nhập (core ≥ 1.20.0): tên hiển thị, giới thiệu, email. Đổi email cần mật khẩu hiện tại.
102
+ * Đặt trong trang tài khoản của theme (vd template "account"); khách chưa đăng nhập thấy lời nhắn.
103
+ */
104
+ export declare function ProfileForm({ labels, fields, onSaved }: {
105
+ labels?: Partial<AuthLabels>;
106
+ fields?: ReactNode;
107
+ onSaved?(user: CurrentUser): void;
108
+ }): import("react").JSX.Element | null;
109
+ /** Đổi mật khẩu của thành viên đang đăng nhập (core ≥ 1.20.0); các thiết bị khác bị đăng xuất. */
110
+ export declare function ChangePasswordForm({ labels }: {
111
+ labels?: Partial<AuthLabels>;
112
+ }): import("react").JSX.Element;
113
+ /**
114
+ * Thành viên tự xoá tài khoản (core ≥ 1.20.0): nhập lại mật khẩu và đánh dấu xác nhận. Tài khoản quản trị không xoá được ở đây.
115
+ * Xong thì đăng xuất và chuyển về <paramref name="redirectTo"/>.
116
+ */
117
+ export declare function DeleteAccountForm({ labels, redirectTo }: {
118
+ labels?: Partial<AuthLabels>;
119
+ redirectTo?: string;
120
+ }): import("react").JSX.Element;
@@ -42,6 +42,11 @@ export const AUTH_LABELS = {
42
42
  sending: "Đang xử lý…", error: "Có lỗi xảy ra, vui lòng thử lại", logout: "Đăng xuất",
43
43
  noAccount: "Chưa có tài khoản?", register: "Đăng ký", forgot: "Quên mật khẩu?", haveAccount: "Đã có tài khoản?", login2: "Đăng nhập",
44
44
  registerClosed: "Website hiện không mở đăng ký thành viên.", missingToken: "Liên kết không hợp lệ — hãy yêu cầu đặt lại mật khẩu lần nữa.",
45
+ // Hồ sơ (core ≥ 1.20)
46
+ bio: "Giới thiệu", currentPassword: "Mật khẩu hiện tại", currentPasswordForEmail: "Mật khẩu hiện tại (cần khi đổi email)",
47
+ submitProfile: "Lưu hồ sơ", submitPassword: "Đổi mật khẩu", notSignedIn: "Bạn cần đăng nhập để xem trang này.",
48
+ deleteWarning: "Xoá tài khoản là vĩnh viễn: bạn không đăng nhập được nữa, bài viết của bạn không còn tác giả.",
49
+ deleteConfirm: "Tôi hiểu, xoá tài khoản của tôi", submitDelete: "Xoá tài khoản",
45
50
  };
46
51
  export const AUTH_PATHS = { login: "/login", register: "/register", forgotPassword: "/forgot-password", home: "/" };
47
52
  function Field({ label, children }) {
@@ -65,6 +70,7 @@ export function LoginForm({ redirectTo, labels, links = true, paths, fields }) {
65
70
  const [challenge, setChallenge] = useState(null);
66
71
  const [busy, setBusy] = useState(false);
67
72
  const [error, setError] = useState("");
73
+ const providers = useSignInProviders();
68
74
  async function onSubmit(e) {
69
75
  e.preventDefault();
70
76
  const fd = new FormData(e.currentTarget);
@@ -87,7 +93,14 @@ export function LoginForm({ redirectTo, labels, links = true, paths, fields }) {
87
93
  setBusy(false);
88
94
  }
89
95
  }
90
- return (_jsxs("form", { className: "auth-form login-form", onSubmit: onSubmit, children: [challenge ? (_jsx(Field, { label: L.code, children: _jsx("input", { name: "code", inputMode: "numeric", autoComplete: "one-time-code", required: true, maxLength: 12, autoFocus: true }) })) : (_jsxs(_Fragment, { children: [_jsx(Field, { label: L.login, children: _jsx("input", { name: "login", autoComplete: "username", required: true, maxLength: 200 }) }), _jsx(Field, { label: L.password, children: _jsx("input", { name: "password", type: "password", autoComplete: "current-password", required: true }) }), fields] })), _jsx("p", { children: _jsx("button", { type: "submit", disabled: busy, children: busy ? L.sending : challenge ? L.verify : L.submitLogin }) }), _jsx(Status, { error: error }), links && !challenge && (_jsxs("p", { className: "auth-links", children: [_jsx("a", { href: P.forgotPassword, children: L.forgot }), " \u00B7 ", L.noAccount, " ", _jsx("a", { href: P.register, children: L.register })] }))] }));
96
+ return (_jsxs("form", { className: "auth-form login-form", onSubmit: onSubmit, children: [challenge ? (_jsx(Field, { label: L.code, children: _jsx("input", { name: "code", inputMode: "numeric", autoComplete: "one-time-code", required: true, maxLength: 12, autoFocus: true }) })) : (_jsxs(_Fragment, { children: [_jsx(Field, { label: L.login, children: _jsx("input", { name: "login", autoComplete: "username", required: true, maxLength: 200 }) }), _jsx(Field, { label: L.password, children: _jsx("input", { name: "password", type: "password", autoComplete: "current-password", required: true }) }), fields] })), _jsx("p", { children: _jsx("button", { type: "submit", disabled: busy, children: busy ? L.sending : challenge ? L.verify : L.submitLogin }) }), _jsx(Status, { error: error }), !challenge && providers.length > 0 && (_jsx("p", { className: "auth-providers", children: providers.map((p) => (_jsxs("a", { className: `auth-provider auth-provider-${p.id}`, href: `${p.url}&returnTo=${encodeURIComponent(safeRedirect(redirectParam, redirectTo ?? P.home))}`, children: [p.iconUrl && _jsx("img", { src: p.iconUrl, alt: "", width: 18, height: 18 }), p.label] }, p.id))) })), links && !challenge && (_jsxs("p", { className: "auth-links", children: [_jsx("a", { href: P.forgotPassword, children: L.forgot }), " \u00B7 ", L.noAccount, " ", _jsx("a", { href: P.register, children: L.register })] }))] }));
97
+ }
98
+ function useSignInProviders() {
99
+ const [list, setList] = useState([]);
100
+ useEffect(() => {
101
+ authApi.get("/options").then(({ data }) => setList(data.providers ?? [])).catch(() => setList([]));
102
+ }, []);
103
+ return list;
91
104
  }
92
105
  /** Đăng ký thành viên (chỉ khi quản trị viên bật "Ai cũng có thể đăng ký"). Vai trò = vai trò mặc định trong Cài đặt. */
93
106
  export function RegisterForm({ labels, links = true, paths, fields }) {
@@ -204,3 +217,105 @@ export function useCurrentUser() {
204
217
  }, []);
205
218
  return user;
206
219
  }
220
+ /**
221
+ * Sửa hồ sơ của thành viên đang đăng nhập (core ≥ 1.20.0): tên hiển thị, giới thiệu, email. Đổi email cần mật khẩu hiện tại.
222
+ * Đặt trong trang tài khoản của theme (vd template "account"); khách chưa đăng nhập thấy lời nhắn.
223
+ */
224
+ export function ProfileForm({ labels, fields, onSaved }) {
225
+ const L = { ...AUTH_LABELS, ...labels };
226
+ const loaded = useCurrentUser();
227
+ // Bản đã lưu gần nhất: so email với bản này (không phải bản lúc mở trang) để lần lưu sau không đòi mật khẩu lại
228
+ const [saved, setSaved] = useState(null);
229
+ const user = saved ?? loaded;
230
+ const [busy, setBusy] = useState(false);
231
+ const [error, setError] = useState("");
232
+ const [message, setMessage] = useState("");
233
+ async function onSubmit(e) {
234
+ e.preventDefault();
235
+ const form = e.currentTarget;
236
+ const fd = new FormData(form);
237
+ const email = String(fd.get("email") ?? "").trim();
238
+ const bio = String(fd.get("bio") ?? "");
239
+ setBusy(true);
240
+ setError("");
241
+ setMessage("");
242
+ try {
243
+ const { data } = await authApi.post("/profile", {
244
+ body: {
245
+ displayName: fd.get("displayName"),
246
+ // Core không trả bio (cũ) và ô để trống → không gửi, tránh xoá giới thiệu đang có
247
+ bio: user?.bio === undefined && !bio ? null : bio,
248
+ email: email && email !== user?.email ? email : null, currentPassword: fd.get("currentPassword") || null,
249
+ },
250
+ });
251
+ const pw = form.elements.namedItem("currentPassword");
252
+ if (pw instanceof HTMLInputElement)
253
+ pw.value = "";
254
+ setSaved(data.user);
255
+ setMessage(data.message);
256
+ onSaved?.(data.user);
257
+ }
258
+ catch (err) {
259
+ setError(errorMessage(err, L.error));
260
+ }
261
+ finally {
262
+ setBusy(false);
263
+ }
264
+ }
265
+ if (user === undefined)
266
+ return null;
267
+ if (user === null)
268
+ return _jsx("p", { className: "auth-form profile-signed-out", children: L.notSignedIn });
269
+ return (_jsxs("form", { className: "auth-form profile-form", onSubmit: onSubmit, children: [_jsx(Field, { label: L.displayName, children: _jsx("input", { name: "displayName", autoComplete: "name", required: true, maxLength: 100, defaultValue: user.displayName }) }), _jsx(Field, { label: L.bio, children: _jsx("textarea", { name: "bio", rows: 3, maxLength: 2000, defaultValue: user.bio ?? "" }) }), _jsx(Field, { label: L.email, children: _jsx("input", { name: "email", type: "email", autoComplete: "email", required: true, maxLength: 200, defaultValue: user.email }) }), _jsx(Field, { label: L.currentPasswordForEmail, children: _jsx("input", { name: "currentPassword", type: "password", autoComplete: "current-password" }) }), fields, _jsx("p", { children: _jsx("button", { type: "submit", disabled: busy, children: busy ? L.sending : L.submitProfile }) }), _jsx(Status, { error: error, message: message })] }));
270
+ }
271
+ /** Đổi mật khẩu của thành viên đang đăng nhập (core ≥ 1.20.0); các thiết bị khác bị đăng xuất. */
272
+ export function ChangePasswordForm({ labels }) {
273
+ const L = { ...AUTH_LABELS, ...labels };
274
+ const [busy, setBusy] = useState(false);
275
+ const [error, setError] = useState("");
276
+ const [message, setMessage] = useState("");
277
+ async function onSubmit(e) {
278
+ e.preventDefault();
279
+ const form = e.currentTarget;
280
+ const fd = new FormData(form);
281
+ setBusy(true);
282
+ setError("");
283
+ setMessage("");
284
+ try {
285
+ const { data } = await authApi.post("/password", { body: { currentPassword: fd.get("currentPassword"), password: fd.get("password") } });
286
+ form.reset();
287
+ setMessage(data.message);
288
+ }
289
+ catch (err) {
290
+ setError(errorMessage(err, L.error));
291
+ }
292
+ finally {
293
+ setBusy(false);
294
+ }
295
+ }
296
+ return (_jsxs("form", { className: "auth-form password-form", onSubmit: onSubmit, children: [_jsx(Field, { label: L.currentPassword, children: _jsx("input", { name: "currentPassword", type: "password", autoComplete: "current-password", required: true }) }), _jsx(Field, { label: L.newPassword, children: _jsx("input", { name: "password", type: "password", autoComplete: "new-password", required: true, minLength: 10 }) }), _jsx("p", { children: _jsx("button", { type: "submit", disabled: busy, children: busy ? L.sending : L.submitPassword }) }), _jsx(Status, { error: error, message: message })] }));
297
+ }
298
+ /**
299
+ * Thành viên tự xoá tài khoản (core ≥ 1.20.0): nhập lại mật khẩu và đánh dấu xác nhận. Tài khoản quản trị không xoá được ở đây.
300
+ * Xong thì đăng xuất và chuyển về <paramref name="redirectTo"/>.
301
+ */
302
+ export function DeleteAccountForm({ labels, redirectTo = "/" }) {
303
+ const L = { ...AUTH_LABELS, ...labels };
304
+ const [busy, setBusy] = useState(false);
305
+ const [error, setError] = useState("");
306
+ async function onSubmit(e) {
307
+ e.preventDefault();
308
+ const fd = new FormData(e.currentTarget);
309
+ setBusy(true);
310
+ setError("");
311
+ try {
312
+ await authApi.post("/delete-account", { body: { password: fd.get("password") } });
313
+ window.location.assign(safeRedirect(redirectTo));
314
+ }
315
+ catch (err) {
316
+ setError(errorMessage(err, L.error));
317
+ setBusy(false);
318
+ }
319
+ }
320
+ return (_jsxs("form", { className: "auth-form delete-account-form", onSubmit: onSubmit, children: [_jsx("p", { className: "delete-account-warning", children: L.deleteWarning }), _jsx(Field, { label: L.password, children: _jsx("input", { name: "password", type: "password", autoComplete: "current-password", required: true }) }), _jsx("p", { className: "auth-field", children: _jsxs("label", { children: [_jsx("input", { name: "confirm", type: "checkbox", required: true }), " ", L.deleteConfirm] }) }), _jsx("p", { children: _jsx("button", { type: "submit", className: "danger", disabled: busy, children: busy ? L.sending : L.submitDelete }) }), _jsx(Status, { error: error })] }));
321
+ }
@@ -30,4 +30,7 @@ const LABEL_KEYS = {
30
30
  submitForgot: "auth.submitForgot", newPassword: "auth.newPassword", submitReset: "auth.submitReset", sending: "auth.sending",
31
31
  error: "auth.error", logout: "auth.logout", noAccount: "auth.noAccount", register: "auth.register", forgot: "auth.forgot",
32
32
  haveAccount: "auth.haveAccount", login2: "auth.login2", registerClosed: "auth.registerClosed", missingToken: "auth.missingToken",
33
+ bio: "auth.bio", currentPassword: "auth.currentPassword", currentPasswordForEmail: "auth.currentPasswordForEmail", submitProfile: "auth.submitProfile",
34
+ submitPassword: "auth.submitPassword", notSignedIn: "auth.notSignedIn", deleteWarning: "auth.deleteWarning", deleteConfirm: "auth.deleteConfirm",
35
+ submitDelete: "auth.submitDelete",
33
36
  };
@@ -9,6 +9,12 @@ export interface CurrentUser {
9
9
  displayName: string;
10
10
  roles: string[];
11
11
  capabilities: string[];
12
+ /** Giới thiệu (core ≥ 1.20.0). */
13
+ bio?: string | null;
12
14
  }
13
- /** Chỉ nhận đường dẫn nội bộ ("/account") — chặn chuyển hướng ra trang ngoài (?redirect=https://lừa-đảo). */
15
+ /**
16
+ * Chỉ nhận đường dẫn nội bộ ("/account") — chặn chuyển hướng ra trang ngoài (?redirect=https://lừa-đảo).
17
+ * Trình duyệt bỏ tab / xuống dòng và coi "\" như "/" khi phân tích URL, nên "/<TAB>/evil.com" hay "/\evil.com" thành "//evil.com":
18
+ * chặn mọi ký tự điều khiển và dấu "\" ở bất kỳ vị trí nào, không chỉ ở đầu.
19
+ */
14
20
  export declare function safeRedirect(target: string | null | undefined, fallback?: string): string;
@@ -1,7 +1,14 @@
1
1
  /** Tên cookie phiên đăng nhập của website (httpOnly, do frontend core đặt). Dùng chung cho proxy và SDK. */
2
2
  export const ACCESS_COOKIE = "cms_at";
3
3
  export const REFRESH_COOKIE = "cms_rt";
4
- /** Chỉ nhận đường dẫn nội bộ ("/account") — chặn chuyển hướng ra trang ngoài (?redirect=https://lừa-đảo). */
4
+ /**
5
+ * Chỉ nhận đường dẫn nội bộ ("/account") — chặn chuyển hướng ra trang ngoài (?redirect=https://lừa-đảo).
6
+ * Trình duyệt bỏ tab / xuống dòng và coi "\" như "/" khi phân tích URL, nên "/<TAB>/evil.com" hay "/\evil.com" thành "//evil.com":
7
+ * chặn mọi ký tự điều khiển và dấu "\" ở bất kỳ vị trí nào, không chỉ ở đầu.
8
+ */
5
9
  export function safeRedirect(target, fallback = "/") {
6
- return target && target.startsWith("/") && !target.startsWith("//") && !target.startsWith("/\\") ? target : fallback;
10
+ // eslint-disable-next-line no-control-regex
11
+ if (!target || !target.startsWith("/") || /[\u0000-\u001f\u007f\\]/.test(target))
12
+ return fallback;
13
+ return target.startsWith("//") ? fallback : target;
7
14
  }
@@ -0,0 +1,15 @@
1
+ import type { Post, ResolveResult, Site } from "./types.js";
2
+ /**
3
+ * Gắn dữ liệu (@ecomcx/ecomweb-content 1.15 — `attrs.bindings`): thuộc tính của khối lấy giá trị lúc hiển thị.
4
+ * Nguồn lõi: core/post (trường của bài đang vẽ / bài trong vòng lặp), core/post-meta (trường meta), core/site.
5
+ * Plugin thêm nguồn: definePlugin({ bindingSources: { "shop/product": async (args, ctx) => … } }).
6
+ */
7
+ export interface BindingContext {
8
+ site: Site;
9
+ post?: Post | null;
10
+ data?: ResolveResult | null;
11
+ }
12
+ export type BindingSource = (args: Record<string, string | number | boolean>, ctx: BindingContext) => unknown | Promise<unknown>;
13
+ /** Thuộc tính gắn được của khối lõi: tên trong bindings → nơi đặt giá trị. */
14
+ export declare const BINDABLE: Record<string, readonly string[]>;
15
+ export declare const CORE_BINDING_SOURCES: Record<string, BindingSource>;
@@ -0,0 +1,30 @@
1
+ /** Thuộc tính gắn được của khối lõi: tên trong bindings → nơi đặt giá trị. */
2
+ export const BINDABLE = {
3
+ paragraph: ["content"], heading: ["content"], image: ["src", "alt", "caption"], button: ["text", "href"],
4
+ };
5
+ const scalar = (v) => (typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v) : undefined);
6
+ export const CORE_BINDING_SOURCES = {
7
+ "core/post": (args, { post }) => {
8
+ if (!post)
9
+ return undefined;
10
+ switch (args.field) {
11
+ case "title": return post.title;
12
+ case "excerpt": return post.excerpt;
13
+ case "date": return post.publishedAt ? new Date(post.publishedAt).toLocaleDateString(post.locale || "vi") : undefined;
14
+ case "author": return post.author?.name;
15
+ case "link": return post.link;
16
+ case "featuredImage": return post.featuredMedia?.url;
17
+ default: return undefined;
18
+ }
19
+ },
20
+ "core/post-meta": (args, { post }) => (typeof args.key === "string" && post?.meta ? scalar(post.meta[args.key]) : undefined),
21
+ "core/site": (args, { site }) => {
22
+ switch (args.field) {
23
+ case "title": return site.title;
24
+ case "tagline": return site.tagline;
25
+ case "url": return site.url;
26
+ case "logo": return scalar(site.themeSettings?.logo?.url);
27
+ default: return undefined;
28
+ }
29
+ },
30
+ };
@@ -0,0 +1,56 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ClientMessages, Translator } from "./i18n.js";
3
+ import type { BrowserEvents } from "./client.js";
4
+ /** Lỗi trả về từ endpoint của plugin: status HTTP, lời nhắn (title), lỗi theo từng trường (422), mã lỗi (code). */
5
+ export declare class PluginApiError extends Error {
6
+ readonly status: number;
7
+ readonly errors?: Record<string, string[]> | undefined;
8
+ readonly code?: string | undefined;
9
+ readonly data?: unknown | undefined;
10
+ constructor(status: number, message: string, errors?: Record<string, string[]> | undefined, code?: string | undefined, data?: unknown | undefined);
11
+ }
12
+ export interface PluginRequestOptions {
13
+ query?: Record<string, string | number | boolean | null | undefined>;
14
+ headers?: Record<string, string>;
15
+ signal?: AbortSignal;
16
+ }
17
+ export interface PluginClient {
18
+ get<T = unknown>(path: string, opts?: PluginRequestOptions): Promise<T>;
19
+ post<T = unknown>(path: string, body?: unknown, opts?: PluginRequestOptions): Promise<T>;
20
+ put<T = unknown>(path: string, body?: unknown, opts?: PluginRequestOptions): Promise<T>;
21
+ delete<T = unknown>(path: string, opts?: PluginRequestOptions): Promise<T>;
22
+ }
23
+ /**
24
+ * Gọi endpoint của plugin từ component client — cùng endpoint với `pluginFetch` phía server, qua proxy của website
25
+ * (`/api/cms/plugins/{slug}/…`): gửi kèm phiên của người đang đăng nhập (ICurrentUser ở backend), chặn gọi từ trang khác.
26
+ * Lỗi → `PluginApiError` (status, title, errors theo trường).
27
+ *
28
+ * const shop = pluginClient("shop");
29
+ * const cart = await shop.post<Cart>("/cart/items", { productId, qty: 1 });
30
+ */
31
+ export declare function pluginClient(slug: string): PluginClient;
32
+ type EventName = keyof BrowserEvents extends never ? string : keyof BrowserEvents | (string & {});
33
+ type EventData<K> = K extends keyof BrowserEvents ? BrowserEvents[K] : unknown;
34
+ /**
35
+ * Phát sự kiện cho mọi component trên trang (vd plugin giỏ hàng báo "đã thêm" để mini-cart của theme cập nhật).
36
+ * `crossTab: true`: các tab khác của cùng website cũng nhận (BroadcastChannel).
37
+ */
38
+ export declare function emit<K extends EventName>(name: K, detail?: EventData<K>, opts?: {
39
+ crossTab?: boolean;
40
+ }): void;
41
+ /** Nghe sự kiện; trả hàm huỷ. Nhận cả sự kiện từ tab khác nếu bên phát dùng crossTab. */
42
+ export declare function on<K extends EventName>(name: K, handler: (detail: EventData<K>) => void): () => void;
43
+ /** Hook React của `on`: tự huỷ khi component gỡ; handler mới nhất luôn được gọi (không cần useCallback). */
44
+ export declare function useEvent<K extends EventName>(name: K, handler: (detail: EventData<K>) => void): void;
45
+ /**
46
+ * Chuyển chuỗi đã dịch xuống component client. Server Component lấy bằng `getClientMessages(["cart."])` rồi bọc:
47
+ * <TranslationsProvider value={await getClientMessages(["cart."])}><CartButton /></TranslationsProvider>
48
+ * Nhiều provider lồng nhau được gộp (provider trong thắng).
49
+ */
50
+ export declare function TranslationsProvider({ value, children }: {
51
+ value: ClientMessages;
52
+ children: ReactNode;
53
+ }): import("react").JSX.Element;
54
+ /** Hàm dịch trong component client — cùng cú pháp ICU với `getT`. Khoá không có trong provider → trả lại chính khoá. */
55
+ export declare function useT(): Translator;
56
+ export {};
@@ -0,0 +1,132 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useEffect, useMemo, useRef } from "react";
4
+ import { formatMessage } from "./i18n-format.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Gọi endpoint của plugin từ trình duyệt (core ≥ 1.20.0)
7
+ // ---------------------------------------------------------------------------
8
+ /** Lỗi trả về từ endpoint của plugin: status HTTP, lời nhắn (title), lỗi theo từng trường (422), mã lỗi (code). */
9
+ export class PluginApiError extends Error {
10
+ status;
11
+ errors;
12
+ code;
13
+ data;
14
+ constructor(status, message, errors, code, data) {
15
+ super(message);
16
+ this.status = status;
17
+ this.errors = errors;
18
+ this.code = code;
19
+ this.data = data;
20
+ this.name = "PluginApiError";
21
+ }
22
+ }
23
+ /**
24
+ * Gọi endpoint của plugin từ component client — cùng endpoint với `pluginFetch` phía server, qua proxy của website
25
+ * (`/api/cms/plugins/{slug}/…`): gửi kèm phiên của người đang đăng nhập (ICurrentUser ở backend), chặn gọi từ trang khác.
26
+ * Lỗi → `PluginApiError` (status, title, errors theo trường).
27
+ *
28
+ * const shop = pluginClient("shop");
29
+ * const cart = await shop.post<Cart>("/cart/items", { productId, qty: 1 });
30
+ */
31
+ export function pluginClient(slug) {
32
+ if (!/^[a-z0-9][a-z0-9-]{1,63}$/.test(slug))
33
+ throw new Error(`pluginClient: slug không hợp lệ "${slug}"`);
34
+ async function send(method, path, body, opts = {}) {
35
+ const qs = new URLSearchParams();
36
+ for (const [k, v] of Object.entries(opts.query ?? {}))
37
+ if (v !== undefined && v !== null)
38
+ qs.set(k, String(v));
39
+ const url = `/api/cms/plugins/${slug}${path.startsWith("/") ? path : `/${path}`}${qs.size ? `?${qs}` : ""}`;
40
+ const res = await fetch(url, {
41
+ method, signal: opts.signal, credentials: "same-origin", cache: "no-store",
42
+ headers: { accept: "application/json", ...(body !== undefined ? { "content-type": "application/json" } : {}), ...opts.headers },
43
+ body: body === undefined ? undefined : JSON.stringify(body),
44
+ });
45
+ const text = await res.text();
46
+ let data = undefined;
47
+ if (text) {
48
+ try {
49
+ data = JSON.parse(text);
50
+ }
51
+ catch {
52
+ data = text;
53
+ }
54
+ }
55
+ if (!res.ok) {
56
+ const d = (typeof data === "object" && data);
57
+ throw new PluginApiError(res.status, (d && (d.title ?? d.detail)) || res.statusText || `HTTP ${res.status}`, d ? d.errors : undefined, d ? d.code : undefined, data);
58
+ }
59
+ return data;
60
+ }
61
+ return {
62
+ get: (path, opts) => send("GET", path, undefined, opts),
63
+ post: (path, body, opts) => send("POST", path, body, opts),
64
+ put: (path, body, opts) => send("PUT", path, body, opts),
65
+ delete: (path, opts) => send("DELETE", path, undefined, opts),
66
+ };
67
+ }
68
+ const PREFIX = "ecx:";
69
+ let channel;
70
+ function tabs() {
71
+ if (channel === undefined) {
72
+ channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel("ecx-events");
73
+ // Node (test, render phía server): kênh không được giữ tiến trình sống
74
+ channel?.unref?.();
75
+ }
76
+ return channel;
77
+ }
78
+ /**
79
+ * Phát sự kiện cho mọi component trên trang (vd plugin giỏ hàng báo "đã thêm" để mini-cart của theme cập nhật).
80
+ * `crossTab: true`: các tab khác của cùng website cũng nhận (BroadcastChannel).
81
+ */
82
+ export function emit(name, detail, opts) {
83
+ if (typeof window === "undefined")
84
+ return;
85
+ window.dispatchEvent(new CustomEvent(PREFIX + String(name), { detail }));
86
+ if (opts?.crossTab)
87
+ tabs()?.postMessage({ name: String(name), detail });
88
+ }
89
+ /** Nghe sự kiện; trả hàm huỷ. Nhận cả sự kiện từ tab khác nếu bên phát dùng crossTab. */
90
+ export function on(name, handler) {
91
+ if (typeof window === "undefined")
92
+ return () => { };
93
+ const local = (e) => handler(e.detail);
94
+ const remote = (e) => { if (e.data?.name === name)
95
+ handler(e.data.detail); };
96
+ window.addEventListener(PREFIX + String(name), local);
97
+ tabs()?.addEventListener("message", remote);
98
+ return () => { window.removeEventListener(PREFIX + String(name), local); tabs()?.removeEventListener("message", remote); };
99
+ }
100
+ /** Hook React của `on`: tự huỷ khi component gỡ; handler mới nhất luôn được gọi (không cần useCallback). */
101
+ export function useEvent(name, handler) {
102
+ const ref = useRef(handler);
103
+ useEffect(() => { ref.current = handler; });
104
+ useEffect(() => on(name, (d) => ref.current(d)), [name]);
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Dịch chữ trong component client (core ≥ 1.20.0)
108
+ // ---------------------------------------------------------------------------
109
+ const TranslationsContext = createContext(null);
110
+ /**
111
+ * Chuyển chuỗi đã dịch xuống component client. Server Component lấy bằng `getClientMessages(["cart."])` rồi bọc:
112
+ * <TranslationsProvider value={await getClientMessages(["cart."])}><CartButton /></TranslationsProvider>
113
+ * Nhiều provider lồng nhau được gộp (provider trong thắng).
114
+ */
115
+ export function TranslationsProvider({ value, children }) {
116
+ const parent = useContext(TranslationsContext);
117
+ const merged = useMemo(() => parent ? { locale: value.locale, messages: { ...parent.messages, ...value.messages } } : value, [parent, value]);
118
+ return _jsx(TranslationsContext.Provider, { value: merged, children: children });
119
+ }
120
+ /** Hàm dịch trong component client — cùng cú pháp ICU với `getT`. Khoá không có trong provider → trả lại chính khoá. */
121
+ export function useT() {
122
+ const ctx = useContext(TranslationsContext);
123
+ return useMemo(() => {
124
+ const compiled = new Map();
125
+ const t = ((key, vars) => {
126
+ const hit = ctx?.messages[key];
127
+ return hit ? formatMessage(hit.text, hit.code, vars, compiled, `${hit.code}\0${key}`) : key;
128
+ });
129
+ t.locale = ctx?.locale ?? "en";
130
+ return t;
131
+ }, [ctx]);
132
+ }
package/dist/client.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { Component } from "react";
2
2
  import type { Comment } from "./types.js";
3
3
  import type { AnchorHTMLAttributes, ReactNode } from "react";
4
+ export { pluginClient, PluginApiError, emit, on, useEvent, TranslationsProvider, useT, type PluginClient, type PluginRequestOptions } from "./browser.js";
5
+ /**
6
+ * Bảng tên sự kiện phía trình duyệt → dữ liệu (emit / on / useEvent, core ≥ 1.20.0). Theme / plugin khai báo kiểu cho sự kiện của mình:
7
+ * declare module "@ecomcx/ecomweb-theme-sdk/client" { interface BrowserEvents { "shop.cart.changed": { count: number } } }
8
+ * Tên nên có tiền tố slug plugin (như hook backend).
9
+ */
10
+ export interface BrowserEvents {
11
+ }
4
12
  export declare class ExtensionBoundary extends Component<{
5
13
  name: string;
6
14
  children?: ReactNode;
@@ -98,5 +106,5 @@ export declare function LanguageSwitcherClient({ languages, current, defaultLoca
98
106
  className?: string;
99
107
  variant: "list" | "select";
100
108
  }): import("react").JSX.Element;
101
- export { LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, LogoutButton, useCurrentUser, extraFields, AUTH_LABELS, AUTH_PATHS, type AuthLabels, type AuthPaths } from "./auth-forms.js";
109
+ export { LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, LogoutButton, useCurrentUser, extraFields, ProfileForm, ChangePasswordForm, DeleteAccountForm, AUTH_LABELS, AUTH_PATHS, type AuthLabels, type AuthPaths, type SignInProvider } from "./auth-forms.js";
102
110
  export { safeRedirect, type CurrentUser } from "./auth-shared.js";
package/dist/client.js CHANGED
@@ -7,6 +7,7 @@ import NextLink, { useLinkStatus } from "next/link";
7
7
  import NextForm from "next/form";
8
8
  import { cx, formatDate } from "./utils.js";
9
9
  import { createFetchKit, isFetchKitError } from "next-fetch-kit";
10
+ export { pluginClient, PluginApiError, emit, on, useEvent, TranslationsProvider, useT } from "./browser.js";
10
11
  // ---------------------------------------------------------------------------
11
12
  // Cô lập lỗi của plugin/theme: phần nào ném lỗi khi hiển thị thì ẩn phần đó, trang vẫn hiện bình thường
12
13
  // ---------------------------------------------------------------------------
@@ -187,5 +188,5 @@ export function LanguageSwitcherClient({ languages, current, defaultLocale, labe
187
188
  return (_jsx("select", { className: className ?? "lang-switcher", "aria-label": label, value: current, onChange: (e) => { window.location.href = href(e.target.value); }, children: languages.map((l) => _jsxs("option", { value: l.code, children: [l.flag ? `${l.flag} ` : "", l.name] }, l.code)) }));
188
189
  return (_jsx("nav", { className: className ?? "lang-switcher", "aria-label": label, children: _jsx("ul", { children: languages.map((l) => (_jsx("li", { className: l.code === current ? "current-lang" : undefined, children: _jsxs("a", { href: href(l.code), hrefLang: l.code, lang: l.code, "aria-current": l.code === current ? "true" : undefined, children: [l.flag && _jsxs("span", { "aria-hidden": true, children: [l.flag, " "] }), l.code.toUpperCase()] }) }, l.code))) }) }));
189
190
  }
190
- export { LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, LogoutButton, useCurrentUser, extraFields, AUTH_LABELS, AUTH_PATHS } from "./auth-forms.js";
191
+ export { LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, LogoutButton, useCurrentUser, extraFields, ProfileForm, ChangePasswordForm, DeleteAccountForm, AUTH_LABELS, AUTH_PATHS } from "./auth-forms.js";
191
192
  export { safeRedirect } from "./auth-shared.js";
package/dist/content.js CHANGED
@@ -2,6 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { ExtensionBoundary, PostPasswordForm } from "./client.js";
3
3
  import { applyFilters, findBlock, findRegion, findShortcode, getActiveExtensions } from "./runtime.js";
4
4
  import { getPattern, getSite, getTemplates } from "./api.js";
5
+ import { CORE_BINDING_SOURCES } from "./bindings.js";
5
6
  import { getT } from "./i18n.js";
6
7
  import { renderDynamic } from "./dynamic.js";
7
8
  import { renderContent } from "./rich.js";
@@ -57,5 +58,10 @@ export async function contentContext(site, post, data) {
57
58
  parts: async (slug) => (await getTemplates().catch(() => null))?.parts[slug] ?? null,
58
59
  region: (name, fallback) => regionOverride(name, fallback, site, data),
59
60
  pattern: (ref) => getPattern(ref),
61
+ binding: async (source, args, c) => {
62
+ const fn = CORE_BINDING_SOURCES[source] ?? (await getActiveExtensions(site)).map((d) => d.bindingSources?.[source]).find(Boolean);
63
+ // Bài của vòng lặp (query) / bài đang xem / bài của nội dung
64
+ return fn ? fn(args, { site, post: c.post ?? post, data: c.data ?? data }) : undefined;
65
+ },
60
66
  };
61
67
  }
package/dist/dynamic.js CHANGED
@@ -78,6 +78,55 @@ export async function renderDynamic(node, key, ctx, children) {
78
78
  }
79
79
  case "comments":
80
80
  return post && ctx.data?.post?.id === post.id ? _jsx(Comments, { post: post }, key) : null;
81
+ // H1 của trang lưu trữ / tìm kiếm / tác giả (trang chủ dạng danh sách bài: tên trang)
82
+ case "queryTitle": {
83
+ const d = ctx.data;
84
+ if (!d)
85
+ return null;
86
+ const t = await getT();
87
+ const Tag = `h${Math.min(6, Math.max(1, a.level ?? 1))}`;
88
+ const text = d.search != null ? t("query.search", { q: d.search })
89
+ : d.term ? (a.prefix ? t(d.term.taxonomy === "post_tag" ? "query.tag" : "query.category", { name: d.term.name }) : d.term.name)
90
+ : d.author ? (a.prefix ? t("query.author", { name: d.author.name }) : d.author.name)
91
+ : d.title || site.title;
92
+ return _jsx(Tag, { className: "ecx-query-title", style: align(a), children: text }, key);
93
+ }
94
+ case "termDescription":
95
+ return ctx.data?.term?.description ? _jsx("div", { className: "ecx-term-description", style: align(a), children: ctx.data.term.description }, key) : null;
96
+ case "breadcrumbs": {
97
+ const d = ctx.data;
98
+ if (!d)
99
+ return null;
100
+ const t = await getT();
101
+ const items = [];
102
+ if (a.showHome !== false)
103
+ items.push({ name: t("breadcrumbs.home"), href: localeHref(site, "/") });
104
+ const cat = d.post?.terms?.category?.[0];
105
+ if (d.post && cat)
106
+ items.push({ name: cat.name, href: cat.link });
107
+ const current = d.post?.title ?? d.term?.name ?? d.author?.name ?? (d.search != null ? t("query.search", { q: d.search }) : d.title);
108
+ if (a.showCurrent !== false && current)
109
+ items.push({ name: current });
110
+ if (items.length < 2)
111
+ return null;
112
+ const base = (site.url ?? "").replace(/\/$/, "");
113
+ // Schema BreadcrumbList: Google hiện đường dẫn thay cho URL trong kết quả tìm kiếm
114
+ const jsonLd = { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: items.map((it, i) => ({ "@type": "ListItem", position: i + 1, name: it.name, ...(it.href ? { item: /^https?:/.test(it.href) ? it.href : base + it.href } : {}) })) };
115
+ return (_jsxs("nav", { className: "ecx-breadcrumbs", "aria-label": t("breadcrumbs.label"), children: [_jsx("ol", { children: items.map((it, i) => _jsxs("li", { children: [i > 0 && _jsx("span", { className: "ecx-breadcrumbs-sep", "aria-hidden": "true", children: a.separator ?? "›" }), it.href ? _jsx(Link, { href: it.href, children: it.name }) : _jsx("span", { "aria-current": "page", children: it.name })] }, i)) }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") } })] }, key));
116
+ }
117
+ case "postNavigation": {
118
+ const cur = ctx.data?.post;
119
+ if (!cur || !cur.publishedAt)
120
+ return null;
121
+ const t = await getT();
122
+ const [prev, next] = await Promise.all([
123
+ getPosts({ type: cur.type, perPage: 1, before: cur.publishedAt, orderBy: "date", order: "desc", exclude: [cur.id] }).then((r) => r.items[0]).catch(() => undefined),
124
+ getPosts({ type: cur.type, perPage: 1, after: cur.publishedAt, orderBy: "date", order: "asc", exclude: [cur.id] }).then((r) => r.items[0]).catch(() => undefined),
125
+ ]);
126
+ if (!prev && !next)
127
+ return null;
128
+ return (_jsxs("nav", { className: "ecx-post-navigation", "aria-label": t("postNav.label"), children: [prev ? _jsxs(Link, { className: "ecx-post-navigation-prev", href: prev.link, rel: "prev", children: [_jsx("span", { children: t("postNav.previous") }), a.showTitle !== false && _jsx("strong", { children: prev.title })] }) : _jsx("span", {}), next ? _jsxs(Link, { className: "ecx-post-navigation-next", href: next.link, rel: "next", children: [_jsx("span", { children: t("postNav.next") }), a.showTitle !== false && _jsx("strong", { children: next.title })] }) : _jsx("span", {})] }, key));
129
+ }
81
130
  case "query": {
82
131
  let posts;
83
132
  let data;
@@ -0,0 +1,6 @@
1
+ import { IntlMessageFormat } from "intl-messageformat";
2
+ /**
3
+ * Định dạng một thông điệp ICU (dùng chung cho getT phía server và useT phía trình duyệt).
4
+ * Thiếu biến hoặc cú pháp sai → thay {biến} thô, không ném lỗi.
5
+ */
6
+ export declare function formatMessage(src: string, locale: string, vars: Record<string, string | number> | undefined, compiled: Map<string, IntlMessageFormat | null>, id: string): string;
@@ -0,0 +1,28 @@
1
+ import { IntlMessageFormat } from "intl-messageformat";
2
+ /**
3
+ * Định dạng một thông điệp ICU (dùng chung cho getT phía server và useT phía trình duyệt).
4
+ * Thiếu biến hoặc cú pháp sai → thay {biến} thô, không ném lỗi.
5
+ */
6
+ export function formatMessage(src, locale, vars, compiled, id) {
7
+ if (!src.includes("{") && !src.includes("'"))
8
+ return src;
9
+ if (!compiled.has(id)) {
10
+ try {
11
+ compiled.set(id, new IntlMessageFormat(src, locale, undefined, { ignoreTag: true }));
12
+ }
13
+ catch {
14
+ compiled.set(id, null);
15
+ }
16
+ }
17
+ const f = compiled.get(id);
18
+ try {
19
+ if (f)
20
+ return String(f.format(vars));
21
+ }
22
+ catch { /* thiếu biến → thay thô */ }
23
+ let s = src;
24
+ if (vars)
25
+ for (const [k, v] of Object.entries(vars))
26
+ s = s.replaceAll(`{${k}}`, String(v));
27
+ return s;
28
+ }
package/dist/i18n.d.ts CHANGED
@@ -3,9 +3,17 @@ export declare const BUILTIN: Record<string, Record<string, string>>;
3
3
  export type Translator = ((key: string, vars?: Record<string, string | number>) => string) & {
4
4
  locale: string;
5
5
  };
6
+ export declare const getT: () => Promise<Translator>;
7
+ /** Chuỗi đã chọn đúng ngôn ngữ để truyền xuống trình duyệt (<TranslationsProvider>). */
8
+ export interface ClientMessages {
9
+ locale: string;
10
+ messages: Record<string, {
11
+ code: string;
12
+ text: string;
13
+ }>;
14
+ }
6
15
  /**
7
- * Hàm dịch chữ cho ngôn ngữ hiện tại. Thông điệp theo ICU MessageFormat: biến `{name}`, số nhiều
8
- * `{count, plural, one {# comment} other {# comments}}`, chọn `{gender, select, …}`.
9
- * Thứ tự tra: locales của theme (con → cha) → chữ có sẵn của SDK; ngôn ngữ hiện tại → mặc định → tiếng Anh → chính key.
16
+ * Lấy các chuỗi cần cho component client (core ≥ 1.20.0) — chỉ khoá bắt đầu bằng một trong <paramref name="prefixes"/>
17
+ * (vd ["cart.", "auth."]) để không gửi cả từ điển xuống trình duyệt. Truyền kết quả vào <TranslationsProvider value={…}>.
10
18
  */
11
- export declare const getT: () => Promise<Translator>;
19
+ export declare function getClientMessages(prefixes: string[]): Promise<ClientMessages>;
package/dist/i18n.js CHANGED
@@ -1,11 +1,11 @@
1
- import { IntlMessageFormat } from "intl-messageformat";
2
1
  import { cache } from "react";
3
2
  import { getSite } from "./api.js";
4
3
  import { getActiveTheme } from "./runtime.js";
4
+ import { formatMessage } from "./i18n-format.js";
5
5
  /** Chữ mặc định của các component trong SDK (theme có thể ghi đè trong locales/{code}.json). */
6
6
  export const BUILTIN = {
7
7
  vi: {
8
- "search.placeholder": "Tìm kiếm…", "search.button": "Tìm", "search.label": "Từ khoá", "navigation.menu": "Menu", "toc.title": "Mục lục",
8
+ "search.placeholder": "Tìm kiếm…", "search.button": "Tìm", "search.label": "Từ khoá", "navigation.menu": "Menu", "toc.title": "Mục lục", "query.search": "Kết quả tìm kiếm: {q}", "query.category": "Chuyên mục: {name}", "query.tag": "Thẻ: {name}", "query.author": "Tác giả: {name}", "breadcrumbs.home": "Trang chủ", "breadcrumbs.label": "Đường dẫn", "postNav.previous": "Bài trước", "postNav.next": "Bài sau", "postNav.label": "Bài khác",
9
9
  "pagination.prev": "« Trước", "pagination.next": "Sau »", "pagination.label": "Phân trang",
10
10
  "comments.title": "Bình luận", "comments.closed": "Bình luận đã đóng.",
11
11
  "comments.form.title": "Để lại bình luận", "comments.form.content": "Bình luận", "comments.form.name": "Tên",
@@ -24,9 +24,13 @@ export const BUILTIN = {
24
24
  "auth.login2": "Đăng nhập", "auth.registerClosed": "Website hiện không mở đăng ký thành viên.",
25
25
  "auth.missingToken": "Liên kết không hợp lệ — hãy yêu cầu đặt lại mật khẩu lần nữa.", "auth.signedInAs": "Bạn đang đăng nhập với tên {name}.",
26
26
  "file.download": "Tải tệp", "form.submit": "Gửi", "form.sending": "Đang gửi…", "form.error": "Không gửi được, vui lòng thử lại",
27
+ "auth.bio": "Giới thiệu", "auth.currentPassword": "Mật khẩu hiện tại", "auth.currentPasswordForEmail": "Mật khẩu hiện tại (cần khi đổi email)",
28
+ "auth.submitProfile": "Lưu hồ sơ", "auth.submitPassword": "Đổi mật khẩu", "auth.notSignedIn": "Bạn cần đăng nhập để xem trang này.",
29
+ "auth.deleteWarning": "Xoá tài khoản là vĩnh viễn: bạn không đăng nhập được nữa, bài viết của bạn không còn tác giả.",
30
+ "auth.deleteConfirm": "Tôi hiểu, xoá tài khoản của tôi", "auth.submitDelete": "Xoá tài khoản",
27
31
  },
28
32
  en: {
29
- "search.placeholder": "Search…", "search.button": "Search", "search.label": "Keyword", "navigation.menu": "Menu", "toc.title": "Contents",
33
+ "search.placeholder": "Search…", "search.button": "Search", "search.label": "Keyword", "navigation.menu": "Menu", "toc.title": "Contents", "query.search": "Search results: {q}", "query.category": "Category: {name}", "query.tag": "Tag: {name}", "query.author": "Author: {name}", "breadcrumbs.home": "Home", "breadcrumbs.label": "Breadcrumb", "postNav.previous": "Previous", "postNav.next": "Next", "postNav.label": "More posts",
30
34
  "pagination.prev": "« Previous", "pagination.next": "Next »", "pagination.label": "Pagination",
31
35
  "comments.title": "Comments", "comments.closed": "Comments are closed.",
32
36
  "comments.form.title": "Leave a comment", "comments.form.content": "Comment", "comments.form.name": "Name",
@@ -45,6 +49,10 @@ export const BUILTIN = {
45
49
  "auth.login2": "Log in", "auth.registerClosed": "Registration is currently closed.",
46
50
  "auth.missingToken": "Invalid link — please request a new password reset.", "auth.signedInAs": "You are logged in as {name}.",
47
51
  "file.download": "Download", "form.submit": "Send", "form.sending": "Sending…", "form.error": "Could not send, please try again",
52
+ "auth.bio": "Bio", "auth.currentPassword": "Current password", "auth.currentPasswordForEmail": "Current password (needed to change your email)",
53
+ "auth.submitProfile": "Save profile", "auth.submitPassword": "Change password", "auth.notSignedIn": "Please log in to see this page.",
54
+ "auth.deleteWarning": "Deleting your account is permanent: you can no longer log in and your posts will have no author.",
55
+ "auth.deleteConfirm": "I understand, delete my account", "auth.submitDelete": "Delete account",
48
56
  },
49
57
  };
50
58
  /**
@@ -52,7 +60,8 @@ export const BUILTIN = {
52
60
  * `{count, plural, one {# comment} other {# comments}}`, chọn `{gender, select, …}`.
53
61
  * Thứ tự tra: locales của theme (con → cha) → chữ có sẵn của SDK; ngôn ngữ hiện tại → mặc định → tiếng Anh → chính key.
54
62
  */
55
- export const getT = cache(async () => {
63
+ /** Từ điển theo thứ tự tra cho ngôn ngữ hiện tại (theme con → cha → SDK; hiện tại → mặc định → tiếng Anh). */
64
+ const getDicts = cache(async () => {
56
65
  const site = await getSite();
57
66
  const theme = await getActiveTheme(site);
58
67
  const order = [...new Set([site.locale, site.defaultLocale, "en"])];
@@ -70,35 +79,28 @@ export const getT = cache(async () => {
70
79
  if (BUILTIN[code])
71
80
  dicts.push({ code, dict: BUILTIN[code] });
72
81
  }
82
+ return { site, dicts };
83
+ });
84
+ export const getT = cache(async () => {
85
+ const { site, dicts } = await getDicts();
73
86
  const compiled = new Map();
74
87
  const t = ((key, vars) => {
75
88
  const hit = dicts.find((d) => d.dict[key] !== undefined);
76
- if (!hit)
77
- return key;
78
- const src = hit.dict[key];
79
- if (!src.includes("{") && !src.includes("'"))
80
- return src;
81
- const id = `${hit.code}\0${key}`;
82
- if (!compiled.has(id)) {
83
- try {
84
- compiled.set(id, new IntlMessageFormat(src, hit.code, undefined, { ignoreTag: true }));
85
- }
86
- catch {
87
- compiled.set(id, null);
88
- }
89
- }
90
- const f = compiled.get(id);
91
- try {
92
- if (f)
93
- return String(f.format(vars));
94
- }
95
- catch { /* thiếu biến → thay thô như bản cũ */ }
96
- let s = src;
97
- if (vars)
98
- for (const [k, v] of Object.entries(vars))
99
- s = s.replaceAll(`{${k}}`, String(v));
100
- return s;
89
+ return hit ? formatMessage(hit.dict[key], hit.code, vars, compiled, `${hit.code}\0${key}`) : key;
101
90
  });
102
91
  t.locale = site.locale;
103
92
  return t;
104
93
  });
94
+ /**
95
+ * Lấy các chuỗi cần cho component client (core ≥ 1.20.0) — chỉ khoá bắt đầu bằng một trong <paramref name="prefixes"/>
96
+ * (vd ["cart.", "auth."]) để không gửi cả từ điển xuống trình duyệt. Truyền kết quả vào <TranslationsProvider value={…}>.
97
+ */
98
+ export async function getClientMessages(prefixes) {
99
+ const { site, dicts } = await getDicts();
100
+ const messages = {};
101
+ for (const d of dicts)
102
+ for (const [key, text] of Object.entries(d.dict))
103
+ if (!(key in messages) && prefixes.some((p) => key.startsWith(p)))
104
+ messages[key] = { code: d.code, text };
105
+ return { locale: site.locale, messages };
106
+ }
package/dist/index.d.ts CHANGED
@@ -5,15 +5,16 @@ export { definePlugin, getPlugin, hasPlugin, applyFilters, doAction, getSlotItem
5
5
  export { PostContent, EditableRegion } from "./content.js";
6
6
  export { Img, imageCandidates, type ImgProps } from "./image.js";
7
7
  export { Menu, WidgetArea, Slot, SearchForm, Pagination, FeaturedImage, PostDate, TermLinks, Comments, LanguageSwitcher } from "./components.js";
8
- export { getT, BUILTIN as BUILTIN_STRINGS, type Translator } from "./i18n.js";
8
+ export { getT, getClientMessages, BUILTIN as BUILTIN_STRINGS, type Translator, type ClientMessages } from "./i18n.js";
9
9
  export { getRequestLocale, localeHref, LOCALE_HEADER } from "./locale.js";
10
10
  export { CommentForm, CommentList, CommentsMore, ExtensionBoundary, PostPasswordForm, Link, isInternalHref, type LinkProps } from "./client.js";
11
11
  export { getPreference } from "./preferences.js";
12
12
  export { getCurrentUser, getAccessToken, requireUser, userCan, safeRedirect, ACCESS_COOKIE, REFRESH_COOKIE, type CurrentUser } from "./auth.js";
13
- export { LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, LogoutButton, useCurrentUser, AUTH_LABELS, AUTH_PATHS, type AuthLabels, type AuthPaths } from "./auth-forms.js";
13
+ export { LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, LogoutButton, useCurrentUser, ProfileForm, ChangePasswordForm, DeleteAccountForm, AUTH_LABELS, AUTH_PATHS, type AuthLabels, type AuthPaths, type SignInProvider } from "./auth-forms.js";
14
14
  export { buildDocumentMetadata } from "./metadata.js";
15
15
  /** Lấy giá trị Customizer của theme đang active. */
16
16
  export declare function getThemeSettings(): Promise<import("./types.js").JsonObject>;
17
17
  export { PluginForm } from "./plugin-form.js";
18
18
  export type { PluginFormField } from "./plugin-form-client.js";
19
19
  export { defineBlock, type BlockDefinition, type BlockDefinitionInput, type BlockAttrSpec } from "@ecomcx/ecomweb-content";
20
+ export { BINDABLE, CORE_BINDING_SOURCES, type BindingContext, type BindingSource } from "./bindings.js";
package/dist/index.js CHANGED
@@ -5,12 +5,12 @@ export { definePlugin, getPlugin, hasPlugin, applyFilters, doAction, getSlotItem
5
5
  export { PostContent, EditableRegion } from "./content.js";
6
6
  export { Img, imageCandidates } from "./image.js";
7
7
  export { Menu, WidgetArea, Slot, SearchForm, Pagination, FeaturedImage, PostDate, TermLinks, Comments, LanguageSwitcher } from "./components.js";
8
- export { getT, BUILTIN as BUILTIN_STRINGS } from "./i18n.js";
8
+ export { getT, getClientMessages, BUILTIN as BUILTIN_STRINGS } from "./i18n.js";
9
9
  export { getRequestLocale, localeHref, LOCALE_HEADER } from "./locale.js";
10
10
  export { CommentForm, CommentList, CommentsMore, ExtensionBoundary, PostPasswordForm, Link, isInternalHref } from "./client.js";
11
11
  export { getPreference } from "./preferences.js";
12
12
  export { getCurrentUser, getAccessToken, requireUser, userCan, safeRedirect, ACCESS_COOKIE, REFRESH_COOKIE } from "./auth.js";
13
- export { LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, LogoutButton, useCurrentUser, AUTH_LABELS, AUTH_PATHS } from "./auth-forms.js";
13
+ export { LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, LogoutButton, useCurrentUser, ProfileForm, ChangePasswordForm, DeleteAccountForm, AUTH_LABELS, AUTH_PATHS } from "./auth-forms.js";
14
14
  export { buildDocumentMetadata } from "./metadata.js";
15
15
  /** Lấy giá trị Customizer của theme đang active. */
16
16
  export async function getThemeSettings() {
@@ -19,3 +19,4 @@ export async function getThemeSettings() {
19
19
  }
20
20
  export { PluginForm } from "./plugin-form.js";
21
21
  export { defineBlock } from "@ecomcx/ecomweb-content";
22
+ export { BINDABLE, CORE_BINDING_SOURCES } from "./bindings.js";
package/dist/rich.d.ts CHANGED
@@ -42,6 +42,8 @@ export interface RenderContext {
42
42
  styles?: Map<string, string>;
43
43
  /** Đang vẽ bên trong nhóm nút (`buttons`): nút không bọc thêm đoạn văn. */
44
44
  inButtons?: boolean;
45
+ /** Lấy giá trị của nguồn gắn dữ liệu (attrs.bindings). Không có → bỏ qua bindings (giữ nội dung gõ tay). */
46
+ binding?(source: string, args: Record<string, string | number | boolean>, ctx: RenderContext): Promise<unknown>;
45
47
  /** Tiêu đề của bài (khối mục lục) — renderContent ngoài cùng tính. */
46
48
  headings?: Array<{
47
49
  level: number;
package/dist/rich.js CHANGED
@@ -149,7 +149,48 @@ function decorate(el, a, key, ctx) {
149
149
  }
150
150
  return _jsx("div", { className: extra || undefined, id: a.anchor, children: el }, key);
151
151
  }
152
+ /** Thuộc tính gắn được của khối lõi (ngoài danh sách bị bỏ qua). */
153
+ const BINDABLE = { paragraph: ["content"], heading: ["content"], image: ["src", "alt", "caption"], button: ["text", "href"] };
154
+ const URL_ATTRS = new Set(["src", "href"]);
155
+ /**
156
+ * Áp gắn dữ liệu: thay chữ / thuộc tính bằng giá trị của nguồn lúc hiển thị. Giá trị là chữ thường (không HTML), đường dẫn qua safeUrl;
157
+ * nguồn không có / trả về rỗng → giữ nội dung gõ tay. Lỗi của nguồn (plugin) chỉ bỏ qua thuộc tính đó.
158
+ */
159
+ async function applyBindings(node, ctx) {
160
+ const bindings = node.attrs?.bindings;
161
+ const allowed = BINDABLE[node.type];
162
+ if (!bindings || !allowed || !ctx.binding)
163
+ return node;
164
+ let out = node;
165
+ for (const [attr, b] of Object.entries(bindings)) {
166
+ if (!allowed.includes(attr) || typeof b?.source !== "string")
167
+ continue;
168
+ let v;
169
+ try {
170
+ v = await ctx.binding(b.source, b.args ?? {}, ctx);
171
+ }
172
+ catch (e) {
173
+ console.warn(`[cms] binding ${b.source} lỗi:`, e.message);
174
+ continue;
175
+ }
176
+ if (v === null || v === undefined || v === "")
177
+ continue;
178
+ const text = String(v);
179
+ if (attr === "content")
180
+ out = { ...out, content: [{ type: "text", text }] };
181
+ else if (URL_ATTRS.has(attr)) {
182
+ const u = safeUrl(text, "");
183
+ if (u)
184
+ out = { ...out, attrs: { ...out.attrs, [attr]: u } };
185
+ }
186
+ else
187
+ out = { ...out, attrs: { ...out.attrs, [attr]: text } };
188
+ }
189
+ return out;
190
+ }
152
191
  async function renderNode(node, key, ctx) {
192
+ if (node.attrs?.bindings)
193
+ node = await applyBindings(node, ctx);
153
194
  const el = await renderNodeInner(node, key, ctx);
154
195
  const a = node.attrs;
155
196
  const out = a && (a.style || a.blockStyle || a.className || a.anchor) ? decorate(el, a, key, ctx) : el;
package/dist/types.d.ts CHANGED
@@ -497,6 +497,11 @@ export interface PluginDefinition {
497
497
  blocks?: Record<string, ComponentType<BlockProps>>;
498
498
  /** Định nghĩa khối (defineBlock, file dùng chung với phần admin) — website chuẩn hoá thuộc tính + nâng phiên bản trước khi vẽ. */
499
499
  blockTypes?: import("@ecomcx/ecomweb-content").BlockDefinition[];
500
+ /**
501
+ * Nguồn gắn dữ liệu (attrs.bindings của khối, @ecomcx/ecomweb-content 1.15): { "shop/product": async ({ id, field }, { post }) => … }.
502
+ * Khối lõi (đoạn văn, tiêu đề, ảnh, nút) hiện giá trị trả về; trả null/undefined → giữ nội dung gõ tay.
503
+ */
504
+ bindingSources?: Record<string, import("./bindings.js").BindingSource>;
500
505
  /** File ở gốc website: { "/news-sitemap.xml": handler }. Đường dẫn phải khai báo trong plugin.json → frontend.routes. */
501
506
  routes?: Record<string, PluginRoute>;
502
507
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecomcx/ecomweb-theme-sdk",
3
- "version": "1.29.0",
3
+ "version": "1.31.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",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "@ecomcx/ecomweb-content": "^1.14.0",
36
+ "@ecomcx/ecomweb-content": "^1.15.0",
37
37
  "intl-messageformat": "12.1.3"
38
38
  },
39
39
  "peerDependencies": {