@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
package/dist/style.js
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
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 const BREAKPOINTS = { tablet: 1024, mobile: 640 };
|
|
8
|
+
export const DEVICES = ["base", "tablet", "mobile"];
|
|
9
|
+
const SIDES = ["top", "right", "bottom", "left"];
|
|
10
|
+
const LENGTH = /^(0|-?\d{1,4}(\.\d{1,3})?(px|rem|em|%|vw|vh))$/;
|
|
11
|
+
const PRESET = /^([a-z][a-z0-9-]{0,30}|[1-9]x[sl])$/;
|
|
12
|
+
const COLOR = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([\d\s.,%/]{1,40}\))$/i;
|
|
13
|
+
const WEIGHTS = ["300", "400", "500", "600", "700", "800", "900"];
|
|
14
|
+
const ALIGNS = ["left", "center", "right", "justify"];
|
|
15
|
+
const BORDER_STYLES = ["solid", "dashed", "dotted"];
|
|
16
|
+
const HIDE = ["desktop", "tablet", "mobile"];
|
|
17
|
+
const FONT_STYLES = ["normal", "italic"];
|
|
18
|
+
const DECORATIONS = ["none", "underline", "line-through"];
|
|
19
|
+
const TRANSFORMS = ["none", "uppercase", "lowercase", "capitalize"];
|
|
20
|
+
const preset = (v) => (typeof v === "string" && PRESET.test(v) ? v : undefined);
|
|
21
|
+
const DIRECTIONS = ["row", "column"];
|
|
22
|
+
const JUSTIFY = ["start", "center", "end", "between"];
|
|
23
|
+
const ALIGN_ITEMS = ["start", "center", "end", "stretch"];
|
|
24
|
+
const RATIOS = ["1/1", "4/3", "3/2", "16/9", "21/9", "3/4", "2/3", "9/16"];
|
|
25
|
+
const columns = (v) => {
|
|
26
|
+
const n = typeof v === "number" ? v : typeof v === "string" && /^\d{1,2}$/.test(v) ? Number(v) : NaN;
|
|
27
|
+
return Number.isInteger(n) && n >= 1 && n <= 12 ? n : undefined;
|
|
28
|
+
};
|
|
29
|
+
/** Tách tham số CSS theo dấu phẩy ngoài ngoặc: "90deg, rgb(1,2,3) 0%, #fff" → 3 phần. */
|
|
30
|
+
function splitArgs(s) {
|
|
31
|
+
const out = [];
|
|
32
|
+
let depth = 0, cur = "";
|
|
33
|
+
for (const ch of s) {
|
|
34
|
+
if (ch === "(")
|
|
35
|
+
depth++;
|
|
36
|
+
if (ch === ")")
|
|
37
|
+
depth--;
|
|
38
|
+
if (ch === "," && depth === 0) {
|
|
39
|
+
out.push(cur.trim());
|
|
40
|
+
cur = "";
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
cur += ch;
|
|
44
|
+
if (depth < 0)
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
out.push(cur.trim());
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
const STOP = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([\d\s.,%/]{1,40}\))(\s+\d{1,3}%)?$/i;
|
|
51
|
+
const LINEAR_DIR = /^(\d{1,3}deg|to (top|bottom|left|right)( (top|bottom|left|right))?)$/;
|
|
52
|
+
const RADIAL_SHAPE = /^(circle|ellipse)( at center)?$/;
|
|
53
|
+
/** 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. */
|
|
54
|
+
export function normalizeGradient(v) {
|
|
55
|
+
if (typeof v !== "string")
|
|
56
|
+
return undefined;
|
|
57
|
+
const t = v.trim();
|
|
58
|
+
if (PRESET.test(t))
|
|
59
|
+
return t;
|
|
60
|
+
const m = /^(linear|radial)-gradient\((.{1,400})\)$/i.exec(t);
|
|
61
|
+
if (!m)
|
|
62
|
+
return undefined;
|
|
63
|
+
const kind = m[1].toLowerCase();
|
|
64
|
+
const args = splitArgs(m[2]).map((a) => a.replace(/\s+/g, " "));
|
|
65
|
+
if (!args.length || args.some((a) => !a))
|
|
66
|
+
return undefined;
|
|
67
|
+
const head = kind === "linear" ? LINEAR_DIR.test(args[0]) : RADIAL_SHAPE.test(args[0]);
|
|
68
|
+
const stops = head ? args.slice(1) : args;
|
|
69
|
+
if (stops.length < 2 || stops.length > 6 || !stops.every((x) => STOP.test(x)))
|
|
70
|
+
return undefined;
|
|
71
|
+
return `${kind}-gradient(${args.join(", ")})`;
|
|
72
|
+
}
|
|
73
|
+
/** Bóng: preset hoặc none (giá trị tuỳ ý chỉ khai báo được ở preset của theme). */
|
|
74
|
+
const shadow = (v) => (v === "none" ? "none" : preset(v));
|
|
75
|
+
function layout(v) {
|
|
76
|
+
if (!isObj(v) || (v.type !== "flex" && v.type !== "grid"))
|
|
77
|
+
return undefined;
|
|
78
|
+
const out = { type: v.type };
|
|
79
|
+
if (v.type === "flex") {
|
|
80
|
+
const d = responsive(v.direction, oneOf(DIRECTIONS));
|
|
81
|
+
if (d)
|
|
82
|
+
out.direction = d;
|
|
83
|
+
}
|
|
84
|
+
const j = responsive(v.justify, oneOf(JUSTIFY));
|
|
85
|
+
if (j)
|
|
86
|
+
out.justify = j;
|
|
87
|
+
const a = responsive(v.align, oneOf(ALIGN_ITEMS));
|
|
88
|
+
if (a)
|
|
89
|
+
out.align = a;
|
|
90
|
+
if (v.type === "flex" && typeof v.wrap === "boolean")
|
|
91
|
+
out.wrap = v.wrap;
|
|
92
|
+
if (v.type === "grid") {
|
|
93
|
+
const c = responsive(v.columns, columns);
|
|
94
|
+
if (c)
|
|
95
|
+
out.columns = c;
|
|
96
|
+
const w = size(v.minColumnWidth);
|
|
97
|
+
if (w)
|
|
98
|
+
out.minColumnWidth = w;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
function hover(v) {
|
|
103
|
+
if (!isObj(v))
|
|
104
|
+
return undefined;
|
|
105
|
+
const out = {};
|
|
106
|
+
const c = group(v.color, { text: color, background: color });
|
|
107
|
+
if (c)
|
|
108
|
+
out.color = c;
|
|
109
|
+
const b = group(v.border, { color });
|
|
110
|
+
if (b)
|
|
111
|
+
out.border = b;
|
|
112
|
+
const sh = shadow(v.shadow);
|
|
113
|
+
if (sh)
|
|
114
|
+
out.shadow = sh;
|
|
115
|
+
if (v.lift === true)
|
|
116
|
+
out.lift = true;
|
|
117
|
+
return Object.keys(out).length ? out : undefined;
|
|
118
|
+
}
|
|
119
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
120
|
+
const size = (v) => (typeof v === "string" && (LENGTH.test(v) || PRESET.test(v)) ? v : undefined);
|
|
121
|
+
const color = (v) => (typeof v === "string" && (COLOR.test(v.trim()) || PRESET.test(v)) ? v.trim() : undefined);
|
|
122
|
+
const oneOf = (list) => (v) => (typeof v === "string" && list.includes(v) ? v : undefined);
|
|
123
|
+
const lineHeight = (v) => {
|
|
124
|
+
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
|
|
125
|
+
return Number.isFinite(n) && n >= 0.5 && n <= 4 ? Math.round(n * 100) / 100 : undefined;
|
|
126
|
+
};
|
|
127
|
+
function box(v) {
|
|
128
|
+
if (typeof v === "string")
|
|
129
|
+
return size(v);
|
|
130
|
+
if (!isObj(v))
|
|
131
|
+
return undefined;
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const s of SIDES) {
|
|
134
|
+
const x = size(v[s]);
|
|
135
|
+
if (x !== undefined)
|
|
136
|
+
out[s] = x;
|
|
137
|
+
}
|
|
138
|
+
return Object.keys(out).length ? out : undefined;
|
|
139
|
+
}
|
|
140
|
+
/** Giá trị theo khổ: nhận cả dạng gọn (một giá trị = base). */
|
|
141
|
+
function responsive(v, fn) {
|
|
142
|
+
const src = isObj(v) && DEVICES.some((d) => d in v) ? v : { base: v };
|
|
143
|
+
const out = {};
|
|
144
|
+
for (const d of DEVICES) {
|
|
145
|
+
const x = fn(src[d]);
|
|
146
|
+
if (x !== undefined)
|
|
147
|
+
out[d] = x;
|
|
148
|
+
}
|
|
149
|
+
return Object.keys(out).length ? out : undefined;
|
|
150
|
+
}
|
|
151
|
+
function group(v, fields) {
|
|
152
|
+
if (!isObj(v))
|
|
153
|
+
return undefined;
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const k of Object.keys(fields)) {
|
|
156
|
+
const x = fields[k](v[k]);
|
|
157
|
+
if (x !== undefined)
|
|
158
|
+
out[k] = x;
|
|
159
|
+
}
|
|
160
|
+
return Object.keys(out).length ? out : undefined;
|
|
161
|
+
}
|
|
162
|
+
/** Chuẩn hoá `attrs.style`: giữ đúng thứ tự khoá, bỏ giá trị không hợp lệ. Rỗng → undefined. */
|
|
163
|
+
export function normalizeStyle(v) {
|
|
164
|
+
if (!isObj(v))
|
|
165
|
+
return undefined;
|
|
166
|
+
const out = {};
|
|
167
|
+
const set = (k, x) => { if (x !== undefined)
|
|
168
|
+
out[k] = x; };
|
|
169
|
+
set("padding", responsive(v.padding, box));
|
|
170
|
+
set("margin", responsive(v.margin, box));
|
|
171
|
+
set("gap", responsive(v.gap, size));
|
|
172
|
+
set("width", responsive(v.width, size));
|
|
173
|
+
set("maxWidth", responsive(v.maxWidth, size));
|
|
174
|
+
set("minHeight", responsive(v.minHeight, size));
|
|
175
|
+
set("fontFamily", preset(v.fontFamily));
|
|
176
|
+
set("fontSize", responsive(v.fontSize, size));
|
|
177
|
+
set("lineHeight", responsive(v.lineHeight, lineHeight));
|
|
178
|
+
set("letterSpacing", responsive(v.letterSpacing, size));
|
|
179
|
+
set("fontWeight", oneOf(WEIGHTS)(typeof v.fontWeight === "number" ? String(v.fontWeight) : v.fontWeight));
|
|
180
|
+
set("fontStyle", oneOf(FONT_STYLES)(v.fontStyle));
|
|
181
|
+
set("textDecoration", oneOf(DECORATIONS)(v.textDecoration));
|
|
182
|
+
set("textTransform", oneOf(TRANSFORMS)(v.textTransform));
|
|
183
|
+
set("textAlign", responsive(v.textAlign, oneOf(ALIGNS)));
|
|
184
|
+
set("color", group(v.color, { text: color, background: color }));
|
|
185
|
+
set("border", group(v.border, { width: size, style: oneOf(BORDER_STYLES), color }));
|
|
186
|
+
set("radius", size(v.radius));
|
|
187
|
+
if (Array.isArray(v.hide)) {
|
|
188
|
+
const h = HIDE.filter((d) => v.hide.includes(d));
|
|
189
|
+
if (h.length)
|
|
190
|
+
out.hide = [...h];
|
|
191
|
+
}
|
|
192
|
+
set("layout", layout(v.layout));
|
|
193
|
+
set("gradient", normalizeGradient(v.gradient));
|
|
194
|
+
set("shadow", shadow(v.shadow));
|
|
195
|
+
set("aspectRatio", oneOf(RATIOS)(v.aspectRatio));
|
|
196
|
+
if (isObj(v.position) && v.position.type === "sticky") {
|
|
197
|
+
const top = size(v.position.top);
|
|
198
|
+
set("position", top ? { type: "sticky", top } : { type: "sticky" });
|
|
199
|
+
}
|
|
200
|
+
set("hover", hover(v.hover));
|
|
201
|
+
return Object.keys(out).length ? out : undefined;
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// CSS
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
const lit = (v) => v === "auto" || v === "0" || LENGTH.test(v);
|
|
207
|
+
const spacing = (v) => (lit(v) ? v : `var(--ecx-spacing-${v})`);
|
|
208
|
+
const fontSize = (v) => (lit(v) ? v : `var(--ecx-font-size-${v})`);
|
|
209
|
+
const colorValue = (v) => (COLOR.test(v) ? v : `var(--ecx-color-${v}, ${v})`);
|
|
210
|
+
function boxDecl(prop, v) {
|
|
211
|
+
if (typeof v === "string")
|
|
212
|
+
return [`${prop}:${spacing(v)}`];
|
|
213
|
+
return SIDES.filter((s) => v[s] !== undefined).map((s) => `${prop}-${s}:${spacing(v[s])}`);
|
|
214
|
+
}
|
|
215
|
+
/** Khai báo CSS cho từng khổ màn hình. */
|
|
216
|
+
function declarations(style) {
|
|
217
|
+
const out = { base: [], tablet: [], mobile: [] };
|
|
218
|
+
for (const d of DEVICES) {
|
|
219
|
+
const p = style.padding?.[d];
|
|
220
|
+
if (p !== undefined)
|
|
221
|
+
out[d].push(...boxDecl("padding", p));
|
|
222
|
+
const m = style.margin?.[d];
|
|
223
|
+
if (m !== undefined)
|
|
224
|
+
out[d].push(...boxDecl("margin", m));
|
|
225
|
+
const g = style.gap?.[d];
|
|
226
|
+
if (g !== undefined)
|
|
227
|
+
out[d].push(`gap:${spacing(g)}`);
|
|
228
|
+
const w = style.width?.[d];
|
|
229
|
+
if (w !== undefined)
|
|
230
|
+
out[d].push(`width:${spacing(w)}`);
|
|
231
|
+
const mw = style.maxWidth?.[d];
|
|
232
|
+
if (mw !== undefined)
|
|
233
|
+
out[d].push(`max-width:${spacing(mw)}`);
|
|
234
|
+
const mh = style.minHeight?.[d];
|
|
235
|
+
if (mh !== undefined)
|
|
236
|
+
out[d].push(`min-height:${spacing(mh)}`);
|
|
237
|
+
const fs = style.fontSize?.[d];
|
|
238
|
+
if (fs !== undefined)
|
|
239
|
+
out[d].push(`font-size:${fontSize(fs)}`);
|
|
240
|
+
const lh = style.lineHeight?.[d];
|
|
241
|
+
if (lh !== undefined)
|
|
242
|
+
out[d].push(`line-height:${lh}`);
|
|
243
|
+
const ls = style.letterSpacing?.[d];
|
|
244
|
+
if (ls !== undefined)
|
|
245
|
+
out[d].push(`letter-spacing:${lit(ls) ? ls : `var(--ecx-letter-spacing-${ls})`}`);
|
|
246
|
+
const ta = style.textAlign?.[d];
|
|
247
|
+
if (ta !== undefined)
|
|
248
|
+
out[d].push(`text-align:${ta}`);
|
|
249
|
+
}
|
|
250
|
+
if (style.fontFamily)
|
|
251
|
+
out.base.push(`font-family:var(--ecx-font-family-${style.fontFamily}, inherit)`);
|
|
252
|
+
if (style.fontWeight)
|
|
253
|
+
out.base.push(`font-weight:${style.fontWeight}`);
|
|
254
|
+
if (style.fontStyle)
|
|
255
|
+
out.base.push(`font-style:${style.fontStyle}`);
|
|
256
|
+
if (style.textDecoration)
|
|
257
|
+
out.base.push(`text-decoration:${style.textDecoration}`);
|
|
258
|
+
if (style.textTransform)
|
|
259
|
+
out.base.push(`text-transform:${style.textTransform}`);
|
|
260
|
+
if (style.color?.text)
|
|
261
|
+
out.base.push(`color:${colorValue(style.color.text)}`);
|
|
262
|
+
if (style.color?.background)
|
|
263
|
+
out.base.push(`background:${colorValue(style.color.background)}`);
|
|
264
|
+
if (style.border?.width || style.border?.style || style.border?.color)
|
|
265
|
+
out.base.push(`border:${style.border.width ? spacing(style.border.width) : "1px"} ${style.border.style ?? "solid"} ${style.border.color ? colorValue(style.border.color) : "currentColor"}`);
|
|
266
|
+
if (style.radius)
|
|
267
|
+
out.base.push(`border-radius:${spacing(style.radius)}`);
|
|
268
|
+
// 1.15: bố cục, nền chuyển màu, bóng, tỉ lệ, ghim
|
|
269
|
+
const l = style.layout;
|
|
270
|
+
if (l?.type === "flex") {
|
|
271
|
+
out.base.push("display:flex", `flex-wrap:${l.wrap === false ? "nowrap" : "wrap"}`);
|
|
272
|
+
if (!l.direction?.base)
|
|
273
|
+
out.base.push("flex-direction:row");
|
|
274
|
+
if (style.gap === undefined)
|
|
275
|
+
out.base.push("gap:var(--ecx-block-gap, 1rem)");
|
|
276
|
+
}
|
|
277
|
+
else if (l?.type === "grid") {
|
|
278
|
+
out.base.push("display:grid");
|
|
279
|
+
if (!l.columns?.base)
|
|
280
|
+
out.base.push(`grid-template-columns:repeat(auto-fill,minmax(min(${l.minColumnWidth ? spacing(l.minColumnWidth) : "12rem"},100%),1fr))`);
|
|
281
|
+
if (style.gap === undefined)
|
|
282
|
+
out.base.push("gap:var(--ecx-block-gap, 1rem)");
|
|
283
|
+
}
|
|
284
|
+
for (const d of DEVICES) {
|
|
285
|
+
const dir = l?.direction?.[d];
|
|
286
|
+
if (dir)
|
|
287
|
+
out[d].push(`flex-direction:${dir}`);
|
|
288
|
+
const j = l?.justify?.[d];
|
|
289
|
+
if (j)
|
|
290
|
+
out[d].push(`justify-content:${JUSTIFY_CSS[j]}`);
|
|
291
|
+
const a = l?.align?.[d];
|
|
292
|
+
if (a)
|
|
293
|
+
out[d].push(`align-items:${ALIGN_CSS[a]}`);
|
|
294
|
+
const c = l?.columns?.[d];
|
|
295
|
+
if (c)
|
|
296
|
+
out[d].push(`grid-template-columns:repeat(${c},minmax(0,1fr))`);
|
|
297
|
+
}
|
|
298
|
+
if (style.gradient)
|
|
299
|
+
out.base.push(`background:${gradientValue(style.gradient)}`);
|
|
300
|
+
if (style.shadow)
|
|
301
|
+
out.base.push(`box-shadow:${shadowValue(style.shadow)}`);
|
|
302
|
+
if (style.aspectRatio)
|
|
303
|
+
out.base.push(`aspect-ratio:${style.aspectRatio}`, "object-fit:cover");
|
|
304
|
+
if (style.position)
|
|
305
|
+
out.base.push("position:sticky", `top:${style.position.top ? spacing(style.position.top) : "0"}`, "z-index:10");
|
|
306
|
+
if (style.hover)
|
|
307
|
+
out.base.push("transition:color .2s ease,background .2s ease,border-color .2s ease,box-shadow .2s ease,transform .2s ease");
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
const JUSTIFY_CSS = { start: "flex-start", center: "center", end: "flex-end", between: "space-between" };
|
|
311
|
+
const ALIGN_CSS = { start: "flex-start", center: "center", end: "flex-end", stretch: "stretch" };
|
|
312
|
+
/** Bóng có sẵn (theme ghi đè bằng preset cùng tên). */
|
|
313
|
+
const SHADOWS = {
|
|
314
|
+
sm: "0 1px 2px rgb(0 0 0 / .08)", md: "0 4px 12px rgb(0 0 0 / .1)", lg: "0 12px 32px rgb(0 0 0 / .14)", xl: "0 24px 64px rgb(0 0 0 / .18)",
|
|
315
|
+
};
|
|
316
|
+
const shadowValue = (v) => (v === "none" ? "none" : `var(--ecx-shadow-${v}${SHADOWS[v] ? `, ${SHADOWS[v]}` : ""})`);
|
|
317
|
+
const gradientValue = (v) => (PRESET.test(v) ? `var(--ecx-gradient-${v})` : v);
|
|
318
|
+
/** Khai báo khi rê chuột. */
|
|
319
|
+
function hoverDeclarations(h) {
|
|
320
|
+
const out = [];
|
|
321
|
+
if (h.color?.text)
|
|
322
|
+
out.push(`color:${colorValue(h.color.text)}`);
|
|
323
|
+
if (h.color?.background)
|
|
324
|
+
out.push(`background:${colorValue(h.color.background)}`);
|
|
325
|
+
if (h.border?.color)
|
|
326
|
+
out.push(`border-color:${colorValue(h.border.color)}`);
|
|
327
|
+
if (h.shadow)
|
|
328
|
+
out.push(`box-shadow:${shadowValue(h.shadow)}`);
|
|
329
|
+
if (h.lift)
|
|
330
|
+
out.push("transform:translateY(-2px)");
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
/** Độ 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. */
|
|
334
|
+
export function spacingValue(v) {
|
|
335
|
+
const x = size(v);
|
|
336
|
+
return x === undefined ? undefined : spacing(x);
|
|
337
|
+
}
|
|
338
|
+
/** Màu hợp lệ (hex/rgb/hsl hoặc preset) → giá trị CSS; không hợp lệ → undefined. */
|
|
339
|
+
export function colorCss(v) {
|
|
340
|
+
const x = color(v);
|
|
341
|
+
return x === undefined ? undefined : colorValue(x);
|
|
342
|
+
}
|
|
343
|
+
/** Tên class ổn định theo nội dung style (cùng kiểu dáng → cùng class). */
|
|
344
|
+
export function styleClass(style) {
|
|
345
|
+
let h = 0x811c9dc5;
|
|
346
|
+
const s = JSON.stringify(style);
|
|
347
|
+
for (let i = 0; i < s.length; i++) {
|
|
348
|
+
h ^= s.charCodeAt(i);
|
|
349
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
350
|
+
}
|
|
351
|
+
return `ecx-s-${h.toString(36)}`;
|
|
352
|
+
}
|
|
353
|
+
/** CSS của một kiểu dáng: `.cls.cls{…}` + media query cho tablet / mobile + ẩn theo khổ + :hover. Rỗng → "". */
|
|
354
|
+
export function styleCss(style, cls = styleClass(style)) {
|
|
355
|
+
return ruleCss(`.${cls}.${cls}`, style);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* 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
|
|
359
|
+
* (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.
|
|
360
|
+
*/
|
|
361
|
+
export function ruleCss(selector, style) {
|
|
362
|
+
const sel = selector;
|
|
363
|
+
const each = (suffix) => sel.split(",").map((x) => `${x.trim()}${suffix}`).join(",");
|
|
364
|
+
const d = declarations(style);
|
|
365
|
+
let css = d.base.length ? `${sel}{${d.base.join(";")}}` : "";
|
|
366
|
+
if (d.tablet.length)
|
|
367
|
+
css += `@media (max-width:${BREAKPOINTS.tablet}px){${sel}{${d.tablet.join(";")}}}`;
|
|
368
|
+
if (d.mobile.length)
|
|
369
|
+
css += `@media (max-width:${BREAKPOINTS.mobile}px){${sel}{${d.mobile.join(";")}}}`;
|
|
370
|
+
for (const h of style.hide ?? []) {
|
|
371
|
+
const q = h === "desktop" ? `(min-width:${BREAKPOINTS.tablet + 1}px)` : h === "tablet" ? `(min-width:${BREAKPOINTS.mobile + 1}px) and (max-width:${BREAKPOINTS.tablet}px)` : `(max-width:${BREAKPOINTS.mobile}px)`;
|
|
372
|
+
css += `@media ${q}{${sel}{display:none!important}}`;
|
|
373
|
+
}
|
|
374
|
+
if (style.hover) {
|
|
375
|
+
const hv = hoverDeclarations(style.hover);
|
|
376
|
+
if (hv.length)
|
|
377
|
+
css += `${each(":hover")}{${hv.join(";")}}`;
|
|
378
|
+
}
|
|
379
|
+
// Hàng ngang / lưới: khoảng cách do gap lo — bỏ lề dọc của khối con (lề luồng văn bản làm lệch hàng)
|
|
380
|
+
if (style.layout)
|
|
381
|
+
css += `${each(" > *")}{margin-block:0}`;
|
|
382
|
+
return css;
|
|
383
|
+
}
|
|
384
|
+
/** 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. */
|
|
385
|
+
export function collectStyles(styles) {
|
|
386
|
+
const seen = new Map();
|
|
387
|
+
for (const s of styles) {
|
|
388
|
+
const cls = styleClass(s);
|
|
389
|
+
if (!seen.has(cls))
|
|
390
|
+
seen.set(cls, styleCss(s, cls));
|
|
391
|
+
}
|
|
392
|
+
return [...seen.values()].join("");
|
|
393
|
+
}
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Thuộc tính chung của khối (1.10): biến thể kiểu, neo, class riêng
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
const ANCHOR = /^[A-Za-z][\w-]{0,63}$/;
|
|
398
|
+
const CLASS_TOKEN = /^-?[_a-zA-Z][\w-]{0,40}$/;
|
|
399
|
+
/** Biến thể kiểu (`blockStyle`): tên preset → class `ecx-is-<tên>`. */
|
|
400
|
+
export function normalizeBlockStyleName(v) {
|
|
401
|
+
return typeof v === "string" && /^[a-z][a-z0-9-]{0,30}$/.test(v) ? v : undefined;
|
|
402
|
+
}
|
|
403
|
+
/** Neo (`anchor`) → id của phần tử. */
|
|
404
|
+
export function normalizeAnchor(v) {
|
|
405
|
+
return typeof v === "string" && ANCHOR.test(v.trim()) ? v.trim() : undefined;
|
|
406
|
+
}
|
|
407
|
+
/** Class CSS riêng (`className`): tối đa 5 class hợp lệ, cách nhau bởi dấu cách. */
|
|
408
|
+
export function normalizeClassName(v) {
|
|
409
|
+
if (typeof v !== "string")
|
|
410
|
+
return undefined;
|
|
411
|
+
const tokens = v.split(/\s+/).filter(Boolean).filter((t) => CLASS_TOKEN.test(t)).slice(0, 5);
|
|
412
|
+
return tokens.length ? [...new Set(tokens)].join(" ") : undefined;
|
|
413
|
+
}
|
|
414
|
+
/** Biến thể có sẵn của lõi (CSS trong style.css); theme thêm bằng theme.json › blockStyles. */
|
|
415
|
+
export const CORE_BLOCK_STYLES = {
|
|
416
|
+
table: ["stripes"],
|
|
417
|
+
image: ["rounded", "circle"],
|
|
418
|
+
blockquote: ["plain"],
|
|
419
|
+
horizontalRule: ["dots", "wide"],
|
|
420
|
+
button: ["pill"],
|
|
421
|
+
group: ["card"],
|
|
422
|
+
};
|
|
423
|
+
/** Bộ chọn của khối lõi trên website (markup của renderer) — Global Styles theo loại khối. */
|
|
424
|
+
export const BLOCK_SELECTORS = {
|
|
425
|
+
paragraph: "p", heading: "h1,h2,h3,h4,h5,h6", bulletList: "ul", orderedList: "ol", blockquote: "blockquote", codeBlock: "pre",
|
|
426
|
+
preformatted: ".ecx-pre", horizontalRule: "hr", image: ".ecx-image", gallery: ".ecx-gallery", table: ".ecx-table table", group: ".ecx-group",
|
|
427
|
+
columns: ".ecx-columns", column: ".ecx-column", buttons: ".ecx-buttons-group", button: ".ecx-button", spacer: ".ecx-spacer", callout: ".ecx-callout",
|
|
428
|
+
cover: ".ecx-cover", mediaText: ".ecx-media-text", pullquote: ".ecx-pullquote", details: ".ecx-details", socialLinks: ".ecx-social",
|
|
429
|
+
siteTitle: ".ecx-site-title", siteTagline: ".ecx-site-tagline", navigation: ".ecx-navigation", postTitle: ".ecx-post-title", postExcerpt: ".ecx-post-excerpt",
|
|
430
|
+
postDate: ".ecx-post-date", featuredImage: ".ecx-featured-image", query: ".ecx-query",
|
|
431
|
+
};
|
|
432
|
+
/** Bộ chọn của phần tử — Global Styles theo phần tử. */
|
|
433
|
+
export const ELEMENT_SELECTORS = {
|
|
434
|
+
link: "a", button: ".ecx-button", heading: "h1,h2,h3,h4,h5,h6", h1: "h1", h2: "h2", h3: "h3", h4: "h4", h5: "h5", h6: "h6", caption: "figcaption",
|
|
435
|
+
};
|
|
436
|
+
const FLUID = /^clamp\(\s*-?[\d.]+(px|rem|em|vw|vh|%)?\s*,\s*[\d.\s+\-*()a-z%]{1,60},\s*-?[\d.]+(px|rem|em|vw|vh|%)?\s*\)$/;
|
|
437
|
+
const FONT = /^[\w\s"',.\-]{1,200}$/;
|
|
438
|
+
const presetSize = (v) => (typeof v === "string" && (LENGTH.test(v.trim()) || FLUID.test(v.trim())) ? v.trim() : undefined);
|
|
439
|
+
const presetColor = (v) => (typeof v === "string" && COLOR.test(v.trim()) ? v.trim() : undefined);
|
|
440
|
+
const presetFont = (v) => (typeof v === "string" && FONT.test(v.trim()) ? v.trim() : undefined);
|
|
441
|
+
const presetGradient = (v) => { const g = normalizeGradient(v); return g && !PRESET.test(g) ? g : undefined; };
|
|
442
|
+
const SHADOW_LAYER = /^(inset )?(-?\d{1,3}(\.\d{1,2})?(px|rem|em)? ){2,3}(-?\d{1,3}(\.\d{1,2})?(px|rem|em)? )?(#[0-9a-f]{3,8}|(rgb|hsl)a?\([\d\s.,%/]{1,40}\))$/i;
|
|
443
|
+
const presetShadow = (v) => {
|
|
444
|
+
if (typeof v !== "string")
|
|
445
|
+
return undefined;
|
|
446
|
+
const layers = splitArgs(v.trim()).map((x) => x.replace(/\s+/g, " "));
|
|
447
|
+
return layers.length >= 1 && layers.length <= 3 && layers.every((x) => SHADOW_LAYER.test(x)) ? layers.join(", ") : undefined;
|
|
448
|
+
};
|
|
449
|
+
/** Kiểu dáng theo tên (khối / phần tử): tên trong danh sách, kiểu chuẩn hoá như attrs.style. */
|
|
450
|
+
function styleMap(v, names) {
|
|
451
|
+
if (!isObj(v))
|
|
452
|
+
return undefined;
|
|
453
|
+
const out = {};
|
|
454
|
+
for (const [k, x] of Object.entries(v)) {
|
|
455
|
+
if (!(k in names))
|
|
456
|
+
continue;
|
|
457
|
+
const st = normalizeStyle(x);
|
|
458
|
+
if (st)
|
|
459
|
+
out[k] = st;
|
|
460
|
+
}
|
|
461
|
+
return Object.keys(out).length ? out : undefined;
|
|
462
|
+
}
|
|
463
|
+
/** 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ó ; { }). */
|
|
464
|
+
export function normalizePresets(v) {
|
|
465
|
+
const out = {};
|
|
466
|
+
if (!isObj(v))
|
|
467
|
+
return out;
|
|
468
|
+
const pick = (key, fn) => {
|
|
469
|
+
const src = v[key];
|
|
470
|
+
if (!isObj(src))
|
|
471
|
+
return;
|
|
472
|
+
const r = {};
|
|
473
|
+
for (const [k, x] of Object.entries(src)) {
|
|
474
|
+
const value = isObj(x) ? x.value : x;
|
|
475
|
+
const ok = /^([a-z][a-z0-9-]{0,30}|[1-9]x[sl])$/.test(k) ? fn(value) : undefined;
|
|
476
|
+
if (ok !== undefined)
|
|
477
|
+
r[k] = ok;
|
|
478
|
+
}
|
|
479
|
+
if (Object.keys(r).length)
|
|
480
|
+
out[key] = r;
|
|
481
|
+
};
|
|
482
|
+
pick("colors", presetColor);
|
|
483
|
+
pick("fontSizes", presetSize);
|
|
484
|
+
pick("spacing", presetSize);
|
|
485
|
+
pick("fonts", presetFont);
|
|
486
|
+
pick("gradients", presetGradient);
|
|
487
|
+
pick("shadows", presetShadow);
|
|
488
|
+
const blocks = styleMap(v.blocks, BLOCK_SELECTORS);
|
|
489
|
+
if (blocks)
|
|
490
|
+
out.blocks = blocks;
|
|
491
|
+
const elements = styleMap(v.elements, ELEMENT_SELECTORS);
|
|
492
|
+
if (elements)
|
|
493
|
+
out.elements = elements;
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
/** Gộp preset theo thứ tự (sau đè trước): theme → bản sửa của quản trị viên. */
|
|
497
|
+
export function mergePresets(...list) {
|
|
498
|
+
const out = {};
|
|
499
|
+
for (const p of list.map(normalizePresets))
|
|
500
|
+
for (const k of Object.keys(p))
|
|
501
|
+
out[k] = { ...out[k], ...p[k] };
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
/** Biến CSS của preset: `--ecx-color-*`, `--ecx-font-size-*`, `--ecx-spacing-*`, `--ecx-font-family-*`. */
|
|
505
|
+
export function presetCss(presets, selector = ":root") {
|
|
506
|
+
const p = normalizePresets(presets);
|
|
507
|
+
const decl = [
|
|
508
|
+
...Object.entries(p.colors ?? {}).map(([k, v]) => `--ecx-color-${k}:${v}`),
|
|
509
|
+
...Object.entries(p.fontSizes ?? {}).map(([k, v]) => `--ecx-font-size-${k}:${v}`),
|
|
510
|
+
...Object.entries(p.spacing ?? {}).map(([k, v]) => `--ecx-spacing-${k}:${v}`),
|
|
511
|
+
...Object.entries(p.fonts ?? {}).map(([k, v]) => `--ecx-font-family-${k}:${v}`),
|
|
512
|
+
...Object.entries(p.gradients ?? {}).map(([k, v]) => `--ecx-gradient-${k}:${v}`),
|
|
513
|
+
...Object.entries(p.shadows ?? {}).map(([k, v]) => `--ecx-shadow-${k}:${v}`),
|
|
514
|
+
];
|
|
515
|
+
let css = decl.length ? `${selector}{${decl.join(";")}}` : "";
|
|
516
|
+
// Global Styles: :where() giữ độ ưu tiên thấp → kiểu riêng của từng khối (.ecx-s-…) luôn thắng
|
|
517
|
+
const scoped = (sel) => sel.split(",").map((x) => `:where(.ecx-content) ${x.trim()}`).join(",");
|
|
518
|
+
for (const [name, st] of Object.entries(p.elements ?? {}))
|
|
519
|
+
css += ruleCss(scoped(ELEMENT_SELECTORS[name]), st);
|
|
520
|
+
for (const [name, st] of Object.entries(p.blocks ?? {}))
|
|
521
|
+
css += ruleCss(scoped(BLOCK_SELECTORS[name]), st);
|
|
522
|
+
return css;
|
|
523
|
+
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ContentDoc, RichNode } from "./types.js";
|
|
2
|
+
/** Chữ thuần của một nút (không định dạng). */
|
|
3
|
+
export declare function nodeText(node: RichNode): string;
|
|
4
|
+
/** Chữ thuần của cả bài (tìm kiếm, tóm tắt, đếm từ). Khối HTML bỏ thẻ; khối plugin bỏ qua. */
|
|
5
|
+
export declare function contentText(doc: ContentDoc): string;
|
|
6
|
+
/** Số từ (tách theo khoảng trắng — đúng cho tiếng Việt và tiếng Anh). */
|
|
7
|
+
export declare const wordCount: (doc: ContentDoc) => number;
|
|
8
|
+
/** Duyệt mọi nút soạn thảo của bài (kể cả lồng nhau). Trả false để không đi vào con. */
|
|
9
|
+
export declare function visitNodes(doc: ContentDoc, visit: (node: RichNode, parent: RichNode | null) => void | false): void;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const BLOCK_BREAK = new Set(["paragraph", "heading", "listItem", "taskItem", "blockquote", "codeBlock", "tableCell", "tableHeader"]);
|
|
2
|
+
/** Chữ thuần của một nút (không định dạng). */
|
|
3
|
+
export function nodeText(node) {
|
|
4
|
+
if (node.type === "text")
|
|
5
|
+
return node.text ?? "";
|
|
6
|
+
if (node.type === "hardBreak")
|
|
7
|
+
return "\n";
|
|
8
|
+
if (node.type === "button")
|
|
9
|
+
return typeof node.attrs?.text === "string" && node.attrs.text ? node.attrs.text + "\n" : "";
|
|
10
|
+
return (node.content ?? []).map(nodeText).join("") + (BLOCK_BREAK.has(node.type) ? "\n" : "");
|
|
11
|
+
}
|
|
12
|
+
/** Chữ thuần của cả bài (tìm kiếm, tóm tắt, đếm từ). Khối HTML bỏ thẻ; khối plugin bỏ qua. */
|
|
13
|
+
export function contentText(doc) {
|
|
14
|
+
return doc.blocks.map((b) => (b.type === "richtext" && b.data ? nodeText(b.data) : b.type === "html" ? String(b.data?.html ?? "").replace(/<[^>]+>/g, " ") : ""))
|
|
15
|
+
.join("\n").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
16
|
+
}
|
|
17
|
+
/** Số từ (tách theo khoảng trắng — đúng cho tiếng Việt và tiếng Anh). */
|
|
18
|
+
export const wordCount = (doc) => contentText(doc).split(/\s+/).filter(Boolean).length;
|
|
19
|
+
/** Duyệt mọi nút soạn thảo của bài (kể cả lồng nhau). Trả false để không đi vào con. */
|
|
20
|
+
export function visitNodes(doc, visit) {
|
|
21
|
+
const walk = (node, parent) => {
|
|
22
|
+
if (visit(node, parent) === false)
|
|
23
|
+
return;
|
|
24
|
+
for (const c of node.content ?? [])
|
|
25
|
+
walk(c, node);
|
|
26
|
+
};
|
|
27
|
+
for (const b of doc.blocks)
|
|
28
|
+
if (b.type === "richtext" && b.data)
|
|
29
|
+
walk(b.data, null);
|
|
30
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Định dạng nội dung của CMS (phiên bản 1). Đây là chuẩn của CMS — trình soạn (hiện là TipTap/ProseMirror) phải
|
|
3
|
+
* chuyển sang chuẩn này, website và máy chủ chỉ đọc chuẩn này. Đổi trình soạn không đổi dữ liệu đã lưu.
|
|
4
|
+
*/
|
|
5
|
+
/** Phiên bản định dạng hiện tại. Tăng khi đổi cấu trúc — kèm hàm nâng cấp trong migrate.ts. */
|
|
6
|
+
export declare const CONTENT_VERSION = 1;
|
|
7
|
+
/** Nội dung một bài viết / trang: danh sách khối cấp cao nhất. */
|
|
8
|
+
export interface ContentDoc {
|
|
9
|
+
version: number;
|
|
10
|
+
blocks: Block[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Khối cấp cao nhất.
|
|
14
|
+
* - `richtext`: `data` là cây {@link RichNode} gốc `type: "doc"` (nội dung soạn bằng trình soạn).
|
|
15
|
+
* - `html`: `data.html` là HTML (nội dung nhập từ hệ thống cũ).
|
|
16
|
+
* - tên khác: khối của plugin, `data` do plugin định nghĩa. Plugin không có/đã tắt → giữ nguyên dữ liệu, website bỏ qua.
|
|
17
|
+
*/
|
|
18
|
+
export interface Block {
|
|
19
|
+
id?: string;
|
|
20
|
+
type: string;
|
|
21
|
+
data: any;
|
|
22
|
+
}
|
|
23
|
+
/** Nút trong nội dung soạn thảo: khối (đoạn, tiêu đề, ảnh…) hoặc chữ (`type: "text"`). */
|
|
24
|
+
export interface RichNode {
|
|
25
|
+
type: string;
|
|
26
|
+
attrs?: Record<string, unknown>;
|
|
27
|
+
content?: RichNode[];
|
|
28
|
+
/** Chỉ có ở `type: "text"`, không rỗng. */
|
|
29
|
+
text?: string;
|
|
30
|
+
/** Định dạng chữ (đậm, liên kết…), chỉ có ở `type: "text"`. */
|
|
31
|
+
marks?: RichMark[];
|
|
32
|
+
}
|
|
33
|
+
export interface RichMark {
|
|
34
|
+
type: string;
|
|
35
|
+
attrs?: Record<string, unknown>;
|
|
36
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Định dạng nội dung của CMS (phiên bản 1). Đây là chuẩn của CMS — trình soạn (hiện là TipTap/ProseMirror) phải
|
|
3
|
+
* chuyển sang chuẩn này, website và máy chủ chỉ đọc chuẩn này. Đổi trình soạn không đổi dữ liệu đã lưu.
|
|
4
|
+
*/
|
|
5
|
+
/** Phiên bản định dạng hiện tại. Tăng khi đổi cấu trúc — kèm hàm nâng cấp trong migrate.ts. */
|
|
6
|
+
export const CONTENT_VERSION = 1;
|
package/dist/url.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL an toàn cho href/src: http(s), mailto, tel, tương đối (/, #, ?, ./), ảnh data:image/* (không SVG).
|
|
3
|
+
* Cùng quy tắc với ContentSanitizer.IsSafeUrl của máy chủ.
|
|
4
|
+
*/
|
|
5
|
+
export declare function isSafeUrl(url: unknown): boolean;
|
|
6
|
+
/** URL an toàn hoặc chuỗi thay thế. */
|
|
7
|
+
export declare const safeUrl: (url: unknown, fallback?: string) => string;
|
package/dist/url.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL an toàn cho href/src: http(s), mailto, tel, tương đối (/, #, ?, ./), ảnh data:image/* (không SVG).
|
|
3
|
+
* Cùng quy tắc với ContentSanitizer.IsSafeUrl của máy chủ.
|
|
4
|
+
*/
|
|
5
|
+
export function isSafeUrl(url) {
|
|
6
|
+
if (url == null || url === "")
|
|
7
|
+
return true;
|
|
8
|
+
// Bỏ ký tự điều khiển/khoảng trắng mà trình duyệt bỏ qua khi phân tích ("java\tscript:")
|
|
9
|
+
const u = String(url).replace(/[\u0000- ]/g, "").toLowerCase();
|
|
10
|
+
if (u.startsWith("data:"))
|
|
11
|
+
return u.startsWith("data:image/") && !u.startsWith("data:image/svg");
|
|
12
|
+
const colon = u.indexOf(":");
|
|
13
|
+
if (colon < 0)
|
|
14
|
+
return true;
|
|
15
|
+
const sep = u.search(/[/?#]/);
|
|
16
|
+
if (sep >= 0 && sep < colon)
|
|
17
|
+
return true; // ":" nằm sau phần đường dẫn → URL tương đối
|
|
18
|
+
return ["http", "https", "mailto", "tel"].includes(u.slice(0, colon));
|
|
19
|
+
}
|
|
20
|
+
/** URL an toàn hoặc chuỗi thay thế. */
|
|
21
|
+
export const safeUrl = (url, fallback = "#") => (isSafeUrl(url) && url != null ? String(url) : fallback);
|