@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
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { getSite, pluginFetch } from "./api.js";
3
+ import { getT } from "./i18n.js";
4
+ import { PluginFormClient } from "./plugin-form-client.js";
5
+ /**
6
+ * Form tự sinh cho bảng EF của plugin có [PublicCreate] (form liên hệ, đặt lịch, đăng ký…):
7
+ * <PluginForm plugin="lien-he" table="messages" /> — hoặc shortcode [cms-form plugin="lien-he" table="messages"]
8
+ * Ô nhập, nhãn, ràng buộc lấy từ kiểu dữ liệu C# của plugin; máy chủ kiểm tra lại và giới hạn số lần gửi.
9
+ * Class CSS: cms-form, cms-form-field, cms-form-{kiểu}, form-error, form-success.
10
+ */
11
+ export async function PluginForm({ plugin, table, submitLabel, className }) {
12
+ if (!/^[a-z0-9][a-z0-9-]{1,63}$/.test(plugin) || !/^[a-z][a-z0-9_]*$/.test(table))
13
+ return null;
14
+ const site = await getSite();
15
+ const form = await pluginFetch(plugin, `/data/${table}/form`, { query: { lang: site.locale ?? site.language } })
16
+ .catch(() => null);
17
+ if (!form)
18
+ return null;
19
+ const t = await getT();
20
+ return _jsx(PluginFormClient, { plugin: plugin, table: table, fields: form.fields, successMessage: form.successMessage, className: className, labels: { submit: submitLabel ?? t("form.submit"), sending: t("form.sending"), error: t("form.error") } });
21
+ }
22
+ /** Shortcode có sẵn: [cms-form plugin="lien-he" table="messages" submit="Gửi liên hệ" class="…"]. */
23
+ export function CmsFormShortcode({ attrs }) {
24
+ return _jsx(PluginForm, { plugin: attrs.plugin ?? "", table: attrs.table ?? "", submitLabel: attrs.submit, className: attrs.class });
25
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Tuỳ chọn hiển thị của người đọc (sáng/tối, cỡ chữ…). Lưu ở cookie "cms-pref-{key}" để server render đúng ngay lần tải đầu.
3
+ * Theme/plugin dùng API này thay vì đọc/ghi document.cookie (bị bộ quét bảo mật chặn).
4
+ * Giới hạn: key [a-z0-9-] tối đa 32 ký tự; value [a-zA-Z0-9_-] tối đa 64 ký tự — không thể dùng để lưu dữ liệu nhạy cảm.
5
+ */
6
+ export declare const PREF_PREFIX = "cms-pref-";
7
+ export declare const isValidPreference: (key: string, value: string) => boolean;
8
+ /** Đọc tuỳ chọn ở Server Component (layout/template). */
9
+ export declare function getPreference(key: string): Promise<string | undefined>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Tuỳ chọn hiển thị của người đọc (sáng/tối, cỡ chữ…). Lưu ở cookie "cms-pref-{key}" để server render đúng ngay lần tải đầu.
3
+ * Theme/plugin dùng API này thay vì đọc/ghi document.cookie (bị bộ quét bảo mật chặn).
4
+ * Giới hạn: key [a-z0-9-] tối đa 32 ký tự; value [a-zA-Z0-9_-] tối đa 64 ký tự — không thể dùng để lưu dữ liệu nhạy cảm.
5
+ */
6
+ export const PREF_PREFIX = "cms-pref-";
7
+ const KEY = /^[a-z0-9-]{1,32}$/;
8
+ const VALUE = /^[A-Za-z0-9_-]{0,64}$/;
9
+ export const isValidPreference = (key, value) => KEY.test(key) && VALUE.test(value);
10
+ /** Đọc tuỳ chọn ở Server Component (layout/template). */
11
+ export async function getPreference(key) {
12
+ if (!KEY.test(key))
13
+ return undefined;
14
+ const { cookies } = await import("next/headers");
15
+ const v = (await cookies()).get(PREF_PREFIX + key)?.value;
16
+ return v !== undefined && VALUE.test(v) ? v : undefined;
17
+ }
package/dist/rich.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { type ComponentType, type ReactNode } from "react";
2
+ import { type Block, type BlockDefinition, type ContentDoc, type RichNode } from "@ecomcx/nexus-content";
3
+ import type { BlockProps, Post, ResolveResult, ShortcodeProps, Site } from "./types.js";
4
+ /**
5
+ * Vẽ nội dung (định dạng của CMS — @ecomcx/nexus-content) thành React, không phụ thuộc trình soạn.
6
+ * Hàm thuần: shortcode/khối của plugin tra qua `resolve`, bọc lỗi qua `boundary` → test được không cần Next/runtime.
7
+ */
8
+ export interface RenderContext {
9
+ site: Site;
10
+ post?: Post | null;
11
+ resolve: {
12
+ shortcode(name: string): Promise<ComponentType<ShortcodeProps> | null>;
13
+ /** Khối của plugin: khối cấp cao nhất và nút trong nội dung soạn thảo dùng chung một renderer (definePlugin → blocks). */
14
+ block(type: string): Promise<ComponentType<BlockProps> | null>;
15
+ };
16
+ /** Bọc component của plugin (lỗi chỉ ẩn phần đó). Mặc định: không bọc. */
17
+ boundary?(name: string, key: string, el: ReactNode): ReactNode;
18
+ /** Khối của plugin (defineBlock) — chuẩn hoá thuộc tính + nâng phiên bản khối lưu bằng bản cũ. */
19
+ blocks?: readonly BlockDefinition[];
20
+ /** Chữ giao diện (vd "Tải tệp", "Mục lục"). */
21
+ labels?: {
22
+ download?: string;
23
+ toc?: string;
24
+ };
25
+ /** Ngữ cảnh template: trang đang xem (khối động postTitle, postContent, query… đọc từ đây). Không có = đang vẽ nội dung bài. */
26
+ data?: ResolveResult;
27
+ /** Vẽ khối động (@ecomcx/nexus-content DYNAMIC_NODES). Không có → khối động bị bỏ qua. */
28
+ dynamic?(node: RenderNode, key: string, ctx: RenderContext, children: (ctx: RenderContext, nodes?: RenderNode[]) => Promise<ReactNode[]>): Promise<ReactNode>;
29
+ /** Bài của vòng lặp `query` gần nhất (+ dữ liệu phân trang khi dùng danh sách của trang). */
30
+ query?: {
31
+ posts: Post[];
32
+ data?: ResolveResult;
33
+ };
34
+ /** Nội dung template part theo slug (khối templatePart). */
35
+ parts?(slug: string): Promise<ContentDoc | null>;
36
+ partDepth?: number;
37
+ /** Nội dung mẫu đồng bộ theo id (khối syncedPattern). */
38
+ pattern?(ref: number): Promise<ContentDoc | null>;
39
+ /** Plugin thay vùng (definePlugin → regions): nhận bản template part đã vẽ, trả bản thay thế. Không có = giữ nguyên. */
40
+ region?(name: string, fallback: ReactNode): Promise<ReactNode>;
41
+ /** CSS kiểu dáng khối (attrs.style) gom theo class — renderContent ngoài cùng tạo và xuất thành một thẻ <style>. */
42
+ styles?: Map<string, string>;
43
+ /** Đang vẽ bên trong nhóm nút (`buttons`): nút không bọc thêm đoạn văn. */
44
+ inButtons?: boolean;
45
+ /** Tiêu đề của bài (khối mục lục) — renderContent ngoài cùng tính. */
46
+ headings?: Array<{
47
+ level: number;
48
+ id: string;
49
+ text: string;
50
+ }>;
51
+ }
52
+ type Node = Omit<RichNode, "attrs" | "content" | "marks"> & {
53
+ attrs?: Record<string, any>;
54
+ content?: Node[];
55
+ marks?: Array<{
56
+ type: string;
57
+ attrs?: Record<string, any>;
58
+ }>;
59
+ };
60
+ export type RenderNode = Node;
61
+ /**
62
+ * Vẽ nội dung bài viết: chuẩn hoá theo định dạng của CMS (bài lưu bằng bản cũ được nâng cấp, cấu trúc hỏng bị bỏ),
63
+ * rồi vẽ từng khối. Khối/nút của plugin không có renderer (plugin tắt) → bỏ qua, dữ liệu vẫn nguyên trong DB.
64
+ */
65
+ export declare function renderContent(content: ContentDoc | unknown, ctx: RenderContext, transform?: (blocks: Block[]) => Block[] | Promise<Block[]>): Promise<ReactNode[]>;
66
+ export {};
package/dist/rich.js ADDED
@@ -0,0 +1,388 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment, cloneElement, isValidElement } from "react";
3
+ import { DYNAMIC_NODES, colorCss, embedInfo, normalizeContent, safeUrl, spacingValue, styleClass, styleCss } from "@ecomcx/nexus-content";
4
+ import { cx } from "./utils.js";
5
+ import { SOCIAL_ICONS } from "./social-icons.js";
6
+ const DYNAMIC = new Set(DYNAMIC_NODES);
7
+ const SHORTCODE = /^\s*\[([a-z0-9_-]+)((?:\s+[a-z0-9_-]+=(?:"[^"]*"|'[^']*'|[^\s\]]+))*)\s*\]\s*$/i;
8
+ const SHORTCODE_INLINE = /\[([a-z0-9_-]+)((?:\s+[a-z0-9_-]+=(?:"[^"]*"|'[^']*'|[^\s\]]+))*)\s*\]/gi;
9
+ function parseAttrs(raw) {
10
+ const attrs = {};
11
+ for (const m of raw.matchAll(/([a-z0-9_-]+)=(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/gi))
12
+ attrs[m[1]] = m[2] ?? m[3] ?? m[4] ?? "";
13
+ return attrs;
14
+ }
15
+ const SOCIAL = {
16
+ facebook: "Facebook", x: "X", twitter: "X", instagram: "Instagram", youtube: "YouTube", tiktok: "TikTok", linkedin: "LinkedIn", zalo: "Zalo",
17
+ github: "GitHub", threads: "Threads", pinterest: "Pinterest", telegram: "Telegram", messenger: "Messenger", email: "Email", website: "Website",
18
+ };
19
+ /** id của tiêu đề (neo cho mục lục): thuộc tính id, không thì chữ không dấu nối bằng gạch ngang. */
20
+ function headingId(node) {
21
+ return node.attrs?.anchor ?? node.attrs?.id ?? plainText(node).toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/đ/g, "d").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
22
+ }
23
+ function collectHeadings(blocks) {
24
+ const out = [];
25
+ const walk = (n) => {
26
+ if (n.type === "heading")
27
+ out.push({ level: Math.min(6, Math.max(1, Number(n.attrs?.level) || 2)), id: headingId(n), text: plainText(n) });
28
+ else
29
+ for (const c of n.content ?? [])
30
+ walk(c);
31
+ };
32
+ for (const b of blocks)
33
+ if (b.type === "richtext" && b.data)
34
+ walk(b.data);
35
+ return out;
36
+ }
37
+ function plainText(node) {
38
+ if (node.type === "text")
39
+ return node.text ?? "";
40
+ return (node.content ?? []).map(plainText).join("");
41
+ }
42
+ /** Màu do người viết chọn (nhóm, chữ): chỉ nhận dạng màu CSS, không nhận chuỗi tuỳ ý (url(), dấu ;…). */
43
+ const COLOR = /^(#[0-9a-f]{3,8}|(?:rgb|hsl)a?\([\d\s.,%deg/]+\)|[a-z]{3,20}|var\(--[\w-]+\))$/i;
44
+ const safeColor = (v) => (typeof v === "string" && COLOR.test(v.trim()) ? v.trim() : undefined);
45
+ const PADDING = { none: "0", small: "0.75rem", medium: "1.5rem", large: "3rem" };
46
+ const TONES = { info: "#2563eb", success: "#16a34a", warning: "#d97706", danger: "#dc2626" };
47
+ function alignStyle(attrs) {
48
+ return attrs?.textAlign && attrs.textAlign !== "left" ? { textAlign: attrs.textAlign } : undefined;
49
+ }
50
+ function renderMarks(text, marks, key) {
51
+ let out = text;
52
+ for (const [i, m] of (marks ?? []).entries()) {
53
+ const k = `${key}-m${i}`;
54
+ switch (m.type) {
55
+ case "bold":
56
+ out = _jsx("strong", { children: out }, k);
57
+ break;
58
+ case "italic":
59
+ out = _jsx("em", { children: out }, k);
60
+ break;
61
+ case "underline":
62
+ out = _jsx("u", { children: out }, k);
63
+ break;
64
+ case "strike":
65
+ out = _jsx("s", { children: out }, k);
66
+ break;
67
+ case "code":
68
+ out = _jsx("code", { children: out }, k);
69
+ break;
70
+ case "subscript":
71
+ out = _jsx("sub", { children: out }, k);
72
+ break;
73
+ case "superscript":
74
+ out = _jsx("sup", { children: out }, k);
75
+ break;
76
+ case "highlight":
77
+ out = _jsx("mark", { style: m.attrs?.color ? { background: m.attrs.color } : undefined, children: out }, k);
78
+ break;
79
+ case "textStyle":
80
+ out = m.attrs?.color ? _jsx("span", { style: { color: m.attrs.color }, children: out }, k) : out;
81
+ break;
82
+ case "link": {
83
+ const target = m.attrs?.target;
84
+ // rel do người viết/plugin đặt (nofollow, sponsored, ugc…) + luôn noopener khi mở tab mới
85
+ const rel = new Set(String(m.attrs?.rel ?? "").toLowerCase().split(/\s+/).filter((x) => /^[a-z-]+$/.test(x)));
86
+ if (target === "_blank") {
87
+ rel.add("noopener");
88
+ rel.add("noreferrer");
89
+ }
90
+ out = _jsx("a", { href: safeUrl(m.attrs?.href), target: target ?? undefined, rel: rel.size ? [...rel].join(" ") : undefined, children: out }, k);
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ const SIDES = ["", "Top", "Right", "Bottom", "Left"];
98
+ /** Thuộc tính inline của renderer bị kiểu dáng khối thay thế (inline luôn thắng class → phải bỏ). */
99
+ function overridden(style) {
100
+ const keys = [];
101
+ if (style.padding)
102
+ keys.push(...SIDES.map((x) => `padding${x}`));
103
+ if (style.margin)
104
+ keys.push(...SIDES.map((x) => `margin${x}`), "marginLeft", "marginRight");
105
+ if (style.gap)
106
+ keys.push("gap");
107
+ if (style.width)
108
+ keys.push("width");
109
+ if (style.maxWidth)
110
+ keys.push("maxWidth");
111
+ if (style.fontSize)
112
+ keys.push("fontSize");
113
+ if (style.lineHeight)
114
+ keys.push("lineHeight");
115
+ if (style.fontWeight)
116
+ keys.push("fontWeight");
117
+ if (style.textAlign)
118
+ keys.push("textAlign");
119
+ if (style.color?.text)
120
+ keys.push("color");
121
+ if (style.color?.background)
122
+ keys.push("background", "backgroundColor");
123
+ if (style.border)
124
+ keys.push(...SIDES.map((x) => `border${x}`));
125
+ if (style.radius)
126
+ keys.push("borderRadius");
127
+ return keys;
128
+ }
129
+ /**
130
+ * Gắn kiểu dáng (class `ecx-s-…`), biến thể (`ecx-is-…`), class riêng và neo (id) của khối vào phần tử đã vẽ
131
+ * (không có phần tử bọc ngoài → thêm div). Giá trị đã được normalizeContent kiểm.
132
+ */
133
+ function decorate(el, a, key, ctx) {
134
+ if (el == null || el === false)
135
+ return el;
136
+ const style = a.style && typeof a.style === "object" ? a.style : null;
137
+ const cls = style ? styleClass(style) : null;
138
+ if (style && cls && ctx.styles && !ctx.styles.has(cls))
139
+ ctx.styles.set(cls, styleCss(style, cls));
140
+ const extra = cx(cls, a.blockStyle && `ecx-is-${a.blockStyle}`, a.className);
141
+ if (isValidElement(el) && el.type !== Fragment && typeof el.type === "string") {
142
+ const e = el;
143
+ let inline = e.props.style;
144
+ if (inline && style) {
145
+ const drop = new Set(overridden(style));
146
+ inline = Object.fromEntries(Object.entries(inline).filter(([k]) => !drop.has(k)));
147
+ }
148
+ return cloneElement(e, { className: cx(e.props.className, extra) || undefined, style: inline, id: a.anchor ?? e.props.id });
149
+ }
150
+ return _jsx("div", { className: extra || undefined, id: a.anchor, children: el }, key);
151
+ }
152
+ async function renderNode(node, key, ctx) {
153
+ const el = await renderNodeInner(node, key, ctx);
154
+ const a = node.attrs;
155
+ return a && (a.style || a.blockStyle || a.className || a.anchor) ? decorate(el, a, key, ctx) : el;
156
+ }
157
+ async function renderNodeInner(node, key, ctx) {
158
+ const children = async () => Promise.all((node.content ?? []).map((c, i) => renderNode(c, `${key}-${i}`, ctx)));
159
+ const a = node.attrs ?? {};
160
+ switch (node.type) {
161
+ case "doc": return _jsx(Fragment, { children: await children() }, key);
162
+ case "text": return renderMarks(node.text ?? "", node.marks, key);
163
+ case "paragraph": {
164
+ // Đoạn văn chỉ chứa shortcode → render component của plugin.
165
+ const text = plainText(node);
166
+ const sc = SHORTCODE.exec(text);
167
+ if (sc) {
168
+ const Comp = await ctx.resolve.shortcode(sc[1]);
169
+ if (Comp)
170
+ return wrap(ctx, `Shortcode [${sc[1]}]`, key, _jsx(Comp, { attrs: parseAttrs(sc[2] ?? ""), site: ctx.site, post: ctx.post }));
171
+ // Shortcode của plugin chưa cài/đã tắt: ẩn với người đọc thay vì hiện mã thô.
172
+ console.warn(`[cms] Shortcode [${sc[1]}] không có plugin nào xử lý — đã ẩn khỏi trang`);
173
+ return null;
174
+ }
175
+ return _jsx("p", { style: alignStyle(a), children: await children() }, key);
176
+ }
177
+ case "heading": {
178
+ const level = Math.min(6, Math.max(1, Number(a.level) || 2));
179
+ const Tag = `h${level}`;
180
+ const id = headingId(node);
181
+ return _jsx(Tag, { id: id || undefined, style: alignStyle(a), children: await children() }, key);
182
+ }
183
+ case "bulletList": return _jsx("ul", { children: await children() }, key);
184
+ case "orderedList": return _jsx("ol", { start: a.start && a.start !== 1 ? a.start : undefined, children: await children() }, key);
185
+ case "listItem": return _jsx("li", { children: await children() }, key);
186
+ case "taskList": return _jsx("ul", { className: "ecx-task-list task-list", children: await children() }, key);
187
+ case "taskItem": return _jsxs("li", { className: "ecx-task-item task-item", children: [_jsx("input", { type: "checkbox", checked: !!a.checked, readOnly: true }), " ", await children()] }, key);
188
+ case "blockquote": return _jsx("blockquote", { children: await children() }, key);
189
+ case "codeBlock": return _jsx("pre", { children: _jsx("code", { className: a.language ? `language-${a.language}` : undefined, children: plainText(node) }) }, key);
190
+ case "hardBreak": return _jsx("br", {}, key);
191
+ case "horizontalRule": return _jsx("hr", {}, key);
192
+ case "image": {
193
+ // Ảnh chèn từ thư viện mang sẵn srcset (JPEG + WebP) và kích thước → responsive, không nhảy layout
194
+ // Bề rộng hiển thị = cỡ người viết kéo trong trình soạn → trình duyệt chọn file vừa đúng
195
+ const sizes = a.width ? `(max-width: ${a.width}px) 100vw, ${a.width}px` : (a.sizes || "(max-width: 1024px) 100vw, 1024px");
196
+ const img = _jsx("img", { src: safeUrl(a.src, ""), srcSet: a.srcset || undefined, sizes: a.srcset ? sizes : undefined, alt: a.alt ?? "", title: a.title ?? undefined, width: a.width ?? undefined, height: a.height ?? undefined, loading: "lazy", decoding: "async" });
197
+ const pic = a.webpSrcset ? _jsxs("picture", { children: [_jsx("source", { type: "image/webp", srcSet: a.webpSrcset, sizes: sizes }), img] }) : img;
198
+ const cls = cx("ecx-image cms-image", a.align && `ecx-align-${a.align} align${a.align}`, a.mediaId && `ecx-image-${a.mediaId} cms-image-${a.mediaId}`);
199
+ return a.caption || a.align ? _jsxs("figure", { className: cls, children: [pic, a.caption && _jsx("figcaption", { children: a.caption })] }, key) : _jsx(Fragment, { children: pic }, key);
200
+ }
201
+ case "gallery": {
202
+ const items = (a.images ?? []);
203
+ const cols = Math.min(6, Math.max(1, Number(a.columns) || 3));
204
+ return (_jsxs("figure", { className: cx("ecx-gallery cms-gallery", `ecx-gallery-columns-${cols} columns-${cols}`), style: { display: "grid", gap: 8, gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }, children: [items.map((im, i) => {
205
+ const el = _jsx("img", { src: safeUrl(im.src, ""), srcSet: im.srcset || undefined, sizes: `(max-width: 768px) 50vw, ${Math.round(100 / cols)}vw`, alt: im.alt ?? "", width: im.width, height: im.height, loading: "lazy", decoding: "async", style: { width: "100%", height: "100%", objectFit: "cover", aspectRatio: "1" } });
206
+ const pic = im.webpSrcset ? _jsxs("picture", { children: [_jsx("source", { type: "image/webp", srcSet: im.webpSrcset, sizes: `(max-width: 768px) 50vw, ${Math.round(100 / cols)}vw` }), el] }) : el;
207
+ return _jsxs("figure", { className: "ecx-gallery-item cms-gallery-item", style: { margin: 0 }, children: [im.full ? _jsx("a", { href: safeUrl(im.full), children: pic }) : pic, im.caption && _jsx("figcaption", { children: im.caption })] }, i);
208
+ }), a.caption && _jsx("figcaption", { className: "ecx-gallery-caption cms-gallery-caption", style: { gridColumn: "1 / -1" }, children: a.caption })] }, key));
209
+ }
210
+ case "video":
211
+ return _jsxs("figure", { className: "ecx-video cms-video", children: [_jsx("video", { src: safeUrl(a.src, ""), poster: a.poster ? safeUrl(a.poster, "") : undefined, controls: true, preload: "metadata", playsInline: true, style: { width: "100%", height: "auto" } }), a.caption && _jsx("figcaption", { children: a.caption })] }, key);
212
+ case "audio":
213
+ return _jsxs("figure", { className: "ecx-audio cms-audio", children: [_jsx("audio", { src: safeUrl(a.src, ""), controls: true, preload: "metadata", style: { width: "100%" } }), a.caption && _jsx("figcaption", { children: a.caption })] }, key);
214
+ case "file": {
215
+ const isPdf = String(a.mime ?? "").includes("pdf") || /\.pdf($|\?)/i.test(String(a.src ?? ""));
216
+ return (_jsxs("div", { className: "ecx-file cms-file", children: [isPdf && a.embed !== false && _jsx("object", { data: safeUrl(a.src, ""), type: "application/pdf", style: { width: "100%", height: 600 }, "aria-label": a.name, children: _jsx("a", { href: safeUrl(a.src), children: a.name }) }), _jsx("a", { className: "ecx-file-link cms-file-link", href: safeUrl(a.src), download: a.download ? "" : undefined, target: "_blank", rel: "noopener", children: a.name || ctx.labels?.download || "Download" })] }, key));
217
+ }
218
+ case "table": {
219
+ // Hàng đầu toàn ô tiêu đề → <thead>
220
+ const rows = await children();
221
+ const head = node.content?.[0]?.content?.length && node.content[0].content.every((c) => c.type === "tableHeader");
222
+ // Hàng cuối là chân bảng (attrs.footer) → <tfoot>
223
+ const foot = a.footer === true && rows.length > (head ? 2 : 1);
224
+ const body = rows.slice(head ? 1 : 0, foot ? -1 : undefined);
225
+ return _jsx("div", { className: "ecx-table table-wrap", children: _jsxs("table", { className: a.fixed ? "is-fixed" : undefined, children: [head && _jsx("thead", { children: rows[0] }), _jsx("tbody", { children: body }), foot && _jsx("tfoot", { children: rows.at(-1) })] }) }, key);
226
+ }
227
+ case "tableRow": return _jsx("tr", { children: await children() }, key);
228
+ case "tableHeader": return _jsx("th", { colSpan: a.colspan > 1 ? a.colspan : undefined, rowSpan: a.rowspan > 1 ? a.rowspan : undefined, children: await children() }, key);
229
+ case "tableCell": return _jsx("td", { colSpan: a.colspan > 1 ? a.colspan : undefined, rowSpan: a.rowspan > 1 ? a.rowspan : undefined, children: await children() }, key);
230
+ case "youtube": {
231
+ const m = String(a.src ?? "").match(/(?:v=|youtu\.be\/|embed\/)([\w-]{11})/);
232
+ const start = Number(a.start) > 0 ? `?start=${Math.floor(Number(a.start))}` : "";
233
+ return m ? _jsx("div", { className: "ecx-youtube embed", children: _jsx("iframe", { src: `https://www.youtube-nocookie.com/embed/${m[1]}${start}`, allowFullScreen: true, loading: "lazy", title: "YouTube" }) }, key) : null;
234
+ }
235
+ // Bố cục (@ecomcx/nexus-content 1.2). Cấu trúc dùng style trực tiếp → mọi theme hiển thị đúng; class `ecx-*` để theme tạo kiểu thêm (`cms-*` giữ tới 2.0).
236
+ case "columns": {
237
+ // Cột co giãn theo tỉ lệ độ rộng; màn hẹp (mỗi cột < 14rem) tự xuống dòng thành một cột — không cần CSS của theme
238
+ const stack = a.stack !== false;
239
+ const align = a.verticalAlign === "center" ? "center" : a.verticalAlign === "bottom" ? "flex-end" : "flex-start";
240
+ const cols = await Promise.all((node.content ?? []).map(async (c, i) => {
241
+ const w = Number(c.attrs?.width) > 0 ? Number(c.attrs?.width) : 1;
242
+ const inner = await Promise.all((c.content ?? []).map((cc, j) => renderNode(cc, `${key}-${i}-${j}`, ctx)));
243
+ return _jsx("div", { className: "ecx-column cms-column", style: { flex: `${w} 1 0`, minWidth: stack ? "min(100%, 14rem)" : 0 }, children: inner }, `${key}-${i}`);
244
+ }));
245
+ return _jsx("div", { className: cx("ecx-columns cms-columns", a.verticalAlign && `ecx-columns-${a.verticalAlign} cms-columns-${a.verticalAlign}`), style: { display: "flex", flexWrap: stack ? "wrap" : "nowrap", gap: "1.5rem", alignItems: align }, children: cols }, key);
246
+ }
247
+ case "column": return _jsx("div", { className: "ecx-column cms-column", children: await children() }, key);
248
+ case "group": {
249
+ const bg = safeColor(a.background), fg = safeColor(a.textColor);
250
+ const pad = PADDING[a.padding] ?? (bg ? PADDING.medium : undefined);
251
+ return _jsx("div", { className: cx("ecx-group cms-group", a.padding && `ecx-group-${a.padding} cms-group-${a.padding}`), style: { background: bg, color: fg, padding: pad, borderRadius: bg ? 12 : undefined }, children: await children() }, key);
252
+ }
253
+ case "callout": {
254
+ const tone = TONES[a.tone] ? a.tone : "info";
255
+ return (_jsxs("div", { role: "note", className: cx("ecx-callout cms-callout", `ecx-callout-${tone} cms-callout-${tone}`), style: { borderLeft: `4px solid ${TONES[tone]}`, background: `color-mix(in srgb, ${TONES[tone]} 8%, transparent)`, padding: "0.75rem 1rem", borderRadius: 8 }, children: [a.title && _jsx("p", { className: "ecx-callout-title cms-callout-title", style: { margin: 0, fontWeight: 600 }, children: a.title }), await children()] }, key));
256
+ }
257
+ case "button": {
258
+ if (!a.text)
259
+ return null;
260
+ const variant = a.variant === "secondary" || a.variant === "outline" ? a.variant : "primary";
261
+ const target = a.target === "_blank" ? "_blank" : undefined;
262
+ const rel = new Set(String(a.rel ?? "").toLowerCase().split(/\s+/).filter((x) => /^[a-z-]+$/.test(x)));
263
+ if (target) {
264
+ rel.add("noopener");
265
+ rel.add("noreferrer");
266
+ }
267
+ const look = variant === "outline"
268
+ ? { border: "1px solid var(--ecx-button-bg, var(--cms-button-bg, var(--primary, currentColor)))", color: "var(--ecx-button-bg, var(--cms-button-bg, var(--primary, inherit)))" }
269
+ : variant === "secondary"
270
+ ? { background: "var(--ecx-button-secondary-bg, var(--cms-button-secondary-bg, rgba(127,127,127,.15)))", color: "inherit" }
271
+ : { background: "var(--ecx-button-bg, var(--cms-button-bg, var(--primary, #18181b)))", color: "var(--ecx-button-fg, var(--cms-button-fg, #fff))" };
272
+ const link = (_jsx("a", { className: cx("ecx-button cms-button", `ecx-button-${variant} cms-button-${variant}`), href: safeUrl(a.href), target: target, rel: rel.size ? [...rel].join(" ") : undefined, style: { display: "inline-block", padding: "0.6em 1.2em", borderRadius: 8, textDecoration: "none", fontWeight: 600, lineHeight: 1.2, ...look }, children: a.text }, ctx.inButtons ? key : undefined));
273
+ if (ctx.inButtons)
274
+ return link;
275
+ return (_jsx("p", { className: cx("ecx-buttons cms-buttons", a.align && `ecx-buttons-${a.align} cms-buttons-${a.align}`), style: a.align ? { textAlign: a.align } : undefined, children: link }, key));
276
+ }
277
+ // Bộ khối cơ bản (@ecomcx/nexus-content 1.9)
278
+ case "spacer":
279
+ return _jsx("div", { className: "ecx-spacer", style: { height: spacingValue(a.height ?? "lg") ?? "1.5rem" }, "aria-hidden": "true" }, key);
280
+ case "buttons": {
281
+ const items = await Promise.all((node.content ?? []).map((c, i) => renderNode(c, `${key}-${i}`, { ...ctx, inButtons: true })));
282
+ return _jsx("div", { className: cx("ecx-buttons-group", a.align && `is-${a.align}`), children: items }, key);
283
+ }
284
+ case "cover": {
285
+ const src = a.src ? safeUrl(a.src, "") : "";
286
+ const overlay = colorCss(a.overlay) ?? (src ? "#000" : undefined);
287
+ return (_jsxs("div", { className: cx("ecx-cover", a.contentAlign && a.contentAlign !== "center" && `is-${a.contentAlign}`, a.fixed && src && "is-fixed"), style: { minHeight: spacingValue(a.minHeight ?? "400px"), backgroundImage: a.fixed && src ? `url("${src.replace(/["\\()]/g, encodeURIComponent)}")` : undefined }, children: [src && !a.fixed && _jsx("img", { className: "ecx-cover-img", src: src, alt: a.alt ?? "", loading: "lazy", decoding: "async" }), overlay && _jsx("span", { className: "ecx-cover-overlay", "aria-hidden": "true", style: { background: overlay, opacity: Math.min(100, Math.max(0, Number(a.opacity ?? 50))) / 100 } }), _jsx("div", { className: "ecx-cover-inner", children: await children() })] }, key));
288
+ }
289
+ case "mediaText": {
290
+ const width = Math.min(80, Math.max(20, Number(a.width) || 50));
291
+ return (_jsxs("div", { className: cx("ecx-media-text", a.position === "right" && "is-right", a.verticalAlign && a.verticalAlign !== "center" && `is-${a.verticalAlign}`, a.stack !== false && "is-stack"), style: { ["--ecx-media-width"]: `${width}%` }, children: [_jsx("figure", { className: "ecx-media-text-media", style: { margin: 0 }, children: a.src && _jsx("img", { src: safeUrl(a.src, ""), alt: a.alt ?? "", loading: "lazy", decoding: "async" }) }), _jsx("div", { className: "ecx-media-text-content", children: await children() })] }, key));
292
+ }
293
+ case "pullquote":
294
+ return _jsxs("figure", { className: "ecx-pullquote", children: [_jsx("blockquote", { children: _jsx("p", { children: await children() }) }), a.citation && _jsx("figcaption", { children: a.citation })] }, key);
295
+ case "details":
296
+ return _jsxs("details", { className: "ecx-details", open: a.open === true || undefined, children: [_jsx("summary", { children: a.summary || "…" }), await children()] }, key);
297
+ case "customHtml":
298
+ // Máy chủ đã lọc HTML khi người soạn thiếu quyền unfiltered_html (ContentSanitizer)
299
+ return a.html ? _jsx("div", { className: "ecx-html", dangerouslySetInnerHTML: { __html: String(a.html) } }, key) : null;
300
+ case "socialLinks": {
301
+ const links = (Array.isArray(a.links) ? a.links : []).filter((l) => l && typeof l.url === "string" && typeof l.service === "string" && /^[a-z0-9-]{1,30}$/.test(l.service));
302
+ if (!links.length)
303
+ return null;
304
+ return (_jsx("ul", { className: cx("ecx-social", `is-${a.size ?? "medium"}`, a.align && `is-${a.align}`, a.showLabels && "has-labels"), children: links.map((l, i) => {
305
+ const name = l.label || SOCIAL[l.service] || l.service;
306
+ return (_jsx("li", { children: _jsxs("a", { className: `ecx-social-${l.service}`, href: safeUrl(l.url), target: "_blank", rel: "noopener noreferrer me", "aria-label": a.showLabels ? undefined : name, title: name, children: [_jsx("svg", { viewBox: "0 0 24 24", width: "1em", height: "1em", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: SOCIAL_ICONS[l.service] ?? SOCIAL_ICONS.website, fillRule: SOCIAL_ICONS[l.service] && !["email", "website", "linkedin"].includes(l.service) ? undefined : "evenodd" }) }), a.showLabels && _jsx("span", { children: name })] }) }, i));
307
+ }) }, key));
308
+ }
309
+ case "tableOfContents": {
310
+ const max = Math.min(4, Math.max(2, Number(a.maxLevel) || 3));
311
+ const items = (ctx.headings ?? []).filter((h) => h.level >= 2 && h.level <= max && h.id);
312
+ if (!items.length)
313
+ return null;
314
+ const title = a.title || ctx.labels?.toc || "Contents";
315
+ return (_jsxs("nav", { className: "ecx-toc", "aria-label": title, children: [_jsx("p", { className: "ecx-toc-title", children: title }), _jsx("ol", { children: items.map((h, i) => _jsx("li", { className: `ecx-toc-l${h.level}`, style: h.level > 2 ? { marginLeft: `${h.level - 2}em` } : undefined, children: _jsx("a", { href: `#${h.id}`, children: h.text }) }, i)) })] }, key));
316
+ }
317
+ case "preformatted":
318
+ return _jsx("pre", { className: "ecx-pre", children: await children() }, key);
319
+ case "embed": {
320
+ // Khung nhúng chỉ cho nhà cung cấp đã biết; địa chỉ khung do embedInfo dựng → không nhúng được trang tuỳ ý
321
+ const info = embedInfo(a.url);
322
+ if (!info)
323
+ return a.url ? _jsx("p", { className: "ecx-embed-link cms-embed-link", children: _jsx("a", { href: safeUrl(a.url), target: "_blank", rel: "noopener noreferrer", children: a.caption || a.url }) }, key) : null;
324
+ const box = info.aspect ? { position: "relative", aspectRatio: String(info.aspect) } : { position: "relative", height: info.height };
325
+ return (_jsxs("figure", { className: cx("ecx-embed cms-embed", `ecx-embed-${info.provider} cms-embed-${info.provider}`), style: { maxWidth: info.maxWidth, marginLeft: info.maxWidth ? "auto" : undefined, marginRight: info.maxWidth ? "auto" : undefined }, children: [_jsx("div", { style: box, children: _jsx("iframe", { src: info.src, title: a.caption || info.title, loading: "lazy", allowFullScreen: true, referrerPolicy: "strict-origin-when-cross-origin", allow: "autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture", style: { position: "absolute", inset: 0, width: "100%", height: "100%", border: 0, borderRadius: 8 } }) }), a.caption && _jsx("figcaption", { children: a.caption })] }, key));
326
+ }
327
+ default: {
328
+ if (DYNAMIC.has(node.type))
329
+ return ctx.dynamic ? ctx.dynamic(node, key, ctx, (c, nodes = node.content ?? []) => Promise.all(nodes.map((n, i) => renderNode(n, `${key}-${i}`, c)))) : null;
330
+ // Nút của plugin trong nội dung soạn thảo (defineBlock) — cùng renderer với khối cấp cao nhất: definePlugin({ blocks })
331
+ const Comp = await ctx.resolve.block(node.type);
332
+ const inner = node.content ? await children() : null;
333
+ if (Comp) {
334
+ const block = { type: node.type, data: a };
335
+ return wrap(ctx, `Block ${node.type}`, key, _jsx(Comp, { block: block, data: a, site: ctx.site, post: ctx.post, children: inner }));
336
+ }
337
+ // Plugin đã tắt/chưa cài: vẫn hiện phần chữ bên trong (nếu có), không để lại khung rỗng
338
+ return inner ? _jsx(Fragment, { children: inner }, key) : null;
339
+ }
340
+ }
341
+ }
342
+ /** HTML (vd nội dung nhập từ hệ thống cũ): thay shortcode đứng riêng bằng component. */
343
+ async function renderHtml(html, key, ctx) {
344
+ const parts = [];
345
+ let last = 0;
346
+ let i = 0;
347
+ for (const m of html.matchAll(SHORTCODE_INLINE)) {
348
+ const Comp = await ctx.resolve.shortcode(m[1]);
349
+ if (!Comp)
350
+ continue;
351
+ if (m.index > last)
352
+ parts.push(_jsx("div", { dangerouslySetInnerHTML: { __html: html.slice(last, m.index) } }, `${key}-h${i++}`));
353
+ parts.push(wrap(ctx, `Shortcode [${m[1]}]`, `${key}-s${i++}`, _jsx(Comp, { attrs: parseAttrs(m[2] ?? ""), site: ctx.site, post: ctx.post })));
354
+ last = m.index + m[0].length;
355
+ }
356
+ if (last < html.length)
357
+ parts.push(_jsx("div", { dangerouslySetInnerHTML: { __html: html.slice(last) } }, `${key}-h${i++}`));
358
+ return _jsx(Fragment, { children: parts }, key);
359
+ }
360
+ async function renderBlock(block, key, ctx) {
361
+ switch (block.type) {
362
+ case "richtext": return block.data ? await renderNode(block.data, key, ctx) : null;
363
+ case "html": return await renderHtml(String(block.data?.html ?? ""), key, ctx);
364
+ default: {
365
+ const Comp = await ctx.resolve.block(block.type);
366
+ return Comp ? wrap(ctx, `Block ${block.type}`, key, _jsx(Comp, { block: block, data: block.data, site: ctx.site, post: ctx.post })) : null;
367
+ }
368
+ }
369
+ }
370
+ function wrap(ctx, name, key, el) {
371
+ return ctx.boundary ? ctx.boundary(name, key, el) : _jsx(Fragment, { children: el }, key);
372
+ }
373
+ /**
374
+ * Vẽ nội dung bài viết: chuẩn hoá theo định dạng của CMS (bài lưu bằng bản cũ được nâng cấp, cấu trúc hỏng bị bỏ),
375
+ * rồi vẽ từng khối. Khối/nút của plugin không có renderer (plugin tắt) → bỏ qua, dữ liệu vẫn nguyên trong DB.
376
+ */
377
+ export async function renderContent(content, ctx, transform) {
378
+ const { doc } = normalizeContent(content, { blocks: ctx.blocks });
379
+ const blocks = transform ? await transform(doc.blocks) : doc.blocks;
380
+ // Lần vẽ ngoài cùng gom CSS kiểu dáng của mọi khối (kể cả phần lồng bên trong) thành một thẻ <style>
381
+ const own = !ctx.styles;
382
+ if (own)
383
+ ctx = { ...ctx, styles: new Map(), headings: ctx.headings ?? collectHeadings(blocks) };
384
+ const out = await Promise.all(blocks.map((b, i) => renderBlock(b, b.id ?? `b${i}`, ctx)));
385
+ if (own && ctx.styles.size)
386
+ out.unshift(_jsx("style", { "data-ecx-styles": "", children: [...ctx.styles.values()].join("") }, "ecx-styles"));
387
+ return out;
388
+ }
@@ -0,0 +1,58 @@
1
+ import type { ComponentType } from "react";
2
+ import type { FilterMap, LayoutProps, PluginDefinition, PluginLoader, RegionProps, ShortcodeProps, Site, TemplateProps, ThemeEntry } from "./types.js";
3
+ interface RuntimeConfig {
4
+ themes: Record<string, ThemeEntry>;
5
+ plugins: Record<string, PluginLoader>;
6
+ }
7
+ /** Frontend core gọi một lần với registry được sinh tự động (generated/registry.ts). */
8
+ export declare function configureRuntime(config: RuntimeConfig): void;
9
+ export declare const FALLBACK_THEME = "default";
10
+ /** Tên template trang có sẵn (theme có template cùng tên thì dùng của theme). */
11
+ export declare const BUILTIN_PAGE_TEMPLATES: readonly ["canvas", "full-width"];
12
+ export interface LoadedTheme {
13
+ slug: string;
14
+ chain: ThemeEntry[];
15
+ templateNames: string[];
16
+ /** layout = false: template tự vẽ cả trang, không bọc trong layout của theme (`export const layout = false` trong file template). */
17
+ resolveTemplate(candidates: string[]): Promise<{
18
+ name: string;
19
+ Component: ComponentType<TemplateProps>;
20
+ layout: boolean;
21
+ }>;
22
+ layout(): Promise<ComponentType<LayoutProps> | null>;
23
+ }
24
+ /** Theme đang active (kèm theme cha nếu là child theme). Fallback về "default" nếu theme không tồn tại trong bản build. */
25
+ export declare const getActiveTheme: (site?: Site) => Promise<LoadedTheme>;
26
+ /** Định nghĩa của plugin đang bật + phần "extend" của theme. */
27
+ export declare const getActiveExtensions: (site?: Site) => Promise<PluginDefinition[]>;
28
+ /**
29
+ * Hàm mà plugin đang bật công bố qua definePlugin({ exports }). null nếu plugin chưa cài/chưa bật — luôn kiểm tra:
30
+ * const shop = await getPlugin<ShopApi>("shop");
31
+ * if (shop) cart = await shop.getCart();
32
+ * Chỉ dùng phía server (Server Component, route, generateMetadata). Component client nhận dữ liệu qua props.
33
+ */
34
+ export declare function getPlugin<T = Record<string, unknown>>(slug: string, site?: Site): Promise<T | null>;
35
+ /** Plugin có đang bật không. */
36
+ export declare function hasPlugin(slug: string, site?: Site): Promise<boolean>;
37
+ export declare function findShortcode(name: string, site?: Site): Promise<ComponentType<ShortcodeProps> | null>;
38
+ export declare function findWidget(type: string, site?: Site): Promise<ComponentType<import("./types.js").WidgetProps<any>> | null>;
39
+ /** Route gốc của plugin đang bật (vd "/news-sitemap.xml"). */
40
+ export declare function findRoute(path: string, site?: Site): Promise<import("./types.js").PluginRoute | null>;
41
+ export declare function findBlock(type: string, site?: Site): Promise<ComponentType<import("./types.js").BlockProps<any>> | null>;
42
+ /** Component thay vùng (definePlugin → regions): priority nhỏ nhất thắng, bằng nhau thì plugin trước theme. */
43
+ export declare function findRegion(name: string, site?: Site): Promise<ComponentType<RegionProps> | null>;
44
+ export interface LoadedSlotItem {
45
+ Component: ComponentType<any>;
46
+ priority: number;
47
+ }
48
+ export declare function getSlotItems(name: string, site?: Site): Promise<LoadedSlotItem[]>;
49
+ /** Lấy các component gắn vào slot, đã sắp xếp theo priority */
50
+ export declare function getSlotComponents(name: string, site?: Site): Promise<ComponentType<any>[]>;
51
+ /** Chạy filter của các plugin/theme đang bật theo thứ tự, bỏ qua filter bị lỗi. */
52
+ export declare function applyFilters<K extends keyof FilterMap>(name: K, value: Parameters<NonNullable<FilterMap[K]>>[0], ctx?: Parameters<NonNullable<FilterMap[K]>>[1], site?: Site): Promise<Parameters<NonNullable<FilterMap[K]>>[0]>;
53
+ export declare function applyFilters<T = any>(name: string, value: T, ctx?: any, site?: Site): Promise<T>;
54
+ /** Phát action tới các plugin/theme đang bật. */
55
+ export declare function doAction(name: string, arg?: any, site?: Site): Promise<void>;
56
+ /** Dùng trong file entry của plugin/theme: export default definePlugin({...}). */
57
+ export declare function definePlugin(def: PluginDefinition): PluginDefinition;
58
+ export {};