@ecomcx/ecomweb-content 0.0.0-stage → 1.15.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/LICENSE +21 -0
- package/README.md +178 -2
- package/dist/bindings.d.ts +19 -0
- package/dist/bindings.js +44 -0
- package/dist/block.d.ts +58 -0
- package/dist/block.js +60 -0
- package/dist/embed.d.ts +23 -0
- package/dist/embed.js +63 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/migrate.d.ts +2 -0
- package/dist/migrate.js +19 -0
- package/dist/normalize.d.ts +25 -0
- package/dist/normalize.js +147 -0
- package/dist/schema.d.ts +31 -0
- package/dist/schema.js +173 -0
- package/dist/style.d.ts +132 -0
- package/dist/style.js +523 -0
- package/dist/text.d.ts +9 -0
- package/dist/text.js +30 -0
- package/dist/types.d.ts +36 -0
- package/dist/types.js +6 -0
- package/dist/url.d.ts +7 -0
- package/dist/url.js +21 -0
- package/fixtures/all-core-nodes.json +782 -0
- package/fixtures/attr-coercion.json +219 -0
- package/fixtures/basic-blocks.json +414 -0
- package/fixtures/bindings-lock.json +33 -0
- package/fixtures/block-extras.json +131 -0
- package/fixtures/block-styles.json +316 -0
- package/fixtures/dynamic-blocks.json +316 -0
- package/fixtures/editor-output-nulls.json +112 -0
- package/fixtures/empty-and-newer.json +12 -0
- package/fixtures/invalid-structure.json +145 -0
- package/fixtures/layout-blocks.json +238 -0
- package/fixtures/legacy-html-string.json +17 -0
- package/fixtures/legacy-tiptap-doc.json +41 -0
- package/fixtures/plugin-blocks-preserved.json +155 -0
- package/fixtures/style-layout-effects.json +47 -0
- package/fixtures/synced-pattern.json +80 -0
- package/package.json +44 -4
- package/style.css +169 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { blockNodeSpec, migrateBlockAttrs } from "./block.js";
|
|
2
|
+
import { migrateContent } from "./migrate.js";
|
|
3
|
+
import { CORE_MARKS, CORE_NODES, normalizeAttrs } from "./schema.js";
|
|
4
|
+
import { normalizeAnchor, normalizeBlockStyleName, normalizeClassName, normalizeStyle } from "./style.js";
|
|
5
|
+
import { normalizeBindings, normalizeLock, normalizeTemplateLock } from "./bindings.js";
|
|
6
|
+
import { CONTENT_VERSION } from "./types.js";
|
|
7
|
+
const empty = () => ({ version: CONTENT_VERSION, blocks: [] });
|
|
8
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
9
|
+
/**
|
|
10
|
+
* Chuẩn hoá nội dung về định dạng của CMS. Nhận: {version, blocks}, cây TipTap/ProseMirror thuần (`type: "doc"`),
|
|
11
|
+
* chuỗi HTML. Sửa cấu trúc sai (bỏ nút không hợp lệ, ép kiểu thuộc tính khối lõi, bỏ null), giữ nguyên khối/nút lạ.
|
|
12
|
+
* Chạy hai lần cho cùng kết quả (idempotent). Máy chủ chạy cùng quy tắc (ContentFormat.cs).
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeContent(input, options = {}) {
|
|
15
|
+
const issues = [];
|
|
16
|
+
const state = { count: 0, issues, maxDepth: options.maxDepth ?? 64, maxNodes: options.maxNodes ?? 100_000,
|
|
17
|
+
nodes: { ...CORE_NODES, ...options.nodes, ...Object.fromEntries((options.blocks ?? []).map((b) => [b.name, blockNodeSpec(b)])) },
|
|
18
|
+
marks: { ...CORE_MARKS, ...options.marks }, blocks: new Map((options.blocks ?? []).map((b) => [b.name, b])) };
|
|
19
|
+
if (input == null)
|
|
20
|
+
return { doc: empty(), issues };
|
|
21
|
+
if (typeof input === "string")
|
|
22
|
+
return { doc: { version: CONTENT_VERSION, blocks: input ? [{ id: "main", type: "html", data: { html: input } }] : [] }, issues };
|
|
23
|
+
if (!isObj(input)) {
|
|
24
|
+
issues.push("content is not an object");
|
|
25
|
+
return { doc: empty(), issues };
|
|
26
|
+
}
|
|
27
|
+
if (input.type === "doc")
|
|
28
|
+
input = { version: CONTENT_VERSION, blocks: [{ id: "main", type: "richtext", data: input }] };
|
|
29
|
+
const raw = input;
|
|
30
|
+
if (!Array.isArray(raw.blocks)) {
|
|
31
|
+
issues.push("content has no blocks");
|
|
32
|
+
return { doc: empty(), issues };
|
|
33
|
+
}
|
|
34
|
+
const version = typeof raw.version === "number" && Number.isInteger(raw.version) && raw.version > 0 ? raw.version : CONTENT_VERSION;
|
|
35
|
+
const blocks = [];
|
|
36
|
+
for (const b of raw.blocks) {
|
|
37
|
+
const block = normalizeBlock(b, state);
|
|
38
|
+
if (block)
|
|
39
|
+
blocks.push(block);
|
|
40
|
+
}
|
|
41
|
+
return { doc: migrateContent({ version, blocks }, issues), issues };
|
|
42
|
+
}
|
|
43
|
+
function normalizeBlock(b, state) {
|
|
44
|
+
if (!isObj(b) || typeof b.type !== "string" || !b.type || b.type.length > 100) {
|
|
45
|
+
state.issues.push("dropped invalid block");
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
let data;
|
|
49
|
+
switch (b.type) {
|
|
50
|
+
case "richtext": {
|
|
51
|
+
const node = normalizeNode(b.data, 0, state);
|
|
52
|
+
if (!node || node.type !== "doc")
|
|
53
|
+
state.issues.push("richtext block without doc");
|
|
54
|
+
data = node && node.type === "doc" ? node : { type: "doc" };
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case "html":
|
|
58
|
+
data = { html: isObj(b.data) && typeof b.data.html === "string" ? b.data.html : "" };
|
|
59
|
+
break;
|
|
60
|
+
default:
|
|
61
|
+
// Khối của plugin: dữ liệu do plugin quyết định — giữ nguyên (kể cả khi plugin đã tắt)
|
|
62
|
+
data = b.data === undefined ? null : b.data;
|
|
63
|
+
}
|
|
64
|
+
return typeof b.id === "string" && b.id ? { id: b.id, type: b.type, data } : { type: b.type, data };
|
|
65
|
+
}
|
|
66
|
+
function normalizeNode(node, depth, state) {
|
|
67
|
+
if (!isObj(node) || typeof node.type !== "string" || !node.type) {
|
|
68
|
+
state.issues.push("dropped node without type");
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (depth > state.maxDepth) {
|
|
72
|
+
state.issues.push(`dropped node deeper than ${state.maxDepth}`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (++state.count > state.maxNodes) {
|
|
76
|
+
if (state.count === state.maxNodes + 1)
|
|
77
|
+
state.issues.push(`content truncated at ${state.maxNodes} nodes`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (node.type === "text") {
|
|
81
|
+
if (typeof node.text !== "string" || node.text === "") {
|
|
82
|
+
state.issues.push("dropped empty text node");
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const marks = normalizeMarks(node.marks, state);
|
|
86
|
+
return marks ? { type: "text", text: node.text, marks } : { type: "text", text: node.text };
|
|
87
|
+
}
|
|
88
|
+
const out = { type: node.type };
|
|
89
|
+
const def = state.blocks.get(node.type);
|
|
90
|
+
let raw = node.attrs;
|
|
91
|
+
if (def && def.version > 1) {
|
|
92
|
+
try {
|
|
93
|
+
raw = migrateBlockAttrs(def, (isObj(raw) ? raw : {}));
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
state.issues.push(`block ${node.type}: migrate failed (${e.message}) — kept as is`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const attrs = normalizeAttrs(raw, state.nodes[node.type]?.attrs);
|
|
100
|
+
// Kiểu dáng của khối (mọi khối, kể cả khối plugin): chỉ giữ giá trị hợp lệ
|
|
101
|
+
if (attrs && "style" in attrs) {
|
|
102
|
+
const style = normalizeStyle(attrs.style);
|
|
103
|
+
if (style)
|
|
104
|
+
attrs.style = style;
|
|
105
|
+
else
|
|
106
|
+
delete attrs.style;
|
|
107
|
+
}
|
|
108
|
+
// Biến thể kiểu, neo, class riêng (mọi khối)
|
|
109
|
+
for (const [k, fn] of [["blockStyle", normalizeBlockStyleName], ["anchor", normalizeAnchor], ["className", normalizeClassName], ["bindings", normalizeBindings], ["lock", normalizeLock], ["templateLock", normalizeTemplateLock]]) {
|
|
110
|
+
if (attrs && k in attrs) {
|
|
111
|
+
const v = fn(attrs[k]);
|
|
112
|
+
if (v === undefined)
|
|
113
|
+
delete attrs[k];
|
|
114
|
+
else
|
|
115
|
+
attrs[k] = v;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (attrs && Object.keys(attrs).length)
|
|
119
|
+
out.attrs = attrs;
|
|
120
|
+
if (Array.isArray(node.content)) {
|
|
121
|
+
const children = [];
|
|
122
|
+
for (const c of node.content) {
|
|
123
|
+
const child = normalizeNode(c, depth + 1, state);
|
|
124
|
+
if (child)
|
|
125
|
+
children.push(child);
|
|
126
|
+
}
|
|
127
|
+
if (children.length)
|
|
128
|
+
out.content = children;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function normalizeMarks(marks, state) {
|
|
133
|
+
if (!Array.isArray(marks))
|
|
134
|
+
return undefined;
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const m of marks) {
|
|
137
|
+
if (!isObj(m) || typeof m.type !== "string" || !m.type) {
|
|
138
|
+
state.issues.push("dropped invalid mark");
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (out.some((x) => x.type === m.type))
|
|
142
|
+
continue; // cùng một định dạng hai lần
|
|
143
|
+
const attrs = normalizeAttrs(m.attrs, state.marks[m.type]?.attrs);
|
|
144
|
+
out.push(attrs ? { type: m.type, attrs } : { type: m.type });
|
|
145
|
+
}
|
|
146
|
+
return out.length ? out : undefined;
|
|
147
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mô tả thuộc tính của khối / định dạng chữ — dùng cho khối lõi dưới đây và cho khối của plugin (defineBlock).
|
|
3
|
+
* Giá trị sai kiểu được ép về đúng kiểu nếu được (số dạng chuỗi → số), không ép được thì bỏ (= giá trị mặc định).
|
|
4
|
+
* `null` và "không có" là một: thuộc tính null không được lưu.
|
|
5
|
+
*/
|
|
6
|
+
export type AttrType = "string" | "number" | "integer" | "boolean" | "url" | "json";
|
|
7
|
+
export interface AttrSpec {
|
|
8
|
+
type: AttrType;
|
|
9
|
+
/** Giá trị khi không có — trình soạn và website dùng chung. */
|
|
10
|
+
default?: unknown;
|
|
11
|
+
/** Chỉ nhận các giá trị này. */
|
|
12
|
+
enum?: readonly unknown[];
|
|
13
|
+
/** Số: kẹp trong [min, max]. */
|
|
14
|
+
min?: number;
|
|
15
|
+
max?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface NodeSpec {
|
|
18
|
+
attrs?: Record<string, AttrSpec>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Khối lõi trong nội dung soạn thảo (khối `richtext`). Thuộc tính không có trong danh sách vẫn được giữ nguyên
|
|
22
|
+
* (plugin có thể thêm thuộc tính vào khối lõi) — chỉ thuộc tính có mô tả mới được kiểm kiểu.
|
|
23
|
+
*/
|
|
24
|
+
export declare const CORE_NODES: Record<string, NodeSpec>;
|
|
25
|
+
/** Khối động: nội dung lấy theo ngữ cảnh lúc hiển thị (website, trang, bài trong vòng lặp). */
|
|
26
|
+
export declare const DYNAMIC_NODES: readonly ["siteTitle", "siteLogo", "siteTagline", "navigation", "search", "languageSwitcher", "copyright", "postTitle", "postContent", "postExcerpt", "featuredImage", "postDate", "postAuthor", "postTerms", "comments", "queryTitle", "termDescription", "breadcrumbs", "postNavigation", "query", "postTemplate", "pagination", "queryEmpty", "templatePart", "syncedPattern"];
|
|
27
|
+
export declare const CORE_MARKS: Record<string, NodeSpec>;
|
|
28
|
+
/** Ép giá trị theo mô tả. Trả về undefined = bỏ thuộc tính (dùng mặc định). */
|
|
29
|
+
export declare function coerceAttr(spec: AttrSpec, value: unknown): unknown;
|
|
30
|
+
/** Chuẩn hoá bộ thuộc tính: ép kiểu thuộc tính có mô tả, giữ nguyên thuộc tính khác, bỏ null. Rỗng → undefined. */
|
|
31
|
+
export declare function normalizeAttrs(attrs: unknown, spec?: Record<string, AttrSpec>): Record<string, unknown> | undefined;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const s = (extra = {}) => ({ type: "string", ...extra });
|
|
2
|
+
const n = (extra = {}) => ({ type: "number", ...extra });
|
|
3
|
+
const i = (extra = {}) => ({ type: "integer", ...extra });
|
|
4
|
+
const b = (extra = {}) => ({ type: "boolean", ...extra });
|
|
5
|
+
const url = () => ({ type: "url" });
|
|
6
|
+
const align = s({ enum: ["left", "center", "right", "justify"] });
|
|
7
|
+
const media = { src: url(), name: s(), mime: s(), mediaId: { type: "json" }, caption: s() };
|
|
8
|
+
/**
|
|
9
|
+
* Khối lõi trong nội dung soạn thảo (khối `richtext`). Thuộc tính không có trong danh sách vẫn được giữ nguyên
|
|
10
|
+
* (plugin có thể thêm thuộc tính vào khối lõi) — chỉ thuộc tính có mô tả mới được kiểm kiểu.
|
|
11
|
+
*/
|
|
12
|
+
export const CORE_NODES = {
|
|
13
|
+
doc: {},
|
|
14
|
+
text: {},
|
|
15
|
+
paragraph: { attrs: { textAlign: align } },
|
|
16
|
+
heading: { attrs: { level: i({ min: 1, max: 6, default: 2 }), textAlign: align, id: s() } },
|
|
17
|
+
bulletList: {},
|
|
18
|
+
orderedList: { attrs: { start: i({ min: 0, default: 1 }) } },
|
|
19
|
+
listItem: {},
|
|
20
|
+
taskList: {},
|
|
21
|
+
taskItem: { attrs: { checked: b({ default: false }) } },
|
|
22
|
+
blockquote: {},
|
|
23
|
+
codeBlock: { attrs: { language: s() } },
|
|
24
|
+
hardBreak: {},
|
|
25
|
+
horizontalRule: {},
|
|
26
|
+
image: {
|
|
27
|
+
attrs: {
|
|
28
|
+
src: url(), alt: s(), title: s(), width: n({ min: 1 }), height: n({ min: 1 }), srcset: s(), webpSrcset: s(),
|
|
29
|
+
mediaId: { type: "json" }, size: s({ default: "large" }), caption: s(), align: s({ enum: ["left", "right", "center", "wide", "full"] }),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
gallery: { attrs: { images: { type: "json", default: [] }, columns: i({ min: 1, max: 6, default: 3 }), caption: s() } },
|
|
33
|
+
video: { attrs: { ...media, poster: url() } },
|
|
34
|
+
audio: { attrs: { ...media } },
|
|
35
|
+
file: { attrs: { ...media, size: n({ min: 0 }), embed: b({ default: true }), download: b() } },
|
|
36
|
+
table: { attrs: { footer: b({ default: false }), fixed: b({ default: false }) } },
|
|
37
|
+
tableRow: {},
|
|
38
|
+
tableHeader: { attrs: { colspan: i({ min: 1, default: 1 }), rowspan: i({ min: 1, default: 1 }), colwidth: { type: "json" } } },
|
|
39
|
+
tableCell: { attrs: { colspan: i({ min: 1, default: 1 }), rowspan: i({ min: 1, default: 1 }), colwidth: { type: "json" } } },
|
|
40
|
+
youtube: { attrs: { src: url(), start: i({ min: 0 }), width: n({ min: 1 }), height: n({ min: 1 }) } },
|
|
41
|
+
// Bố cục (1.2): cột, nhóm, hộp lưu ý chứa khối khác; nút; nhúng theo đường dẫn (embedInfo)
|
|
42
|
+
columns: { attrs: { verticalAlign: s({ enum: ["top", "center", "bottom"] }), stack: b({ default: true }) } },
|
|
43
|
+
column: { attrs: { width: n({ min: 5, max: 100 }) } },
|
|
44
|
+
group: { attrs: { background: s(), textColor: s(), padding: s({ enum: ["none", "small", "medium", "large"], default: "medium" }) } },
|
|
45
|
+
callout: { attrs: { tone: s({ enum: ["info", "success", "warning", "danger"], default: "info" }), title: s() } },
|
|
46
|
+
button: {
|
|
47
|
+
attrs: {
|
|
48
|
+
text: s(), href: url(), target: s(), rel: s(),
|
|
49
|
+
variant: s({ enum: ["primary", "secondary", "outline"], default: "primary" }), align: s({ enum: ["left", "center", "right"] }),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
embed: { attrs: { url: url(), caption: s() } },
|
|
53
|
+
// Bộ khối cơ bản (1.9)
|
|
54
|
+
spacer: { attrs: { height: s({ default: "lg" }) } },
|
|
55
|
+
buttons: { attrs: { align: s({ enum: ["left", "center", "right", "space-between"] }) } },
|
|
56
|
+
cover: {
|
|
57
|
+
attrs: {
|
|
58
|
+
src: url(), mediaId: { type: "json" }, alt: s(), overlay: s(), opacity: i({ min: 0, max: 100, default: 50 }), minHeight: s({ default: "400px" }),
|
|
59
|
+
contentAlign: s({ enum: ["top", "center", "bottom"], default: "center" }), fixed: b({ default: false }),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
mediaText: {
|
|
63
|
+
attrs: {
|
|
64
|
+
src: url(), mediaId: { type: "json" }, alt: s(), position: s({ enum: ["left", "right"], default: "left" }), width: i({ min: 20, max: 80, default: 50 }),
|
|
65
|
+
verticalAlign: s({ enum: ["top", "center", "bottom"], default: "center" }), stack: b({ default: true }),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
pullquote: { attrs: { citation: s() } },
|
|
69
|
+
details: { attrs: { summary: s(), open: b({ default: false }) } },
|
|
70
|
+
customHtml: { attrs: { html: s() } },
|
|
71
|
+
socialLinks: { attrs: { links: { type: "json", default: [] }, size: s({ enum: ["small", "medium", "large"], default: "medium" }), align: s({ enum: ["left", "center", "right"] }), showLabels: b({ default: false }) } },
|
|
72
|
+
tableOfContents: { attrs: { title: s(), maxLevel: i({ min: 2, max: 4, default: 3 }) } },
|
|
73
|
+
preformatted: {},
|
|
74
|
+
// Khối động (1.6): lấy dữ liệu lúc hiển thị theo ngữ cảnh (website, trang đang xem, bài trong vòng lặp) — dùng trong template
|
|
75
|
+
siteTitle: { attrs: { level: i({ min: 0, max: 6, default: 0 }), link: b({ default: true }), textAlign: align } },
|
|
76
|
+
siteLogo: { attrs: { width: n({ min: 16, max: 600, default: 120 }), link: b({ default: true }) } },
|
|
77
|
+
siteTagline: { attrs: { textAlign: align } },
|
|
78
|
+
navigation: { attrs: { location: s({ default: "primary" }), orientation: s({ enum: ["horizontal", "vertical"], default: "horizontal" }), mobile: b({ default: true }) } },
|
|
79
|
+
search: { attrs: { placeholder: s(), buttonText: s() } },
|
|
80
|
+
languageSwitcher: {},
|
|
81
|
+
copyright: { attrs: { text: s(), textAlign: align } },
|
|
82
|
+
postTitle: { attrs: { level: i({ min: 1, max: 6, default: 1 }), link: b({ default: false }), textAlign: align } },
|
|
83
|
+
postContent: {},
|
|
84
|
+
postExcerpt: { attrs: { moreText: s() } },
|
|
85
|
+
featuredImage: { attrs: { size: s({ default: "large" }), link: b({ default: false }), aspect: s({ enum: ["auto", "16/9", "4/3", "1/1", "3/4"], default: "auto" }) } },
|
|
86
|
+
postDate: { attrs: { textAlign: align } },
|
|
87
|
+
postAuthor: { attrs: { avatar: b({ default: false }) } },
|
|
88
|
+
postTerms: { attrs: { taxonomy: s({ default: "category" }), separator: s({ default: ", " }) } },
|
|
89
|
+
comments: {},
|
|
90
|
+
// 1.15: khối theme cho SEO / điều hướng — H1 của trang lưu trữ, mô tả chuyên mục, breadcrumb, bài trước / sau
|
|
91
|
+
queryTitle: { attrs: { level: i({ min: 1, max: 6, default: 1 }), prefix: b({ default: false }), textAlign: align } },
|
|
92
|
+
termDescription: { attrs: { textAlign: align } },
|
|
93
|
+
breadcrumbs: { attrs: { separator: s({ default: "›" }), showHome: b({ default: true }), showCurrent: b({ default: true }) } },
|
|
94
|
+
postNavigation: { attrs: { showTitle: b({ default: true }) } },
|
|
95
|
+
query: {
|
|
96
|
+
attrs: {
|
|
97
|
+
inherit: b({ default: true }), postType: s({ default: "post" }), perPage: i({ min: 1, max: 50, default: 10 }),
|
|
98
|
+
category: s(), tag: s(), orderBy: s({ enum: ["date", "title", "menu_order"], default: "date" }), order: s({ enum: ["desc", "asc"], default: "desc" }),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
postTemplate: { attrs: { layout: s({ enum: ["list", "grid"], default: "list" }), columns: i({ min: 1, max: 6, default: 3 }) } },
|
|
102
|
+
pagination: {},
|
|
103
|
+
queryEmpty: {},
|
|
104
|
+
templatePart: { attrs: { slug: s(), tagName: s({ enum: ["div", "header", "footer", "aside", "section"], default: "div" }) } },
|
|
105
|
+
/** Mẫu đồng bộ: tham chiếu tới mẫu (id) — sửa mẫu gốc thì mọi chỗ dùng đổi theo. */
|
|
106
|
+
syncedPattern: { attrs: { ref: i({ min: 1 }) } },
|
|
107
|
+
};
|
|
108
|
+
/** Khối động: nội dung lấy theo ngữ cảnh lúc hiển thị (website, trang, bài trong vòng lặp). */
|
|
109
|
+
export const DYNAMIC_NODES = [
|
|
110
|
+
"siteTitle", "siteLogo", "siteTagline", "navigation", "search", "languageSwitcher", "copyright",
|
|
111
|
+
"postTitle", "postContent", "postExcerpt", "featuredImage", "postDate", "postAuthor", "postTerms", "comments",
|
|
112
|
+
"queryTitle", "termDescription", "breadcrumbs", "postNavigation",
|
|
113
|
+
"query", "postTemplate", "pagination", "queryEmpty", "templatePart", "syncedPattern",
|
|
114
|
+
];
|
|
115
|
+
export const CORE_MARKS = {
|
|
116
|
+
bold: {}, italic: {}, underline: {}, strike: {}, code: {}, subscript: {}, superscript: {},
|
|
117
|
+
highlight: { attrs: { color: s() } },
|
|
118
|
+
textStyle: { attrs: { color: s() } },
|
|
119
|
+
link: { attrs: { href: url(), target: s(), rel: s() } },
|
|
120
|
+
};
|
|
121
|
+
/** Ép giá trị theo mô tả. Trả về undefined = bỏ thuộc tính (dùng mặc định). */
|
|
122
|
+
export function coerceAttr(spec, value) {
|
|
123
|
+
if (value === null || value === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
let v = value;
|
|
126
|
+
switch (spec.type) {
|
|
127
|
+
case "string":
|
|
128
|
+
case "url":
|
|
129
|
+
if (typeof v === "number" || typeof v === "boolean")
|
|
130
|
+
v = String(v);
|
|
131
|
+
if (typeof v !== "string")
|
|
132
|
+
return undefined;
|
|
133
|
+
break;
|
|
134
|
+
case "number":
|
|
135
|
+
case "integer": {
|
|
136
|
+
const num = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
|
|
137
|
+
if (!Number.isFinite(num))
|
|
138
|
+
return undefined;
|
|
139
|
+
let x = spec.type === "integer" ? Math.round(num) : num;
|
|
140
|
+
if (spec.min !== undefined)
|
|
141
|
+
x = Math.max(spec.min, x);
|
|
142
|
+
if (spec.max !== undefined)
|
|
143
|
+
x = Math.min(spec.max, x);
|
|
144
|
+
v = x;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "boolean":
|
|
148
|
+
if (v === "true")
|
|
149
|
+
v = true;
|
|
150
|
+
else if (v === "false")
|
|
151
|
+
v = false;
|
|
152
|
+
if (typeof v !== "boolean")
|
|
153
|
+
return undefined;
|
|
154
|
+
break;
|
|
155
|
+
case "json":
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
if (spec.enum && !spec.enum.includes(v))
|
|
159
|
+
return undefined;
|
|
160
|
+
return v;
|
|
161
|
+
}
|
|
162
|
+
/** Chuẩn hoá bộ thuộc tính: ép kiểu thuộc tính có mô tả, giữ nguyên thuộc tính khác, bỏ null. Rỗng → undefined. */
|
|
163
|
+
export function normalizeAttrs(attrs, spec) {
|
|
164
|
+
if (!attrs || typeof attrs !== "object" || Array.isArray(attrs))
|
|
165
|
+
return undefined;
|
|
166
|
+
const out = {};
|
|
167
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
168
|
+
const v = spec?.[key] ? coerceAttr(spec[key], value) : value === null ? undefined : value;
|
|
169
|
+
if (v !== undefined)
|
|
170
|
+
out[key] = v;
|
|
171
|
+
}
|
|
172
|
+
return Object.keys(out).length ? out : undefined;
|
|
173
|
+
}
|
package/dist/style.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kiểu dáng của khối (`attrs.style`, mọi khối): khoảng cách, cỡ chữ, màu, viền, ẩn theo khổ màn hình.
|
|
3
|
+
* Giá trị theo khổ: `{ base, tablet, mobile }` (tablet ≤ 1024px, mobile ≤ 640px). Giá trị là preset của theme
|
|
4
|
+
* (`lg`, `primary` → biến CSS `--ecx-spacing-lg`, `--ecx-color-primary`) hoặc giá trị CSS đã kiểm (độ dài, màu).
|
|
5
|
+
* Chỉ nhận giá trị trong danh sách → không chèn được CSS tuỳ ý. Máy chủ chạy cùng quy tắc (ContentFormat.cs).
|
|
6
|
+
*/
|
|
7
|
+
export declare const BREAKPOINTS: {
|
|
8
|
+
readonly tablet: 1024;
|
|
9
|
+
readonly mobile: 640;
|
|
10
|
+
};
|
|
11
|
+
export declare const DEVICES: readonly ["base", "tablet", "mobile"];
|
|
12
|
+
export type Device = (typeof DEVICES)[number];
|
|
13
|
+
export type Responsive<T> = Partial<Record<Device, T>>;
|
|
14
|
+
export type Box = string | Partial<Record<"top" | "right" | "bottom" | "left", string>>;
|
|
15
|
+
export interface BlockStyle {
|
|
16
|
+
padding?: Responsive<Box>;
|
|
17
|
+
margin?: Responsive<Box>;
|
|
18
|
+
gap?: Responsive<string>;
|
|
19
|
+
width?: Responsive<string>;
|
|
20
|
+
maxWidth?: Responsive<string>;
|
|
21
|
+
minHeight?: Responsive<string>;
|
|
22
|
+
fontFamily?: string;
|
|
23
|
+
fontSize?: Responsive<string>;
|
|
24
|
+
lineHeight?: Responsive<number>;
|
|
25
|
+
letterSpacing?: Responsive<string>;
|
|
26
|
+
fontWeight?: string;
|
|
27
|
+
fontStyle?: string;
|
|
28
|
+
textDecoration?: string;
|
|
29
|
+
textTransform?: string;
|
|
30
|
+
textAlign?: Responsive<string>;
|
|
31
|
+
color?: {
|
|
32
|
+
text?: string;
|
|
33
|
+
background?: string;
|
|
34
|
+
};
|
|
35
|
+
border?: {
|
|
36
|
+
width?: string;
|
|
37
|
+
style?: string;
|
|
38
|
+
color?: string;
|
|
39
|
+
};
|
|
40
|
+
radius?: string;
|
|
41
|
+
hide?: Array<"desktop" | "tablet" | "mobile">;
|
|
42
|
+
/** Bố cục khối chứa (1.15): xếp ngang/dọc (flex) hoặc lưới — khối con xếp theo đây. */
|
|
43
|
+
layout?: BlockLayout;
|
|
44
|
+
/** Nền chuyển màu (1.15): preset của theme (`brand` → --ecx-gradient-brand) hoặc linear/radial-gradient đã kiểm. */
|
|
45
|
+
gradient?: string;
|
|
46
|
+
/** Đổ bóng (1.15): preset (`sm` `md` `lg` `xl` có sẵn, theme thêm) hoặc `none`. */
|
|
47
|
+
shadow?: string;
|
|
48
|
+
/** Tỉ lệ khung (1.15). */
|
|
49
|
+
aspectRatio?: string;
|
|
50
|
+
/** Ghim khi cuộn (1.15): `{ type: "sticky", top }`. */
|
|
51
|
+
position?: {
|
|
52
|
+
type: "sticky";
|
|
53
|
+
top?: string;
|
|
54
|
+
};
|
|
55
|
+
/** Khi rê chuột (1.15): màu chữ / nền / viền, bóng, nhấc lên. */
|
|
56
|
+
hover?: {
|
|
57
|
+
color?: {
|
|
58
|
+
text?: string;
|
|
59
|
+
background?: string;
|
|
60
|
+
};
|
|
61
|
+
border?: {
|
|
62
|
+
color?: string;
|
|
63
|
+
};
|
|
64
|
+
shadow?: string;
|
|
65
|
+
lift?: boolean;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Bố cục của khối chứa. Không có `type` = luồng thường (khối con xếp dọc như văn bản). */
|
|
69
|
+
export interface BlockLayout {
|
|
70
|
+
type: "flex" | "grid";
|
|
71
|
+
/** flex: hàng ngang (row) hoặc xếp dọc (column). */
|
|
72
|
+
direction?: Responsive<"row" | "column">;
|
|
73
|
+
justify?: Responsive<"start" | "center" | "end" | "between">;
|
|
74
|
+
align?: Responsive<"start" | "center" | "end" | "stretch">;
|
|
75
|
+
/** flex: cho xuống dòng khi hết chỗ (mặc định có). */
|
|
76
|
+
wrap?: boolean;
|
|
77
|
+
/** grid: số cột (1–12) theo khổ. */
|
|
78
|
+
columns?: Responsive<number>;
|
|
79
|
+
/** grid: không đặt số cột → tự chia cột, mỗi cột tối thiểu bằng này. */
|
|
80
|
+
minColumnWidth?: string;
|
|
81
|
+
}
|
|
82
|
+
/** Gradient: preset hoặc linear/radial-gradient(hướng?, 2–6 điểm màu hex/rgb/hsl kèm %) — dạng chuẩn hoá khoảng trắng. */
|
|
83
|
+
export declare function normalizeGradient(v: unknown): string | undefined;
|
|
84
|
+
/** Chuẩn hoá `attrs.style`: giữ đúng thứ tự khoá, bỏ giá trị không hợp lệ. Rỗng → undefined. */
|
|
85
|
+
export declare function normalizeStyle(v: unknown): BlockStyle | undefined;
|
|
86
|
+
/** Độ dài / preset khoảng cách hợp lệ → giá trị CSS (preset thành biến); không hợp lệ → undefined. Dùng cho thuộc tính khối như chiều cao khoảng trống. */
|
|
87
|
+
export declare function spacingValue(v: unknown): string | undefined;
|
|
88
|
+
/** Màu hợp lệ (hex/rgb/hsl hoặc preset) → giá trị CSS; không hợp lệ → undefined. */
|
|
89
|
+
export declare function colorCss(v: unknown): string | undefined;
|
|
90
|
+
/** Tên class ổn định theo nội dung style (cùng kiểu dáng → cùng class). */
|
|
91
|
+
export declare function styleClass(style: BlockStyle): string;
|
|
92
|
+
/** CSS của một kiểu dáng: `.cls.cls{…}` + media query cho tablet / mobile + ẩn theo khổ + :hover. Rỗng → "". */
|
|
93
|
+
export declare function styleCss(style: BlockStyle, cls?: string): string;
|
|
94
|
+
/**
|
|
95
|
+
* CSS của một kiểu dáng cho bộ chọn bất kỳ (danh sách cách nhau dấu phẩy) — dùng cho kiểu của từng khối và Global Styles
|
|
96
|
+
* (kiểu mặc định theo loại khối / phần tử). Bộ chọn do mã gọi đưa vào, không lấy từ dữ liệu người dùng.
|
|
97
|
+
*/
|
|
98
|
+
export declare function ruleCss(selector: string, style: BlockStyle): string;
|
|
99
|
+
/** Gom CSS cho cả một cây nội dung: trả về css (mỗi class một lần) — website và trình soạn dùng chung. */
|
|
100
|
+
export declare function collectStyles(styles: Iterable<BlockStyle>): string;
|
|
101
|
+
/** Biến thể kiểu (`blockStyle`): tên preset → class `ecx-is-<tên>`. */
|
|
102
|
+
export declare function normalizeBlockStyleName(v: unknown): string | undefined;
|
|
103
|
+
/** Neo (`anchor`) → id của phần tử. */
|
|
104
|
+
export declare function normalizeAnchor(v: unknown): string | undefined;
|
|
105
|
+
/** Class CSS riêng (`className`): tối đa 5 class hợp lệ, cách nhau bởi dấu cách. */
|
|
106
|
+
export declare function normalizeClassName(v: unknown): string | undefined;
|
|
107
|
+
/** Biến thể có sẵn của lõi (CSS trong style.css); theme thêm bằng theme.json › blockStyles. */
|
|
108
|
+
export declare const CORE_BLOCK_STYLES: Record<string, string[]>;
|
|
109
|
+
export interface StylePresets {
|
|
110
|
+
colors?: Record<string, string>;
|
|
111
|
+
fontSizes?: Record<string, string>;
|
|
112
|
+
spacing?: Record<string, string>;
|
|
113
|
+
fonts?: Record<string, string>;
|
|
114
|
+
/** 1.15: nền chuyển màu (linear/radial-gradient đã kiểm) → --ecx-gradient-*. */
|
|
115
|
+
gradients?: Record<string, string>;
|
|
116
|
+
/** 1.15: đổ bóng (tối đa 3 lớp "x y blur spread? màu") → --ecx-shadow-*. */
|
|
117
|
+
shadows?: Record<string, string>;
|
|
118
|
+
/** 1.15 Global Styles: kiểu mặc định theo loại khối (paragraph, heading, button, group…). */
|
|
119
|
+
blocks?: Record<string, BlockStyle>;
|
|
120
|
+
/** 1.15 Global Styles: kiểu mặc định theo phần tử (link, button, heading, h1–h6, caption). */
|
|
121
|
+
elements?: Record<string, BlockStyle>;
|
|
122
|
+
}
|
|
123
|
+
/** Bộ chọn của khối lõi trên website (markup của renderer) — Global Styles theo loại khối. */
|
|
124
|
+
export declare const BLOCK_SELECTORS: Record<string, string>;
|
|
125
|
+
/** Bộ chọn của phần tử — Global Styles theo phần tử. */
|
|
126
|
+
export declare const ELEMENT_SELECTORS: Record<string, string>;
|
|
127
|
+
/** Chuẩn hoá preset: tên preset hợp lệ, giá trị đã kiểm (màu hex/rgb/hsl, độ dài hoặc clamp(), danh sách phông không có ; { }). */
|
|
128
|
+
export declare function normalizePresets(v: unknown): StylePresets;
|
|
129
|
+
/** Gộp preset theo thứ tự (sau đè trước): theme → bản sửa của quản trị viên. */
|
|
130
|
+
export declare function mergePresets(...list: unknown[]): StylePresets;
|
|
131
|
+
/** Biến CSS của preset: `--ecx-color-*`, `--ecx-font-size-*`, `--ecx-spacing-*`, `--ecx-font-family-*`. */
|
|
132
|
+
export declare function presetCss(presets: unknown, selector?: string): string;
|