@ecomcx/nexus-theme-sdk 0.0.0-stage → 1.24.1

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.
Files changed (52) hide show
  1. package/dist/api.d.ts +92 -0
  2. package/dist/api.js +149 -0
  3. package/dist/auth-forms.d.ts +83 -0
  4. package/dist/auth-forms.js +206 -0
  5. package/dist/auth-pages.d.ts +6 -0
  6. package/dist/auth-pages.js +33 -0
  7. package/dist/auth-shared.d.ts +14 -0
  8. package/dist/auth-shared.js +7 -0
  9. package/dist/auth.d.ts +16 -0
  10. package/dist/auth.js +48 -0
  11. package/dist/client.d.ts +102 -0
  12. package/dist/client.js +191 -0
  13. package/dist/components.d.ts +68 -0
  14. package/dist/components.js +154 -0
  15. package/dist/content.d.ts +25 -0
  16. package/dist/content.js +59 -0
  17. package/dist/dynamic.d.ts +9 -0
  18. package/dist/dynamic.js +128 -0
  19. package/dist/i18n.d.ts +11 -0
  20. package/dist/i18n.js +104 -0
  21. package/dist/image.d.ts +24 -0
  22. package/dist/image.js +37 -0
  23. package/dist/index.d.ts +19 -0
  24. package/dist/index.js +21 -0
  25. package/dist/locale.d.ts +9 -0
  26. package/dist/locale.js +20 -0
  27. package/dist/metadata.d.ts +3 -0
  28. package/dist/metadata.js +26 -0
  29. package/dist/page-canvas.d.ts +4 -0
  30. package/dist/page-canvas.js +7 -0
  31. package/dist/page-full-width.d.ts +3 -0
  32. package/dist/page-full-width.js +6 -0
  33. package/dist/plugin-form-client.d.ts +27 -0
  34. package/dist/plugin-form-client.js +55 -0
  35. package/dist/plugin-form.d.ts +15 -0
  36. package/dist/plugin-form.js +25 -0
  37. package/dist/preferences.d.ts +9 -0
  38. package/dist/preferences.js +17 -0
  39. package/dist/rich.d.ts +66 -0
  40. package/dist/rich.js +388 -0
  41. package/dist/runtime.d.ts +58 -0
  42. package/dist/runtime.js +238 -0
  43. package/dist/social-icons.d.ts +5 -0
  44. package/dist/social-icons.js +21 -0
  45. package/dist/template-blocks.d.ts +3 -0
  46. package/dist/template-blocks.js +11 -0
  47. package/dist/types.d.ts +475 -0
  48. package/dist/types.js +1 -0
  49. package/dist/utils.d.ts +8 -0
  50. package/dist/utils.js +29 -0
  51. package/package.json +62 -4
  52. package/README.md +0 -3
package/dist/auth.js ADDED
@@ -0,0 +1,48 @@
1
+ import { cache } from "react";
2
+ import { ACCESS_COOKIE } from "./auth-shared.js";
3
+ export { ACCESS_COOKIE, REFRESH_COOKIE, safeRedirect } from "./auth-shared.js";
4
+ const API = (process.env.CMS_API_URL ?? "http://localhost:5100").replace(/\/$/, "");
5
+ // Khoá frontend: request SSR của website không bị tính chung giới hạn đăng nhập theo IP của máy chủ website
6
+ const KEY_HEADER = process.env.CMS_FRONTEND_KEY ? { "X-CMS-Frontend-Key": process.env.CMS_FRONTEND_KEY } : {};
7
+ /** Access token của người đang xem (server). Trang gọi hàm này sẽ render theo từng người (không cache tĩnh). */
8
+ export const getAccessToken = cache(async () => {
9
+ try {
10
+ const { cookies } = await import("next/headers");
11
+ return (await cookies()).get(ACCESS_COOKIE)?.value;
12
+ }
13
+ catch {
14
+ return undefined; // ngoài request (build)
15
+ }
16
+ });
17
+ /**
18
+ * Người đang đăng nhập trên website, null nếu là khách.
19
+ * Dùng trong Server Component, route, generateMetadata. Component client: dùng useCurrentUser() hoặc nhận qua props.
20
+ */
21
+ export const getCurrentUser = cache(async () => {
22
+ const token = await getAccessToken();
23
+ if (!token)
24
+ return null;
25
+ try {
26
+ const res = await fetch(`${API}/api/auth/me`, { headers: { ...KEY_HEADER, Authorization: `Bearer ${token}` }, cache: "no-store" });
27
+ return res.ok ? (await res.json()) : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ });
33
+ /** Người dùng có quyền này không — current_user_can(). */
34
+ export function userCan(user, capability) {
35
+ return !!user?.capabilities.includes(capability);
36
+ }
37
+ /**
38
+ * Bắt buộc đăng nhập: khách bị chuyển tới /login, đăng nhập xong quay lại <paramref name="returnTo"/>.
39
+ * const user = await requireUser("/account");
40
+ */
41
+ export async function requireUser(returnTo = "/") {
42
+ const user = await getCurrentUser();
43
+ if (user)
44
+ return user;
45
+ const [{ redirect }, { getSite }, { localeHref }] = await Promise.all([import("next/navigation"), import("./api.js"), import("./locale.js")]);
46
+ // Trang đăng nhập cùng ngôn ngữ với trang đang xem (/en/login khi đang ở tiếng Anh)
47
+ return redirect(`${localeHref(await getSite(), "/login")}?redirect=${encodeURIComponent(returnTo)}`);
48
+ }
@@ -0,0 +1,102 @@
1
+ import { Component } from "react";
2
+ import type { Comment } from "./types.js";
3
+ import type { AnchorHTMLAttributes, ReactNode } from "react";
4
+ export declare class ExtensionBoundary extends Component<{
5
+ name: string;
6
+ children?: ReactNode;
7
+ }, {
8
+ failed: boolean;
9
+ }> {
10
+ state: {
11
+ failed: boolean;
12
+ };
13
+ static getDerivedStateFromError(): {
14
+ failed: boolean;
15
+ };
16
+ componentDidCatch(error: unknown): void;
17
+ render(): import("react").JSX.Element | null;
18
+ }
19
+ export declare function isInternalHref(href: string): boolean;
20
+ export type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
21
+ href: string;
22
+ children?: ReactNode;
23
+ /**
24
+ * Prefetch của Next.js. Mặc định false: mọi trang của CMS là trang động, mỗi lần prefetch là một lần render trên server
25
+ * (trang chủ báo có thể có cả trăm link). Bật cho vài link quan trọng nếu muốn chuyển trang tức thì.
26
+ */
27
+ prefetch?: boolean | null;
28
+ /** Đặt aria-current và class này khi URL hiện tại khớp href (dùng cho menu). */
29
+ activeClassName?: string;
30
+ /** Khớp cả trang con (vd /category/a/page/2 vẫn active cho /category/a). Mặc định true, trừ trang chủ. */
31
+ matchNested?: boolean;
32
+ };
33
+ export declare function Link({ href, prefetch, activeClassName, matchNested, className, children, ...rest }: LinkProps): import("react").JSX.Element;
34
+ /** Form tìm kiếm (GET) chuyển trang phía client. */
35
+ export declare function SearchFormClient({ action, defaultValue, placeholder, label, buttonLabel }: {
36
+ action: string;
37
+ defaultValue?: string | null;
38
+ placeholder: string;
39
+ label: string;
40
+ buttonLabel: string;
41
+ }): import("react").JSX.Element;
42
+ /** Ghi tuỳ chọn hiển thị của người đọc (xem getPreference). value = null để xoá. */
43
+ export declare function setPreference(key: string, value: string | null, maxAgeDays?: number): void;
44
+ /** Client phía trình duyệt: gọi các route proxy /api/cms/* của frontend. */
45
+ declare const browserApi: import("next-fetch-kit").FetchKitInstance;
46
+ /** Form gửi bình luận. Gửi qua route /api/cms/comments của frontend (proxy sang CMS API). */
47
+ export { browserApi };
48
+ type CommentLabels = Record<"title" | "content" | "name" | "email" | "website" | "submit" | "sending" | "approved" | "pending" | "error", string>;
49
+ /** fields: ô thêm của plugin (captcha…) — Comments của SDK truyền <Slot name="comment_form.fields" />. Ô name="extra.*" gửi kèm trong "extra". */
50
+ export declare function CommentForm({ postId, parentId, labels, fields }: {
51
+ postId: number;
52
+ parentId?: number;
53
+ labels?: Partial<CommentLabels>;
54
+ fields?: ReactNode;
55
+ }): import("react").JSX.Element;
56
+ type DateSite = {
57
+ dateFormat: string;
58
+ timezone: string;
59
+ locale?: string;
60
+ };
61
+ /** Danh sách bình luận lồng nhau (render được cả ở server lẫn client). */
62
+ export declare function CommentList({ comments, site }: {
63
+ comments: Comment[];
64
+ site: DateSite;
65
+ }): import("react").JSX.Element;
66
+ /** Nút "Xem thêm bình luận": tải các trang tiếp theo qua /api/cms/comments. */
67
+ export declare function CommentsMore({ postId, totalPages, site, labels }: {
68
+ postId: number;
69
+ totalPages: number;
70
+ site: DateSite;
71
+ labels: {
72
+ more: string;
73
+ loading: string;
74
+ error: string;
75
+ };
76
+ }): import("react").JSX.Element;
77
+ /** Form mật khẩu của bài được bảo vệ. Đúng mật khẩu → cookie httpOnly, tải lại nội dung trang. */
78
+ export declare function PostPasswordForm({ postId, labels }: {
79
+ postId: number;
80
+ labels: {
81
+ intro: string;
82
+ label: string;
83
+ submit: string;
84
+ wrong: string;
85
+ };
86
+ }): import("react").JSX.Element;
87
+ type Lang = {
88
+ code: string;
89
+ name: string;
90
+ flag?: string;
91
+ };
92
+ /** Phần client của LanguageSwitcher: lấy link bản dịch từ <link rel="alternate" hreflang> do metadata của trang sinh ra. */
93
+ export declare function LanguageSwitcherClient({ languages, current, defaultLocale, label, className, variant }: {
94
+ languages: Lang[];
95
+ current: string;
96
+ defaultLocale: string;
97
+ label: string;
98
+ className?: string;
99
+ variant: "list" | "select";
100
+ }): 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";
102
+ export { safeRedirect, type CurrentUser } from "./auth-shared.js";
package/dist/client.js ADDED
@@ -0,0 +1,191 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { extraFields } from "./auth-forms.js";
4
+ import { Component, Suspense, useEffect, useState } from "react";
5
+ import { usePathname, useRouter } from "next/navigation";
6
+ import NextLink, { useLinkStatus } from "next/link";
7
+ import NextForm from "next/form";
8
+ import { cx, formatDate } from "./utils.js";
9
+ import { createFetchKit, isFetchKitError } from "next-fetch-kit";
10
+ // ---------------------------------------------------------------------------
11
+ // 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
+ export class ExtensionBoundary extends Component {
14
+ state = { failed: false };
15
+ static getDerivedStateFromError() { return { failed: true }; }
16
+ componentDidCatch(error) { console.error(`[cms] ${this.props.name} lỗi khi hiển thị — đã ẩn phần này`, error); }
17
+ // Suspense bên trong: khi render trên server, React không dùng error boundary mà đẩy lỗi lên Suspense gần nhất —
18
+ // không có thì lỗi của một shortcode/slot làm cả trang trả 500 (tuỳ thời điểm khung trang đã gửi hay chưa).
19
+ // Có Suspense: phần lỗi được render lại phía trình duyệt, nơi boundary này bắt và ẩn; trang vẫn 200.
20
+ render() { return this.state.failed ? null : _jsx(Suspense, { fallback: null, children: this.props.children }); }
21
+ }
22
+ // ---------------------------------------------------------------------------
23
+ // Link: điều hướng phía client (SPA) cho link nội bộ, <a> thường cho link ngoài/file
24
+ // ---------------------------------------------------------------------------
25
+ /** Đường dẫn không phải trang của theme (file, feed, API…) → luôn tải lại bình thường. */
26
+ const NON_PAGE = /^\/(uploads|api|_next|extensions)\/|^\/(feed|sitemap\.xml|robots\.txt)(\?|$)|\.[a-z0-9]{2,5}(\?|$)/i;
27
+ export function isInternalHref(href) {
28
+ return href.startsWith("/") && !href.startsWith("//") && !NON_PAGE.test(href);
29
+ }
30
+ /**
31
+ * Link của theme/plugin: link nội bộ dùng next/link (không tải lại trang, giữ header/menu, prefetch),
32
+ * link ngoài, file, RSS, target=_blank và nút đổi ngôn ngữ dùng <a> thường. HTML luôn là <a href> (tốt cho SEO).
33
+ */
34
+ /** Báo trạng thái đang chuyển trang lên <html data-nav-pending> để core hiện thanh tiến trình. */
35
+ function PendingSignal() {
36
+ const { pending } = useLinkStatus();
37
+ useEffect(() => {
38
+ if (!pending)
39
+ return;
40
+ document.documentElement.dataset.navPending = "";
41
+ return () => { delete document.documentElement.dataset.navPending; };
42
+ }, [pending]);
43
+ return null;
44
+ }
45
+ export function Link({ href, prefetch = false, activeClassName, matchNested = true, className, children, ...rest }) {
46
+ const pathname = usePathname();
47
+ const path = href.split(/[?#]/)[0];
48
+ const active = activeClassName !== undefined && (pathname === path || (matchNested && path !== "/" && pathname.startsWith(path.endsWith("/") ? path : path + "/")));
49
+ const cls = cx(className, active && activeClassName);
50
+ const ariaCurrent = active ? "page" : rest["aria-current"];
51
+ if (!isInternalHref(href) || rest.target === "_blank" || rest.download !== undefined)
52
+ return _jsx("a", { href: href, className: cls || undefined, ...rest, "aria-current": ariaCurrent, children: children });
53
+ return (_jsxs(NextLink, { href: href, prefetch: prefetch, className: cls || undefined, ...rest, "aria-current": ariaCurrent, onClick: (e) => {
54
+ rest.onClick?.(e);
55
+ // Trang không tải lại → tự đóng menu dạng <details> (menu mobile) chứa link vừa bấm
56
+ e.currentTarget.closest("details[open]")?.removeAttribute("open");
57
+ }, children: [children, _jsx(PendingSignal, {})] }));
58
+ }
59
+ /** Form tìm kiếm (GET) chuyển trang phía client. */
60
+ export function SearchFormClient({ action, defaultValue, placeholder, label, buttonLabel }) {
61
+ return (_jsxs(NextForm, { action: action, role: "search", className: "search-form", children: [_jsx("input", { type: "search", name: "s", defaultValue: defaultValue ?? "", placeholder: placeholder, "aria-label": label, required: true }), _jsx("button", { type: "submit", children: buttonLabel })] }));
62
+ }
63
+ /** Ghi tuỳ chọn hiển thị của người đọc (xem getPreference). value = null để xoá. */
64
+ export function setPreference(key, value, maxAgeDays = 365) {
65
+ if (!/^[a-z0-9-]{1,32}$/.test(key) || (value !== null && !/^[A-Za-z0-9_-]{0,64}$/.test(value)))
66
+ throw new Error("Tuỳ chọn không hợp lệ");
67
+ document.cookie = `cms-pref-${key}=${value ?? ""}; path=/; max-age=${value === null ? 0 : maxAgeDays * 86400}; samesite=lax`;
68
+ }
69
+ /** Client phía trình duyệt: gọi các route proxy /api/cms/* của frontend. */
70
+ const browserApi = createFetchKit({ baseURL: "/api/cms", timeout: 15_000 });
71
+ function errorMessage(e, fallback) {
72
+ if (isFetchKitError(e)) {
73
+ const d = e.data;
74
+ if (d?.errors)
75
+ return Object.values(d.errors).flat().join(". ");
76
+ return d?.title ?? fallback;
77
+ }
78
+ return e?.message ?? fallback;
79
+ }
80
+ /** Form gửi bình luận. Gửi qua route /api/cms/comments của frontend (proxy sang CMS API). */
81
+ export { browserApi };
82
+ const DEFAULT_LABELS = {
83
+ title: "Để lại bình luận", content: "Bình luận", name: "Tên", email: "Email", website: "Website", submit: "Gửi bình luận",
84
+ sending: "Đang gửi…", approved: "Cảm ơn bạn! Bình luận đã được đăng (tải lại trang để xem).", pending: "Cảm ơn bạn! Bình luận đang chờ duyệt.",
85
+ error: "Không gửi được bình luận",
86
+ };
87
+ /** fields: ô thêm của plugin (captcha…) — Comments của SDK truyền <Slot name="comment_form.fields" />. Ô name="extra.*" gửi kèm trong "extra". */
88
+ export function CommentForm({ postId, parentId, labels, fields }) {
89
+ const L = { ...DEFAULT_LABELS, ...labels };
90
+ const [state, setState] = useState("idle");
91
+ const [message, setMessage] = useState("");
92
+ async function onSubmit(e) {
93
+ e.preventDefault();
94
+ const form = e.currentTarget;
95
+ const fd = new FormData(form);
96
+ setState("sending");
97
+ try {
98
+ const { data } = await browserApi.post("/comments", {
99
+ body: {
100
+ postId, parentId,
101
+ authorName: fd.get("authorName"), authorEmail: fd.get("authorEmail"),
102
+ authorUrl: fd.get("authorUrl"), content: fd.get("content"), website2: fd.get("website2"), extra: extraFields(fd),
103
+ },
104
+ });
105
+ form.reset();
106
+ setState("done");
107
+ setMessage(data.status === "approved" ? L.approved : L.pending);
108
+ }
109
+ catch (err) {
110
+ setState("error");
111
+ setMessage(errorMessage(err, L.error));
112
+ }
113
+ }
114
+ return (_jsxs("form", { className: "comment-form", onSubmit: onSubmit, children: [_jsx("h3", { children: L.title }), _jsx("p", { children: _jsxs("label", { children: [L.content, " *", _jsx("textarea", { name: "content", required: true, rows: 5, maxLength: 5000 })] }) }), _jsxs("p", { className: "comment-form-row", children: [_jsxs("label", { children: [L.name, " *", _jsx("input", { name: "authorName", required: true, maxLength: 100 })] }), _jsxs("label", { children: [L.email, " *", _jsx("input", { name: "authorEmail", type: "email", required: true, maxLength: 200 })] })] }), _jsx("p", { children: _jsxs("label", { children: [L.website, _jsx("input", { name: "authorUrl", type: "url", maxLength: 200 })] }) }), _jsx("input", { name: "website2", tabIndex: -1, autoComplete: "off", style: { position: "absolute", left: "-9999px" }, "aria-hidden": true }), fields, _jsx("p", { children: _jsx("button", { type: "submit", disabled: state === "sending", children: state === "sending" ? L.sending : L.submit }) }), message && _jsx("p", { className: state === "error" ? "form-error" : "form-success", role: "status", children: message })] }));
115
+ }
116
+ /** Danh sách bình luận lồng nhau (render được cả ở server lẫn client). */
117
+ export function CommentList({ comments, site }) {
118
+ return (_jsx("ol", { className: "comment-list", children: comments.map((c) => (_jsxs("li", { id: `comment-${c.id}`, className: "comment", children: [_jsxs("article", { children: [_jsxs("header", { className: "comment-meta", children: [c.avatarUrl && _jsx("img", { className: "comment-avatar avatar", src: c.avatarUrl, alt: "", width: 40, height: 40, loading: "lazy", decoding: "async" }), _jsx("b", { className: "comment-author", children: c.authorUrl ? _jsx("a", { href: c.authorUrl, rel: "nofollow ugc", children: c.authorName }) : c.authorName }), " · ", _jsx("time", { dateTime: c.createdAt, children: formatDate(c.createdAt, site) })] }), _jsx("div", { className: "comment-content", children: c.content.split("\n").map((l, i) => _jsx("p", { children: l }, i)) })] }), c.replies.length > 0 && _jsx(CommentList, { comments: c.replies, site: site })] }, c.id))) }));
119
+ }
120
+ /** Nút "Xem thêm bình luận": tải các trang tiếp theo qua /api/cms/comments. */
121
+ export function CommentsMore({ postId, totalPages, site, labels }) {
122
+ const [pages, setPages] = useState([]);
123
+ const [loading, setLoading] = useState(false);
124
+ const [error, setError] = useState("");
125
+ const next = pages.length + 2;
126
+ async function load() {
127
+ setLoading(true);
128
+ setError("");
129
+ try {
130
+ const { data } = await browserApi.get("/comments", { query: { postId, page: next } });
131
+ setPages((p) => [...p, data.items]);
132
+ }
133
+ catch (e) {
134
+ setError(errorMessage(e, labels.error));
135
+ }
136
+ finally {
137
+ setLoading(false);
138
+ }
139
+ }
140
+ return (_jsxs(_Fragment, { children: [pages.map((items, i) => _jsx(CommentList, { comments: items, site: site }, i)), next <= totalPages && _jsx("p", { className: "comments-more", children: _jsx("button", { type: "button", onClick: load, disabled: loading, children: loading ? labels.loading : labels.more }) }), error && _jsx("p", { className: "form-error", role: "alert", children: error })] }));
141
+ }
142
+ /** Form mật khẩu của bài được bảo vệ. Đúng mật khẩu → cookie httpOnly, tải lại nội dung trang. */
143
+ export function PostPasswordForm({ postId, labels }) {
144
+ const router = useRouter();
145
+ const [error, setError] = useState("");
146
+ const [busy, setBusy] = useState(false);
147
+ async function onSubmit(e) {
148
+ e.preventDefault();
149
+ setBusy(true);
150
+ setError("");
151
+ try {
152
+ await browserApi.post("/unlock", { body: { postId, password: new FormData(e.currentTarget).get("password") } });
153
+ router.refresh();
154
+ }
155
+ catch (err) {
156
+ setError(errorMessage(err, labels.wrong));
157
+ setBusy(false);
158
+ }
159
+ }
160
+ return (_jsxs("form", { className: "post-password-form", onSubmit: onSubmit, children: [_jsx("p", { children: labels.intro }), _jsxs("p", { children: [_jsxs("label", { children: [labels.label, " ", _jsx("input", { name: "password", type: "password", required: true, autoComplete: "off", maxLength: 100 })] }), " ", _jsx("button", { type: "submit", disabled: busy, children: labels.submit })] }), error && _jsx("p", { className: "form-error", role: "alert", children: error })] }));
161
+ }
162
+ /** Phần client của LanguageSwitcher: lấy link bản dịch từ <link rel="alternate" hreflang> do metadata của trang sinh ra. */
163
+ export function LanguageSwitcherClient({ languages, current, defaultLocale, label, className, variant }) {
164
+ const pathname = usePathname();
165
+ const home = (code) => (code === defaultLocale ? "/" : `/${code}`);
166
+ const [links, setLinks] = useState({});
167
+ useEffect(() => {
168
+ const read = () => {
169
+ const map = {};
170
+ document.querySelectorAll('link[rel="alternate"][hreflang]').forEach((l) => {
171
+ const lang = l.getAttribute("hreflang");
172
+ if (lang && lang !== "x-default") {
173
+ try {
174
+ map[lang] = new URL(l.href).pathname;
175
+ }
176
+ catch { /* bỏ qua */ }
177
+ }
178
+ });
179
+ setLinks(map);
180
+ };
181
+ read();
182
+ const t = setTimeout(read, 150); // metadata có thể được chèn sau khi chuyển trang
183
+ return () => clearTimeout(t);
184
+ }, [pathname]);
185
+ const href = (code) => links[code] ?? home(code);
186
+ if (variant === "select")
187
+ 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
+ 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
+ export { LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm, LogoutButton, useCurrentUser, extraFields, AUTH_LABELS, AUTH_PATHS } from "./auth-forms.js";
191
+ export { safeRedirect } from "./auth-shared.js";
@@ -0,0 +1,68 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Media, Post, ResolveResult, SlotName } from "./types.js";
3
+ export declare function Menu({ location, menuId, currentPath, className, fallback }: {
4
+ location?: string;
5
+ menuId?: number;
6
+ currentPath?: string;
7
+ className?: string;
8
+ fallback?: ReactNode;
9
+ }): Promise<import("react").JSX.Element>;
10
+ export declare function WidgetArea({ id, className }: {
11
+ id: string;
12
+ className?: string;
13
+ }): Promise<import("react").JSX.Element | null>;
14
+ export declare function Slot<P = any>({ name, data, props, fallback, className, }: {
15
+ name: SlotName;
16
+ data?: ResolveResult | null;
17
+ props?: P;
18
+ fallback?: ReactNode;
19
+ className?: string;
20
+ }): Promise<import("react").JSX.Element>;
21
+ /** Ô tìm kiếm — gửi về trang chủ của ngôn ngữ hiện tại (/?s= hoặc /en?s=). */
22
+ export declare function SearchForm({ defaultValue, placeholder, buttonLabel }: {
23
+ defaultValue?: string | null;
24
+ placeholder?: string;
25
+ buttonLabel?: string;
26
+ }): Promise<import("react").JSX.Element>;
27
+ export declare function Pagination({ data, prevLabel, nextLabel }: {
28
+ data: ResolveResult;
29
+ prevLabel?: string;
30
+ nextLabel?: string;
31
+ }): Promise<import("react").JSX.Element | null>;
32
+ /** Ảnh đại diện: responsive, WebP, srcset. */
33
+ export declare function FeaturedImage({ media, size, className, link, sizes, priority }: {
34
+ media?: Media | null;
35
+ size?: string;
36
+ className?: string;
37
+ link?: string;
38
+ sizes?: string;
39
+ priority?: boolean;
40
+ }): import("react").JSX.Element | null;
41
+ export declare function PostDate({ post, site }: {
42
+ post: Post;
43
+ site?: {
44
+ dateFormat: string;
45
+ timezone: string;
46
+ locale?: string;
47
+ };
48
+ }): import("react").JSX.Element;
49
+ export declare function TermLinks({ terms, separator }: {
50
+ terms?: Array<{
51
+ id: number;
52
+ name: string;
53
+ link: string;
54
+ }>;
55
+ separator?: string;
56
+ }): import("react").JSX.Element | null;
57
+ export declare function Comments({ post, title }: {
58
+ post: Post;
59
+ title?: string;
60
+ }): Promise<import("react").JSX.Element | null>;
61
+ /**
62
+ * Nút chuyển ngôn ngữ. Đặt ở layout của theme. Ẩn khi site chỉ có một ngôn ngữ.
63
+ * Liên kết tới đúng bản dịch của trang hiện tại (đọc thẻ hreflang), không có thì về trang chủ ngôn ngữ đó.
64
+ */
65
+ export declare function LanguageSwitcher({ className, variant }: {
66
+ className?: string;
67
+ variant?: "list" | "select";
68
+ }): Promise<import("react").JSX.Element | null>;
@@ -0,0 +1,154 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { getArchives, getComments, getMenu, getMenuById, getPosts, getSite, getTerms, getWidgets } from "./api.js";
3
+ import { findWidget, getSlotItems } from "./runtime.js";
4
+ import { cx, formatDate, pageLink } from "./utils.js";
5
+ import { Img } from "./image.js";
6
+ import { CommentForm, CommentList, CommentsMore, ExtensionBoundary, LanguageSwitcherClient, Link, SearchFormClient } from "./client.js";
7
+ import { getT } from "./i18n.js";
8
+ import { localeHref } from "./locale.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Menu theo vị trí
11
+ // ---------------------------------------------------------------------------
12
+ function MenuItems({ items, currentPath, depth }) {
13
+ return (_jsx("ul", { className: depth === 0 ? "menu" : "sub-menu", children: items.map((item) => {
14
+ const active = currentPath !== undefined && (item.url === currentPath || (item.url !== "/" && currentPath.startsWith(item.url + "/")));
15
+ return (_jsxs("li", { className: cx("menu-item", item.cssClass, active && "current-menu-item", item.children.length > 0 && "has-children"), children: [_jsx(Link, { href: item.url, target: item.target ?? undefined, rel: item.target === "_blank" ? "noopener noreferrer" : undefined, "aria-current": active ? "page" : undefined, activeClassName: "current-menu-link", matchNested: item.url !== "/", children: item.title }), item.children.length > 0 && _jsx(MenuItems, { items: item.children, currentPath: currentPath, depth: depth + 1 })] }, item.id));
16
+ }) }));
17
+ }
18
+ export async function Menu({ location, menuId, currentPath, className, fallback }) {
19
+ const menu = menuId ? await getMenuById(menuId) : location ? await getMenu(location) : null;
20
+ if (!menu || menu.items.length === 0)
21
+ return _jsx(_Fragment, { children: fallback ?? null });
22
+ return _jsx("nav", { className: className, "aria-label": menu.name, children: _jsx(MenuItems, { items: menu.items, currentPath: currentPath, depth: 0 }) });
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Khu vực widget
26
+ // ---------------------------------------------------------------------------
27
+ async function CoreWidget({ widget }) {
28
+ const s = widget.settings ?? {};
29
+ switch (widget.type) {
30
+ case "text": return _jsx("div", { className: "textwidget", children: String(s.text ?? "").split("\n").map((l, i) => _jsx("p", { children: l }, i)) });
31
+ case "html": return _jsx("div", { className: "textwidget", dangerouslySetInnerHTML: { __html: String(s.html ?? "") } });
32
+ case "search": return _jsx(SearchForm, {});
33
+ case "recent-posts": {
34
+ const { items } = await getPosts({ perPage: Number(s.number) || 5 });
35
+ return _jsx("ul", { children: items.map((p) => _jsx("li", { children: _jsx(Link, { href: p.link, children: p.title }) }, p.id)) });
36
+ }
37
+ case "categories": {
38
+ const terms = await getTerms("category", true);
39
+ // Phân cấp: chuyên mục con lồng trong chuyên mục cha
40
+ const ids = new Set(terms.map((t) => t.id));
41
+ const tree = (parent) => {
42
+ const level = terms.filter((t) => (t.parentId && ids.has(t.parentId) ? t.parentId : null) === parent);
43
+ return level.length ? _jsx("ul", { className: parent ? "children" : undefined, children: level.map((t) => _jsxs("li", { className: "cat-item", children: [_jsx(Link, { href: t.link, children: t.name }), s.showCount ? ` (${t.count})` : "", tree(t.id)] }, t.id)) }) : null;
44
+ };
45
+ return tree(null);
46
+ }
47
+ case "tag-cloud": {
48
+ const terms = await getTerms("post_tag", true);
49
+ const max = Math.max(1, ...terms.map((t) => t.count));
50
+ return _jsx("div", { className: "tagcloud", children: terms.map((t) => _jsx(Link, { href: t.link, style: { fontSize: `${0.8 + (t.count / max) * 0.8}em` }, children: t.name }, t.id)) });
51
+ }
52
+ case "archives": {
53
+ const [rows, t] = await Promise.all([getArchives(), getT()]);
54
+ return _jsx("ul", { children: rows.map((r) => _jsxs("li", { children: [_jsx(Link, { href: r.link, children: t("archives.month", { month: r.month, year: r.year }) }), " (", r.count, ")"] }, r.link)) });
55
+ }
56
+ case "menu": return s.menuId ? _jsx(Menu, { menuId: Number(s.menuId) }) : null;
57
+ default: return null;
58
+ }
59
+ }
60
+ export async function WidgetArea({ id, className }) {
61
+ const [site, widgets] = await Promise.all([getSite(), getWidgets(id)]);
62
+ if (widgets.length === 0)
63
+ return null;
64
+ const rendered = await Promise.all(widgets.map(async (w) => {
65
+ const Plugin = await findWidget(w.type, site);
66
+ const body = Plugin ? _jsx(ExtensionBoundary, { name: `Widget ${w.type}`, children: _jsx(Plugin, { widget: w, settings: w.settings ?? {}, site: site }) }) : await CoreWidget({ widget: w });
67
+ if (!body)
68
+ return null;
69
+ return (_jsxs("section", { className: cx("widget", `widget-${w.type}`), children: [w.title && _jsx("h2", { className: "widget-title", children: w.title }), body] }, w.id));
70
+ }));
71
+ return _jsx("aside", { className: cx("widget-area", className), "data-area": id, children: rendered });
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Slot cho plugin (head, cuối trang, trước/sau nội dung…)
75
+ // ---------------------------------------------------------------------------
76
+ export async function Slot({ name, data, props, fallback, className, }) {
77
+ const site = await getSite();
78
+ const items = await getSlotItems(name, site);
79
+ if (items.length === 0)
80
+ return _jsx(_Fragment, { children: fallback ?? null });
81
+ const elements = items.map(({ Component, priority }, i) => (_jsx(ExtensionBoundary, { name: `Slot ${name}`, children: _jsx(Component, { site: site, data: data, ...props }) }, `${i}-${priority}`)));
82
+ return className ? _jsx("div", { className: className, children: elements }) : _jsx(_Fragment, { children: elements });
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // Tiện ích hiển thị
86
+ // ---------------------------------------------------------------------------
87
+ /** Ô tìm kiếm — gửi về trang chủ của ngôn ngữ hiện tại (/?s= hoặc /en?s=). */
88
+ export async function SearchForm({ defaultValue, placeholder, buttonLabel }) {
89
+ const [t, site] = await Promise.all([getT(), getSite()]);
90
+ return _jsx(SearchFormClient, { action: localeHref(site, "/"), defaultValue: defaultValue, placeholder: placeholder ?? t("search.placeholder"), label: t("search.label"), buttonLabel: buttonLabel ?? t("search.button") });
91
+ }
92
+ export async function Pagination({ data, prevLabel, nextLabel }) {
93
+ if (data.totalPages <= 1)
94
+ return null;
95
+ const t = await getT();
96
+ prevLabel ??= t("pagination.prev");
97
+ nextLabel ??= t("pagination.next");
98
+ const pages = [];
99
+ for (let i = 1; i <= data.totalPages; i++) {
100
+ if (i === 1 || i === data.totalPages || Math.abs(i - data.page) <= 2)
101
+ pages.push(i);
102
+ else if (pages[pages.length - 1] !== "…")
103
+ pages.push("…");
104
+ }
105
+ const link = (p) => pageLink(data.basePath, p, data.kind === "search" ? data.search : null);
106
+ 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
+ : p === data.page ? _jsx("span", { className: "current", "aria-current": "page", children: p }, p)
108
+ : _jsx(Link, { href: link(p), children: p }, p)), data.page < data.totalPages && _jsx(Link, { className: "next", href: link(data.page + 1), children: nextLabel })] }));
109
+ }
110
+ /** Ảnh đại diện: responsive, WebP, srcset. */
111
+ export function FeaturedImage({ media, size = "large", className, link, sizes, priority }) {
112
+ if (!media)
113
+ return null;
114
+ const img = _jsx(Img, { media: media, size: size, sizes: sizes, priority: priority });
115
+ return _jsx("figure", { className: cx("featured-image", className), children: link ? _jsx(Link, { href: link, children: img }) : img });
116
+ }
117
+ export function PostDate({ post, site }) {
118
+ return _jsx("time", { dateTime: post.publishedAt ?? post.createdAt, children: formatDate(post.publishedAt ?? post.createdAt, site) });
119
+ }
120
+ export function TermLinks({ terms, separator = ", " }) {
121
+ if (!terms?.length)
122
+ return null;
123
+ return _jsx(_Fragment, { children: terms.map((t, i) => _jsxs("span", { children: [i > 0 && separator, _jsx(Link, { href: t.link, rel: "tag", children: t.name })] }, t.id)) });
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Khu bình luận
127
+ // ---------------------------------------------------------------------------
128
+ export async function Comments({ post, title }) {
129
+ if (post.commentStatus !== "open" && post.commentCount === 0)
130
+ return null;
131
+ if (post.passwordProtected && !post.content)
132
+ return null; // bài có mật khẩu: ẩn bình luận tới khi mở khoá
133
+ const [site, page, t] = await Promise.all([getSite(), getComments(post.id), getT()]);
134
+ const comments = page?.items ?? [];
135
+ const dateSite = { dateFormat: site.dateFormat, timezone: site.timezone, locale: site.locale };
136
+ title ??= t("comments.title");
137
+ const labels = Object.fromEntries(["title", "content", "name", "email", "website", "submit", "sending", "approved", "pending", "error"].map((k) => [k, t(`comments.form.${k}`)]));
138
+ return (_jsxs("section", { id: "comments", className: "comments-area", children: [_jsx(Slot, { name: "comments.before", props: { post } }), _jsxs("h2", { className: "comments-title", children: [title, " (", post.commentCount, ")"] }), comments.length > 0 && _jsx(CommentList, { comments: comments, site: dateSite }), (page?.totalPages ?? 1) > 1 && _jsx(CommentsMore, { postId: post.id, totalPages: page.totalPages, site: dateSite, labels: { more: t("comments.more"), loading: t("comments.loading"), error: t("comments.form.error") } }), post.commentStatus === "open"
139
+ ? _jsx(CommentForm, { postId: post.id, labels: labels, fields: _jsx(Slot, { name: "comment_form.fields", props: { post } }) })
140
+ : _jsx("p", { className: "no-comments", children: t("comments.closed") }), _jsx(Slot, { name: "comments.after", props: { post } })] }));
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Chuyển ngôn ngữ
144
+ // ---------------------------------------------------------------------------
145
+ /**
146
+ * Nút chuyển ngôn ngữ. Đặt ở layout của theme. Ẩn khi site chỉ có một ngôn ngữ.
147
+ * Liên kết tới đúng bản dịch của trang hiện tại (đọc thẻ hreflang), không có thì về trang chủ ngôn ngữ đó.
148
+ */
149
+ export async function LanguageSwitcher({ className, variant = "list" }) {
150
+ const [site, t] = await Promise.all([getSite(), getT()]);
151
+ if (site.languages.length < 2)
152
+ return null;
153
+ return _jsx(LanguageSwitcherClient, { className: className, variant: variant, current: site.locale, defaultLocale: site.defaultLocale, label: t("language.label"), languages: site.languages.map((l) => ({ code: l.code, name: l.name, flag: l.flag ?? undefined })) });
154
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Post, PostContentDoc, ResolveResult, Site } from "./types.js";
3
+ import { type RenderContext } from "./rich.js";
4
+ /**
5
+ * Render nội dung bài viết (định dạng của CMS — @ecomcx/nexus-content; phần vẽ ở rich.tsx, không phụ thuộc trình soạn).
6
+ * Luôn có class `ecx-content` (giao diện nền trong @ecomcx/nexus-content/style.css, theme ghi đè được); `className` thay cho `entry-content`.
7
+ */
8
+ export declare function PostContent({ post, content, className }: {
9
+ post?: Post | null;
10
+ content?: PostContentDoc | null;
11
+ className?: string;
12
+ }): Promise<import("react").JSX.Element | null>;
13
+ /**
14
+ * Vùng của theme: header, footer (vùng chuẩn của core — nên luôn bọc) và vùng hybrid khai báo ở theme.json › regions.
15
+ * Thứ tự: plugin thay vùng (definePlugin → regions) → bản sửa trong Trình dựng / `parts/{name}.json` → `children` (bản code của theme).
16
+ * `<EditableRegion name="header" data={data}><Header /></EditableRegion>`
17
+ */
18
+ export declare function EditableRegion({ name, data, className, children }: {
19
+ name: string;
20
+ data?: ResolveResult;
21
+ className?: string;
22
+ children?: ReactNode;
23
+ }): Promise<import("react").JSX.Element>;
24
+ /** Ngữ cảnh vẽ nội dung dùng chung cho bài viết và template khối. */
25
+ export declare function contentContext(site: Site, post?: Post | null, data?: ResolveResult): Promise<RenderContext>;