@ecomcx/ecomweb-content 1.14.0 → 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/README.md CHANGED
@@ -74,6 +74,27 @@ Mọi khối có thể mang `attrs.blockStyle` (tên biến thể → class `ecx
74
74
 
75
75
  `theme.json › styles` (`colors`, `fontSizes`, `spacing`, `fonts`) + bản sửa của quản trị viên → `mergePresets(theme, user)` → `presetCss()` sinh `:root{--ecx-color-*;--ecx-font-size-*;--ecx-spacing-*;--ecx-font-family-*}`. Giá trị được kiểm: màu hex/rgb/hsl, độ dài hoặc `clamp()`, danh sách phông không có `; { }`.
76
76
 
77
+ ## Bố cục, hiệu ứng, Global Styles (1.15)
78
+
79
+ Thêm vào `attrs.style`:
80
+
81
+ ```json
82
+ {
83
+ "layout": { "type": "flex", "direction": { "base": "row", "mobile": "column" }, "justify": "between", "align": "center", "wrap": true },
84
+ "layout": { "type": "grid", "columns": { "base": 3, "tablet": 2, "mobile": 1 }, "minColumnWidth": "14rem" },
85
+ "gradient": "linear-gradient(135deg, #1a597b 0%, #f26251 100%)",
86
+ "shadow": "md", "aspectRatio": "16/9", "position": { "type": "sticky", "top": "md" },
87
+ "hover": { "color": { "text": "#fff", "background": "primary" }, "border": { "color": "accent" }, "shadow": "lg", "lift": true }
88
+ }
89
+ ```
90
+
91
+ - `layout`: `flex` (hàng ngang / xếp dọc — `direction`, `justify` start|center|end|between, `align` start|center|end|stretch, `wrap`) hoặc `grid` (`columns` 1–12 theo khổ, không có thì tự chia cột theo `minColumnWidth`). Không đặt `gap` thì dùng `--ecx-block-gap` (mặc định 1rem).
92
+ - `gradient`: preset (`brand` → `var(--ecx-gradient-brand)`) hoặc `linear-gradient` / `radial-gradient` với 2–6 điểm màu hex/rgb/hsl (kèm %) — mọi phần được kiểm, không chèn được CSS.
93
+ - `shadow`: preset (`sm` `md` `lg` `xl` có sẵn giá trị dự phòng, theme ghi đè) hoặc `none`. `aspectRatio`: `1/1` `4/3` `3/2` `16/9` `21/9` `3/4` `2/3` `9/16`.
94
+ - `hover` → `:hover` kèm chuyển động. `ruleCss(selector, style)` sinh CSS cho bộ chọn bất kỳ (website, trình soạn, Global Styles).
95
+
96
+ Preset có thêm `gradients`, `shadows` (tối đa 3 lớp bóng đã kiểm) và **Global Styles**: `blocks` (kiểu mặc định theo loại khối — `BLOCK_SELECTORS`) và `elements` (`link`, `button`, `heading`, `h1`–`h6`, `caption` — `ELEMENT_SELECTORS`). `presetCss` sinh luật trong `:where(.ecx-content)` (độ ưu tiên thấp: kiểu riêng của từng khối luôn thắng). Máy chủ chạy cùng quy tắc (`StyleFormat.cs`, fixture `style-layout-effects`).
97
+
77
98
  ## Nhúng theo đường dẫn — `embedInfo`
78
99
 
79
100
  Khối `embed` chỉ lưu `url` (+ `caption`). `embedInfo(url)` nhận ra nhà cung cấp trong danh sách (YouTube, Vimeo, Loom, Spotify, SoundCloud, TikTok, X, Instagram, Facebook video, CodePen, Figma, Google Maps dạng embed) và **tự dựng** địa chỉ khung từ mã trong đường dẫn — không dùng HTML nhúng do người dùng dán, nên bài viết không chèn được khung tới trang tuỳ ý. Không nhận ra → `null`, website hiện liên kết thường. Trình soạn (xem trước) và website dùng chung hàm này.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Gắn dữ liệu (1.15): `attrs.bindings` — thuộc tính của khối lấy giá trị từ một nguồn lúc hiển thị (tiêu đề bài, trường meta,
3
+ * thuộc tính sản phẩm của plugin…) thay vì chữ gõ tay: `{ content: { source: "core/post", args: { field: "title" } } }`.
4
+ * Khoá khối: `attrs.lock` `{ move, remove }`, `attrs.templateLock` của khối chứa. Máy chủ chạy cùng quy tắc (ContentFormat.cs).
5
+ */
6
+ export interface Binding {
7
+ /** Tên nguồn "nhóm/tên" (core/post, core/post-meta, core/site, tên-plugin/nguồn). */
8
+ source: string;
9
+ args?: Record<string, string | number | boolean>;
10
+ }
11
+ /** Chuẩn hoá `attrs.bindings`: tối đa 10 thuộc tính, nguồn đúng dạng, tham số là chữ (≤ 200 ký tự) / số / đúng-sai. */
12
+ export declare function normalizeBindings(v: unknown): Record<string, Binding> | undefined;
13
+ /** `attrs.lock`: chỉ giữ cờ bật. */
14
+ export declare function normalizeLock(v: unknown): {
15
+ move?: true;
16
+ remove?: true;
17
+ } | undefined;
18
+ /** `attrs.templateLock` của khối chứa: all | insert | contentOnly | none (khối con thoát khoá của khối ngoài). */
19
+ export declare function normalizeTemplateLock(v: unknown): "all" | "insert" | "contentOnly" | "none" | undefined;
@@ -0,0 +1,44 @@
1
+ const SOURCE = /^[a-z0-9-]{1,40}\/[a-z0-9-]{1,40}$/;
2
+ const KEY = /^[a-zA-Z][a-zA-Z0-9]{0,29}$/;
3
+ const ARG_KEY = /^[a-zA-Z][\w-]{0,39}$/;
4
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
5
+ /** Chuẩn hoá `attrs.bindings`: tối đa 10 thuộc tính, nguồn đúng dạng, tham số là chữ (≤ 200 ký tự) / số / đúng-sai. */
6
+ export function normalizeBindings(v) {
7
+ if (!isObj(v))
8
+ return undefined;
9
+ const out = {};
10
+ for (const [k, b] of Object.entries(v)) {
11
+ if (Object.keys(out).length >= 10)
12
+ break;
13
+ if (!KEY.test(k) || !isObj(b) || typeof b.source !== "string" || !SOURCE.test(b.source))
14
+ continue;
15
+ const args = {};
16
+ if (isObj(b.args)) {
17
+ for (const [ak, av] of Object.entries(b.args)) {
18
+ if (Object.keys(args).length >= 10)
19
+ break;
20
+ if (!ARG_KEY.test(ak))
21
+ continue;
22
+ if ((typeof av === "string" && av.length <= 200) || (typeof av === "number" && Number.isFinite(av)) || typeof av === "boolean")
23
+ args[ak] = av;
24
+ }
25
+ }
26
+ out[k] = Object.keys(args).length ? { source: b.source, args } : { source: b.source };
27
+ }
28
+ return Object.keys(out).length ? out : undefined;
29
+ }
30
+ /** `attrs.lock`: chỉ giữ cờ bật. */
31
+ export function normalizeLock(v) {
32
+ if (!isObj(v))
33
+ return undefined;
34
+ const out = {};
35
+ if (v.move === true)
36
+ out.move = true;
37
+ if (v.remove === true)
38
+ out.remove = true;
39
+ return Object.keys(out).length ? out : undefined;
40
+ }
41
+ /** `attrs.templateLock` của khối chứa: all | insert | contentOnly | none (khối con thoát khoá của khối ngoài). */
42
+ export function normalizeTemplateLock(v) {
43
+ return v === "all" || v === "insert" || v === "contentOnly" || v === "none" ? v : undefined;
44
+ }
package/dist/index.d.ts CHANGED
@@ -6,5 +6,6 @@ export { contentText, nodeText, visitNodes, wordCount } from "./text.js";
6
6
  export { isSafeUrl, safeUrl } from "./url.js";
7
7
  export { BLOCK_VERSION_ATTR, blockDefaults, blockNodeSpec, defineBlock, migrateBlockAttrs, type AttrControl, type BlockAttrSpec, type BlockContent, type BlockDefinition, type BlockDefinitionInput } from "./block.js";
8
8
  export { EMBED_PROVIDERS, embedInfo, type EmbedInfo } from "./embed.js";
9
- export { BREAKPOINTS, DEVICES, collectStyles, colorCss, normalizeStyle, spacingValue, styleClass, styleCss, type BlockStyle, type Box, type Device, type Responsive } from "./style.js";
10
- export { CORE_BLOCK_STYLES, mergePresets, normalizeAnchor, normalizeBlockStyleName, normalizeClassName, normalizePresets, presetCss, type StylePresets } from "./style.js";
9
+ export { BREAKPOINTS, DEVICES, collectStyles, colorCss, normalizeGradient, normalizeStyle, ruleCss, spacingValue, styleClass, styleCss, type BlockLayout, type BlockStyle, type Box, type Device, type Responsive } from "./style.js";
10
+ export { BLOCK_SELECTORS, CORE_BLOCK_STYLES, ELEMENT_SELECTORS, mergePresets, normalizeAnchor, normalizeBlockStyleName, normalizeClassName, normalizePresets, presetCss, type StylePresets } from "./style.js";
11
+ export { normalizeBindings, normalizeLock, normalizeTemplateLock, type Binding } from "./bindings.js";
package/dist/index.js CHANGED
@@ -6,5 +6,6 @@ export { contentText, nodeText, visitNodes, wordCount } from "./text.js";
6
6
  export { isSafeUrl, safeUrl } from "./url.js";
7
7
  export { BLOCK_VERSION_ATTR, blockDefaults, blockNodeSpec, defineBlock, migrateBlockAttrs } from "./block.js";
8
8
  export { EMBED_PROVIDERS, embedInfo } from "./embed.js";
9
- export { BREAKPOINTS, DEVICES, collectStyles, colorCss, normalizeStyle, spacingValue, styleClass, styleCss } from "./style.js";
10
- export { CORE_BLOCK_STYLES, mergePresets, normalizeAnchor, normalizeBlockStyleName, normalizeClassName, normalizePresets, presetCss } from "./style.js";
9
+ export { BREAKPOINTS, DEVICES, collectStyles, colorCss, normalizeGradient, normalizeStyle, ruleCss, spacingValue, styleClass, styleCss } from "./style.js";
10
+ export { BLOCK_SELECTORS, CORE_BLOCK_STYLES, ELEMENT_SELECTORS, mergePresets, normalizeAnchor, normalizeBlockStyleName, normalizeClassName, normalizePresets, presetCss } from "./style.js";
11
+ export { normalizeBindings, normalizeLock, normalizeTemplateLock } from "./bindings.js";
package/dist/normalize.js CHANGED
@@ -2,6 +2,7 @@ import { blockNodeSpec, migrateBlockAttrs } from "./block.js";
2
2
  import { migrateContent } from "./migrate.js";
3
3
  import { CORE_MARKS, CORE_NODES, normalizeAttrs } from "./schema.js";
4
4
  import { normalizeAnchor, normalizeBlockStyleName, normalizeClassName, normalizeStyle } from "./style.js";
5
+ import { normalizeBindings, normalizeLock, normalizeTemplateLock } from "./bindings.js";
5
6
  import { CONTENT_VERSION } from "./types.js";
6
7
  const empty = () => ({ version: CONTENT_VERSION, blocks: [] });
7
8
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
@@ -105,7 +106,7 @@ function normalizeNode(node, depth, state) {
105
106
  delete attrs.style;
106
107
  }
107
108
  // Biến thể kiểu, neo, class riêng (mọi khối)
108
- for (const [k, fn] of [["blockStyle", normalizeBlockStyleName], ["anchor", normalizeAnchor], ["className", normalizeClassName]]) {
109
+ for (const [k, fn] of [["blockStyle", normalizeBlockStyleName], ["anchor", normalizeAnchor], ["className", normalizeClassName], ["bindings", normalizeBindings], ["lock", normalizeLock], ["templateLock", normalizeTemplateLock]]) {
109
110
  if (attrs && k in attrs) {
110
111
  const v = fn(attrs[k]);
111
112
  if (v === undefined)
package/dist/schema.d.ts CHANGED
@@ -23,7 +23,7 @@ export interface NodeSpec {
23
23
  */
24
24
  export declare const CORE_NODES: Record<string, NodeSpec>;
25
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", "query", "postTemplate", "pagination", "queryEmpty", "templatePart", "syncedPattern"];
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
27
  export declare const CORE_MARKS: Record<string, NodeSpec>;
28
28
  /** Ép giá trị theo mô tả. Trả về undefined = bỏ thuộc tính (dùng mặc định). */
29
29
  export declare function coerceAttr(spec: AttrSpec, value: unknown): unknown;
package/dist/schema.js CHANGED
@@ -87,6 +87,11 @@ export const CORE_NODES = {
87
87
  postAuthor: { attrs: { avatar: b({ default: false }) } },
88
88
  postTerms: { attrs: { taxonomy: s({ default: "category" }), separator: s({ default: ", " }) } },
89
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 }) } },
90
95
  query: {
91
96
  attrs: {
92
97
  inherit: b({ default: true }), postType: s({ default: "post" }), perPage: i({ min: 1, max: 50, default: 10 }),
@@ -104,6 +109,7 @@ export const CORE_NODES = {
104
109
  export const DYNAMIC_NODES = [
105
110
  "siteTitle", "siteLogo", "siteTagline", "navigation", "search", "languageSwitcher", "copyright",
106
111
  "postTitle", "postContent", "postExcerpt", "featuredImage", "postDate", "postAuthor", "postTerms", "comments",
112
+ "queryTitle", "termDescription", "breadcrumbs", "postNavigation",
107
113
  "query", "postTemplate", "pagination", "queryEmpty", "templatePart", "syncedPattern",
108
114
  ];
109
115
  export const CORE_MARKS = {
package/dist/style.d.ts CHANGED
@@ -39,7 +39,48 @@ export interface BlockStyle {
39
39
  };
40
40
  radius?: string;
41
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;
42
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;
43
84
  /** Chuẩn hoá `attrs.style`: giữ đúng thứ tự khoá, bỏ giá trị không hợp lệ. Rỗng → undefined. */
44
85
  export declare function normalizeStyle(v: unknown): BlockStyle | undefined;
45
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. */
@@ -48,8 +89,13 @@ export declare function spacingValue(v: unknown): string | undefined;
48
89
  export declare function colorCss(v: unknown): string | undefined;
49
90
  /** Tên class ổn định theo nội dung style (cùng kiểu dáng → cùng class). */
50
91
  export declare function styleClass(style: BlockStyle): string;
51
- /** CSS của một kiểu dáng: `.cls.cls{…}` + media query cho tablet / mobile + ẩn theo khổ. Rỗng → "". */
92
+ /** CSS của một kiểu dáng: `.cls.cls{…}` + media query cho tablet / mobile + ẩn theo khổ + :hover. Rỗng → "". */
52
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;
53
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. */
54
100
  export declare function collectStyles(styles: Iterable<BlockStyle>): string;
55
101
  /** Biến thể kiểu (`blockStyle`): tên preset → class `ecx-is-<tên>`. */
@@ -65,7 +111,19 @@ export interface StylePresets {
65
111
  fontSizes?: Record<string, string>;
66
112
  spacing?: Record<string, string>;
67
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>;
68
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>;
69
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ó ; { }). */
70
128
  export declare function normalizePresets(v: unknown): StylePresets;
71
129
  /** Gộp preset theo thứ tự (sau đè trước): theme → bản sửa của quản trị viên. */
package/dist/style.js CHANGED
@@ -18,6 +18,104 @@ const FONT_STYLES = ["normal", "italic"];
18
18
  const DECORATIONS = ["none", "underline", "line-through"];
19
19
  const TRANSFORMS = ["none", "uppercase", "lowercase", "capitalize"];
20
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
+ }
21
119
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
22
120
  const size = (v) => (typeof v === "string" && (LENGTH.test(v) || PRESET.test(v)) ? v : undefined);
23
121
  const color = (v) => (typeof v === "string" && (COLOR.test(v.trim()) || PRESET.test(v)) ? v.trim() : undefined);
@@ -91,6 +189,15 @@ export function normalizeStyle(v) {
91
189
  if (h.length)
92
190
  out.hide = [...h];
93
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));
94
201
  return Object.keys(out).length ? out : undefined;
95
202
  }
96
203
  // ---------------------------------------------------------------------------
@@ -158,6 +265,69 @@ function declarations(style) {
158
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"}`);
159
266
  if (style.radius)
160
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)");
161
331
  return out;
162
332
  }
163
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. */
@@ -180,9 +350,17 @@ export function styleClass(style) {
180
350
  }
181
351
  return `ecx-s-${h.toString(36)}`;
182
352
  }
183
- /** CSS của một kiểu dáng: `.cls.cls{…}` + media query cho tablet / mobile + ẩn theo khổ. Rỗng → "". */
353
+ /** CSS của một kiểu dáng: `.cls.cls{…}` + media query cho tablet / mobile + ẩn theo khổ + :hover. Rỗng → "". */
184
354
  export function styleCss(style, cls = styleClass(style)) {
185
- const sel = `.${cls}.${cls}`;
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(",");
186
364
  const d = declarations(style);
187
365
  let css = d.base.length ? `${sel}{${d.base.join(";")}}` : "";
188
366
  if (d.tablet.length)
@@ -193,6 +371,14 @@ export function styleCss(style, cls = styleClass(style)) {
193
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)`;
194
372
  css += `@media ${q}{${sel}{display:none!important}}`;
195
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}`;
196
382
  return css;
197
383
  }
198
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. */
@@ -234,11 +420,46 @@ export const CORE_BLOCK_STYLES = {
234
420
  button: ["pill"],
235
421
  group: ["card"],
236
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
+ };
237
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*\)$/;
238
437
  const FONT = /^[\w\s"',.\-]{1,200}$/;
239
438
  const presetSize = (v) => (typeof v === "string" && (LENGTH.test(v.trim()) || FLUID.test(v.trim())) ? v.trim() : undefined);
240
439
  const presetColor = (v) => (typeof v === "string" && COLOR.test(v.trim()) ? v.trim() : undefined);
241
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
+ }
242
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ó ; { }). */
243
464
  export function normalizePresets(v) {
244
465
  const out = {};
@@ -262,6 +483,14 @@ export function normalizePresets(v) {
262
483
  pick("fontSizes", presetSize);
263
484
  pick("spacing", presetSize);
264
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;
265
494
  return out;
266
495
  }
267
496
  /** Gộp preset theo thứ tự (sau đè trước): theme → bản sửa của quản trị viên. */
@@ -280,6 +509,15 @@ export function presetCss(presets, selector = ":root") {
280
509
  ...Object.entries(p.fontSizes ?? {}).map(([k, v]) => `--ecx-font-size-${k}:${v}`),
281
510
  ...Object.entries(p.spacing ?? {}).map(([k, v]) => `--ecx-spacing-${k}:${v}`),
282
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}`),
283
514
  ];
284
- return decl.length ? `${selector}{${decl.join(";")}}` : "";
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;
285
523
  }
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "bindings-lock",
3
+ "note": "Gắn dữ liệu + khoá khối (1.15): attrs.bindings (nguồn nhóm/tên, tham số chữ/số/đúng-sai), attrs.lock (chỉ cờ bật), attrs.templateLock — giá trị lạ bị bỏ",
4
+ "input": {
5
+ "version": 1,
6
+ "blocks": [{ "id": "main", "type": "richtext", "data": { "type": "doc", "content": [
7
+ { "type": "heading", "attrs": { "level": 2, "bindings": {
8
+ "content": { "source": "core/post", "args": { "field": "title", "x;y": 1, "obj": { "a": 1 } } },
9
+ "bad key": { "source": "core/post" },
10
+ "alt": { "source": "javascript:alert(1)" },
11
+ "url": "core/post"
12
+ }, "lock": { "move": true, "remove": "yes" } }, "content": [{ "type": "text", "text": "Tiêu đề mẫu" }] },
13
+ { "type": "group", "attrs": { "templateLock": "everything" }, "content": [
14
+ { "type": "paragraph", "attrs": { "bindings": { "content": { "source": "shop/product", "args": { "id": 42, "field": "price", "sale": true } } } } }
15
+ ] },
16
+ { "type": "group", "attrs": { "templateLock": "contentOnly", "lock": { "move": false } }, "content": [
17
+ { "type": "paragraph", "attrs": { "bindings": {} } }
18
+ ] }
19
+ ] } }]
20
+ },
21
+ "expected": {
22
+ "version": 1,
23
+ "blocks": [{ "id": "main", "type": "richtext", "data": { "type": "doc", "content": [
24
+ { "type": "heading", "attrs": { "level": 2, "bindings": { "content": { "source": "core/post", "args": { "field": "title" } } }, "lock": { "move": true } }, "content": [{ "type": "text", "text": "Tiêu đề mẫu" }] },
25
+ { "type": "group", "content": [
26
+ { "type": "paragraph", "attrs": { "bindings": { "content": { "source": "shop/product", "args": { "id": 42, "field": "price", "sale": true } } } } }
27
+ ] },
28
+ { "type": "group", "attrs": { "templateLock": "contentOnly" }, "content": [
29
+ { "type": "paragraph" }
30
+ ] }
31
+ ] } }]
32
+ }
33
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "style-layout-effects",
3
+ "note": "Kiểu dáng 1.15: bố cục flex/grid (theo khổ), nền chuyển màu đã kiểm, bóng, tỉ lệ, ghim, hover — giá trị lạ và CSS chèn bị bỏ; dạng gọn thành theo khổ",
4
+ "input": {
5
+ "version": 1,
6
+ "blocks": [{ "id": "main", "type": "richtext", "data": { "type": "doc", "content": [
7
+ { "type": "group", "attrs": { "style": {
8
+ "layout": { "type": "flex", "direction": { "base": "row", "mobile": "column" }, "justify": "between", "align": "middle", "wrap": "yes", "columns": 3 },
9
+ "gradient": "linear-gradient(135deg,#1a597b 0%, rgb(242, 98, 81) 100%)",
10
+ "shadow": "lg",
11
+ "hover": { "color": { "background": "primary", "text": "red;}" }, "lift": true, "shadow": "0 0 1px red" }
12
+ } }, "content": [
13
+ { "type": "paragraph", "content": [{ "type": "text", "text": "Hàng ngang" }] }
14
+ ] },
15
+ { "type": "group", "attrs": { "style": {
16
+ "layout": { "type": "grid", "columns": { "base": "3", "tablet": 2, "mobile": 0 }, "minColumnWidth": "14rem", "direction": "row" },
17
+ "gradient": "url(javascript:alert(1))",
18
+ "aspectRatio": "16/9",
19
+ "position": { "type": "sticky", "top": "1e9px" }
20
+ } }, "content": [
21
+ { "type": "paragraph", "content": [{ "type": "text", "text": "Lưới" }] }
22
+ ] },
23
+ { "type": "image", "attrs": { "src": "https://a.vn/a.png", "style": { "aspectRatio": "7/3", "shadow": "none", "layout": "grid" } } }
24
+ ] } }]
25
+ },
26
+ "expected": {
27
+ "version": 1,
28
+ "blocks": [{ "id": "main", "type": "richtext", "data": { "type": "doc", "content": [
29
+ { "type": "group", "attrs": { "style": {
30
+ "layout": { "type": "flex", "direction": { "base": "row", "mobile": "column" }, "justify": { "base": "between" } },
31
+ "gradient": "linear-gradient(135deg, #1a597b 0%, rgb(242, 98, 81) 100%)",
32
+ "shadow": "lg",
33
+ "hover": { "color": { "background": "primary" }, "lift": true }
34
+ } }, "content": [
35
+ { "type": "paragraph", "content": [{ "type": "text", "text": "Hàng ngang" }] }
36
+ ] },
37
+ { "type": "group", "attrs": { "style": {
38
+ "layout": { "type": "grid", "columns": { "base": 3, "tablet": 2 }, "minColumnWidth": "14rem" },
39
+ "aspectRatio": "16/9",
40
+ "position": { "type": "sticky" }
41
+ } }, "content": [
42
+ { "type": "paragraph", "content": [{ "type": "text", "text": "Lưới" }] }
43
+ ] },
44
+ { "type": "image", "attrs": { "src": "https://a.vn/a.png", "style": { "shadow": "none" } } }
45
+ ] } }]
46
+ }
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecomcx/ecomweb-content",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Định dạng nội dung của Ecomweb CMS: kiểu dữ liệu, schema khối lõi, chuẩn hoá, nâng phiên bản và bộ fixture hợp đồng — dùng chung cho trình soạn, website và máy chủ, không phụ thuộc trình soạn nào.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
39
39
  "check-dist": "node scripts/check-dist.mjs",
40
- "test": "tsc -p tsconfig.test.json && node --test .test-build/test/ && node scripts/check-dist.mjs"
40
+ "test": "tsc -p tsconfig.test.json && node --test \".test-build/test/*.test.js\" && node scripts/check-dist.mjs"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "22.19.1",
package/style.css CHANGED
@@ -130,6 +130,14 @@
130
130
  :where(.ecx-site-title) { margin: 0; font-weight: 700; }
131
131
  :where(.ecx-site-title) :where(a) { color: inherit; text-decoration: none; }
132
132
  :where(.ecx-site-tagline, .ecx-copyright, .ecx-post-date, .ecx-post-terms, .ecx-post-author) { color: var(--_muted, inherit); }
133
+ :where(.ecx-breadcrumbs) :where(ol) { display: flex; flex-wrap: wrap; gap: .35em; margin: 0 0 1em; padding: 0; list-style: none; font-size: .875em; color: var(--_muted, inherit); }
134
+ :where(.ecx-breadcrumbs) :where(a) { color: inherit; }
135
+ :where(.ecx-breadcrumbs-sep) { margin-right: .35em; opacity: .6; }
136
+ :where(.ecx-term-description) { color: var(--_muted, inherit); margin: 0 0 1.5em; }
137
+ :where(.ecx-post-navigation) { display: flex; justify-content: space-between; gap: 1em; margin: 2em 0; padding-top: 1em; border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); }
138
+ :where(.ecx-post-navigation) :where(a) { display: flex; flex-direction: column; gap: .2em; max-width: 48%; color: inherit; text-decoration: none; }
139
+ :where(.ecx-post-navigation-next) { text-align: right; margin-left: auto; }
140
+ :where(.ecx-post-navigation) :where(span) { font-size: .8em; text-transform: uppercase; letter-spacing: .04em; opacity: .65; }
133
141
  :where(.ecx-navigation-menu) :where(ul) { display: flex; flex-wrap: wrap; gap: .25em 1.25em; margin: 0; padding: 0; list-style: none; }
134
142
  :where(.ecx-navigation-menu) :where(ul ul) { display: none; }
135
143
  :where(.ecx-navigation-menu) :where(a) { color: inherit; text-decoration: none; }