@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ecomcx JSC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,179 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ecomcx/ecomweb-content — định dạng nội dung của CMS
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Chuẩn lưu nội dung bài viết/trang của CMS, **không phụ thuộc trình soạn**. Trình soạn (hiện là TipTap/ProseMirror trong `@ecomcx/ecomweb-editor`) chuyển sang chuẩn này; website (`@ecomcx/ecomweb-theme-sdk`) và máy chủ (`ContentFormat.cs`) chỉ đọc chuẩn này. Đổi hay thay trình soạn không đổi dữ liệu đã lưu, website vẫn chạy.
|
|
4
|
+
|
|
5
|
+
CMS lưu **dữ liệu**, HTML vẽ lúc hiển thị → theme đổi giao diện khối thì bài cũ tự theo, không có HTML cũ lệch với mã mới.
|
|
6
|
+
|
|
7
|
+
## Định dạng (bản 1)
|
|
8
|
+
|
|
9
|
+
```jsonc
|
|
10
|
+
{
|
|
11
|
+
"version": 1,
|
|
12
|
+
"blocks": [
|
|
13
|
+
{ "id": "main", "type": "richtext", "data": { "type": "doc", "content": [ /* RichNode */ ] } },
|
|
14
|
+
{ "id": "b2", "type": "html", "data": { "html": "<p>nội dung nhập từ hệ thống cũ</p>" } },
|
|
15
|
+
{ "id": "b3", "type": "ecommerce/products", "data": { "ids": [1, 2] } } // khối của plugin
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**RichNode** — cây nội dung soạn thảo:
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{ "type": "paragraph", "attrs": { "textAlign": "center" }, "content": [
|
|
24
|
+
{ "type": "text", "text": "Xin chào ", "marks": [{ "type": "bold" }] },
|
|
25
|
+
{ "type": "text", "text": "liên kết", "marks": [{ "type": "link", "attrs": { "href": "https://…" } }] }
|
|
26
|
+
] }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Khối lõi và thuộc tính: `CORE_NODES`, `CORE_MARKS` trong `src/schema.ts` (1.9: mục lục `tableOfContents`, ảnh bìa `cover`, nhiều nút `buttons` > `button`, khoảng trống `spacer`, ảnh + chữ `mediaText`, trích dẫn nổi bật `pullquote`, chi tiết `details`, HTML tuỳ chỉnh `customHtml` (máy chủ lọc khi người soạn thiếu quyền `unfiltered_html`), mạng xã hội `socialLinks`, văn bản định dạng sẵn `preformatted`; đoạn, tiêu đề, danh sách, danh sách việc, trích dẫn, mã, ảnh, bộ ảnh, video, âm thanh, tệp, bảng, YouTube; từ 1.2: cột `columns` > `column`, nhóm `group`, hộp lưu ý `callout`, nút `button`, nhúng `embed`; đậm, nghiêng, gạch chân, gạch ngang, mã, chỉ số trên/dưới, tô nền, màu chữ, liên kết).
|
|
30
|
+
- `null` = không có: thuộc tính null không được lưu. Thuộc tính khối lõi sai kiểu được ép (`"3"` → `3`, kẹp min/max) hoặc bỏ.
|
|
31
|
+
- **Khối / nút lạ được giữ nguyên** (khối của plugin, kể cả khi plugin đã tắt) — website bỏ qua khối không có renderer, nút lạ trong nội dung soạn thảo vẫn hiện phần chữ bên trong.
|
|
32
|
+
- Giới hạn: lồng tối đa 64 cấp, tối đa 100 000 nút một bài.
|
|
33
|
+
- `version` tăng khi đổi cấu trúc, kèm hàm nâng cấp (`src/migrate.ts`) — bài lưu bằng bản cũ được nâng khi đọc.
|
|
34
|
+
|
|
35
|
+
## Khối động (1.6)
|
|
36
|
+
|
|
37
|
+
Khối không chứa chữ cố định — lấy dữ liệu lúc hiển thị theo ngữ cảnh (dùng trong template của theme / Trình dựng, hoặc trang dựng bằng khối). Danh sách: `DYNAMIC_NODES`.
|
|
38
|
+
|
|
39
|
+
| Nhóm | Khối | Thuộc tính |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Website | `siteTitle`, `siteLogo`, `siteTagline`, `navigation`, `search`, `languageSwitcher`, `copyright` | `level`, `link`, `width`, `location` (vị trí menu), `orientation`, `mobile`, `placeholder`, `buttonText`, `text`, `textAlign` |
|
|
42
|
+
| Bài / trang đang xem (hoặc bài trong vòng lặp) | `postTitle`, `postContent`, `postExcerpt`, `featuredImage`, `postDate`, `postAuthor`, `postTerms`, `comments` | `level`, `link`, `moreText`, `size`, `aspect`, `avatar`, `taxonomy`, `separator` |
|
|
43
|
+
| Danh sách bài | `query` > `postTemplate` (khuôn lặp cho từng bài) + `pagination` + `queryEmpty` | `inherit` (dùng danh sách của trang đang xem), `postType`, `perPage`, `category`, `tag`, `orderBy`, `order`; `layout` (`list`/`grid`), `columns` |
|
|
44
|
+
| Ghép | `templatePart` | `slug` (vd `header`, `footer`), `tagName` |
|
|
45
|
+
| Mẫu đồng bộ | `syncedPattern` | `ref` (id của mẫu — sửa mẫu gốc thì mọi chỗ dùng đổi theo) |
|
|
46
|
+
|
|
47
|
+
## Kiểu dáng khối (1.7) — `attrs.style`
|
|
48
|
+
|
|
49
|
+
Mọi khối (lõi và plugin) có thể mang `attrs.style`:
|
|
50
|
+
|
|
51
|
+
```jsonc
|
|
52
|
+
"style": {
|
|
53
|
+
"padding": { "base": "xl", "tablet": "lg", "mobile": { "top": "md", "bottom": "md" } }, // tablet ≤ 1024px, mobile ≤ 640px
|
|
54
|
+
"margin": { "base": "auto" }, "gap": { "base": "lg" }, "width": { "base": "100%" }, "maxWidth": { "base": "640px" },
|
|
55
|
+
"minHeight": { "base": "60vh" }, "fontFamily": "heading",
|
|
56
|
+
"fontSize": { "base": "3xl", "mobile": "2xl" }, "lineHeight": { "base": 1.2 }, "letterSpacing": { "base": "0.05em" },
|
|
57
|
+
"fontWeight": "700", "fontStyle": "italic", "textDecoration": "underline", "textTransform": "uppercase", "textAlign": { "base": "center" },
|
|
58
|
+
"color": { "text": "white", "background": "#0f172a" }, "border": { "width": "1px", "style": "dashed", "color": "primary" }, "radius": "12px",
|
|
59
|
+
"hide": ["mobile"]
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- Giá trị: preset của theme (`lg`, `2xl`, `primary` → `var(--ecx-spacing-lg)`, `var(--ecx-font-size-2xl)`, `var(--ecx-color-primary, primary)`) hoặc giá trị CSS đã kiểm (độ dài `px|rem|em|%|vw|vh`, `0`, `auto`, màu hex/rgb/hsl). Giá trị khác bị bỏ khi chuẩn hoá → không chèn được CSS.
|
|
64
|
+
- Một giá trị gọn (`"padding": "lg"`) được chuẩn hoá thành `{ "base": "lg" }`.
|
|
65
|
+
- `styleClass(style)` → class ổn định (`ecx-s-…`), `styleCss(style)` → CSS (`.cls.cls{…}` + media query), `collectStyles([...])` gom CSS cả trang. Website và trình soạn dùng chung.
|
|
66
|
+
- `fontFamily` chỉ nhận tên preset (`body`, `heading`, `mono`… → `var(--ecx-font-family-heading, inherit)`), theme khai báo phông.
|
|
67
|
+
- Preset mặc định nằm trong `style.css` (`--ecx-spacing-xs…3xl`, `--ecx-font-size-xs…4xl`, `--ecx-color-primary|muted|surface|white|black`); theme đặt lại biến để đổi.
|
|
68
|
+
|
|
69
|
+
## Biến thể kiểu, neo, class riêng (1.10)
|
|
70
|
+
|
|
71
|
+
Mọi khối có thể mang `attrs.blockStyle` (tên biến thể → class `ecx-is-<tên>`), `attrs.anchor` (id để làm neo, vd `#bang-gia`), `attrs.className` (tối đa 5 class CSS hợp lệ). Biến thể có sẵn của lõi (`CORE_BLOCK_STYLES`, CSS trong `style.css`): bảng `stripes`; ảnh `rounded`, `circle`; trích dẫn `plain`; đường kẻ `dots`, `wide`; nút `pill`; nhóm `card`. Theme thêm biến thể bằng `theme.json › blockStyles` và CSS `.ecx-is-<tên>`.
|
|
72
|
+
|
|
73
|
+
## Preset toàn site (1.10)
|
|
74
|
+
|
|
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
|
+
|
|
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
|
+
|
|
98
|
+
## Nhúng theo đường dẫn — `embedInfo`
|
|
99
|
+
|
|
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.
|
|
101
|
+
|
|
102
|
+
## Cài
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm install github:Ecomcx-Jsc/ecomweb-content#semver:^1
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
"dependencies": { "@ecomcx/ecomweb-content": "github:Ecomcx-Jsc/ecomweb-content#semver:^1" }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`#semver:^1` = mọi bản 1.x (tag `v1.*`): `npm update @ecomcx/ecomweb-content` lấy bản mới nhất, bản 2.0 (đổi làm vỡ API) phải chủ động đổi. `package-lock.json` ghi đúng commit đã cài. `dist/` được commit sẵn trong repo (npm 11 bỏ qua `prepare` của gói GitHub khi cài với `--ignore-scripts` — Builder của CMS cài như vậy). Không phụ thuộc thư viện nào lúc chạy — dùng được trong trình duyệt, Node, Next.js, công cụ dòng lệnh.
|
|
113
|
+
|
|
114
|
+
## Dùng
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { normalizeContent, contentText, wordCount, isSafeUrl } from "@ecomcx/ecomweb-content";
|
|
118
|
+
|
|
119
|
+
const { doc, issues } = normalizeContent(input); // {version, blocks}, cây TipTap thuần hoặc chuỗi HTML
|
|
120
|
+
contentText(doc); // chữ thuần (tìm kiếm, tóm tắt)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Giao diện nền — `style.css`
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import "@ecomcx/ecomweb-content/style.css";
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
CSS tối thiểu cho nội dung nằm trong phần tử có class `ecx-content` (bảng có viền, danh sách, trích dẫn, mã, ảnh, chú thích, khung nhúng, khối bố cục) và form `ecx-form`. Website (`PostContent`) và trình soạn (`@ecomcx/ecomweb-editor`) cùng nạp file này → theme không viết CSS cho nội dung vẫn hiển thị đúng, trình soạn và website giống nhau.
|
|
130
|
+
|
|
131
|
+
- Nằm trong `@layer ecx-content`, mọi selector trong `:where()` → CSS của theme luôn thắng, kể cả CSS có layer (Tailwind). Nạp file này **trước** CSS của theme. Ứng dụng có sẵn layer (vd Tailwind trong admin) mà muốn nền thắng reset của mình thì khai báo `@layer ecx-content;` sau các layer đó.
|
|
132
|
+
- Đổi màu bằng biến: `--ecx-border`, `--ecx-muted`, `--ecx-accent` (mặc định `--primary`), `--ecx-surface`, `--ecx-code-bg`, `--ecx-code-fg`, `--ecx-button-bg`, `--ecx-button-fg`. Không đặt thì màu suy ra từ màu chữ (`currentColor`) → hợp cả nền sáng lẫn tối.
|
|
133
|
+
- Class của khối có tiền tố `ecx-` (`ecx-table`, `ecx-columns`, `ecx-callout`, `ecx-button`, `ecx-embed`, `ecx-align-left`…). Website vẫn xuất kèm class cũ `cms-*` / `table-wrap` / `alignleft` tới bản 2.0.
|
|
134
|
+
- `ecx-full-width` (template trang "full-width" của theme-sdk): trải hết chiều ngang màn hình dù nằm trong khung giới hạn của theme.
|
|
135
|
+
|
|
136
|
+
## Khối của plugin — `defineBlock`
|
|
137
|
+
|
|
138
|
+
Khai báo một lần, dùng ở cả trình soạn (admin) lẫn website — đặt trong file dùng chung của plugin: Tên khối luôn có dạng `slug-plugin/tên` — tên không có `/` dành cho khối lõi (khối lõi mới có thể được thêm ở bản 1.x).
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// blocks.ts
|
|
142
|
+
import { defineBlock } from "@ecomcx/ecomweb-content";
|
|
143
|
+
export const callout = defineBlock({
|
|
144
|
+
name: "lien-he/callout", title: "Hộp lưu ý", icon: "info", keywords: "luu y callout", content: "blocks",
|
|
145
|
+
attributes: {
|
|
146
|
+
tone: { type: "string", enum: ["info", "warning"], enumLabels: ["Thông tin", "Cảnh báo"], default: "info", label: "Kiểu" },
|
|
147
|
+
title: { type: "string", label: "Tiêu đề" },
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
- Admin (`@ecomcx/ecomweb-admin-sdk`): `registerBlock(callout)` → trình soạn tự có mục slash menu, khung khối, form cài đặt sinh từ `attributes` (không viết TipTap).
|
|
153
|
+
- Website (`@ecomcx/ecomweb-theme-sdk`): `definePlugin({ blocks: { [callout.name]: Callout }, blockTypes: [callout] })` — `Callout` nhận `data` (thuộc tính đã chuẩn hoá) và `children` (nội dung bên trong).
|
|
154
|
+
- `content`: `"none"` (khối nguyên), `"inline"` (một dòng chữ), `"blocks"` (chứa khối khác).
|
|
155
|
+
- Đổi cấu trúc thuộc tính: tăng `version`, thêm `migrate[bản-cũ]` — bài đã lưu được nâng khi đọc (lưu kèm `_v`). Migrate lỗi → giữ nguyên dữ liệu.
|
|
156
|
+
- Plugin tắt / gỡ: dữ liệu khối nằm nguyên trong bài; website hiện phần chữ bên trong (nếu có), bật lại là hiện như cũ.
|
|
157
|
+
|
|
158
|
+
## Hợp đồng
|
|
159
|
+
|
|
160
|
+
`fixtures/*.json` — mỗi file `{ name, note, input, expected }`. Ba bên cùng chạy bộ này:
|
|
161
|
+
|
|
162
|
+
| Bên | Test |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `@ecomcx/ecomweb-content` | `normalizeContent(input) == expected`, chuẩn hoá lần hai không đổi |
|
|
165
|
+
| Máy chủ (core) | `ContentFormatTests` — cùng fixture (bản chép ở `backend/tests/Cms.Tests/ContentFixtures/` của repo core) |
|
|
166
|
+
| Website (`@ecomcx/ecomweb-theme-sdk`) | vẽ mỗi fixture ra đúng HTML đã duyệt (`test/snapshots/`) |
|
|
167
|
+
| Trình soạn (`@ecomcx/ecomweb-editor`) | mở fixture rồi xuất lại → chuẩn hoá ra đúng `expected` |
|
|
168
|
+
|
|
169
|
+
Đổi quy tắc: sửa `src/` + `ContentFormat.cs` + fixture cùng lúc. Thay trình soạn: chỉ cần qua bộ test của trình soạn.
|
|
170
|
+
|
|
171
|
+
## Phát triển
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
npm install
|
|
175
|
+
npm test # biên dịch + node --test
|
|
176
|
+
npm run build # dist/ — commit cùng src/ (npm test báo lỗi nếu dist/ cũ)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Phát hành: tăng `version` (MAJOR.MINOR.PATCH — chỉ tăng MAJOR khi làm vỡ API), commit, `git tag vX.Y.Z && git push --tags`; bên dùng chạy `npm update @ecomcx/ecomweb-content`.
|
|
@@ -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;
|
package/dist/bindings.js
ADDED
|
@@ -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/block.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AttrSpec, NodeSpec } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Cách nhập một thuộc tính trong form cài đặt của khối (trình soạn tự sinh form). Mặc định suy từ kiểu:
|
|
4
|
+
* string → text (có enum → select), number/integer → number, boolean → checkbox, url → url.
|
|
5
|
+
*/
|
|
6
|
+
export type AttrControl = "text" | "textarea" | "number" | "checkbox" | "select" | "color" | "url" | "media" | "hidden";
|
|
7
|
+
export interface BlockAttrSpec extends AttrSpec {
|
|
8
|
+
/** Nhãn trong form cài đặt (chữ hoặc "%khoá%" dịch theo locales của plugin). */
|
|
9
|
+
label?: string;
|
|
10
|
+
control?: AttrControl;
|
|
11
|
+
/** Nhãn cho từng giá trị của enum (cùng thứ tự). */
|
|
12
|
+
enumLabels?: readonly string[];
|
|
13
|
+
help?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Khối chứa gì: không gì (khối nguyên — ảnh, thẻ sản phẩm), chữ (một dòng có định dạng), hoặc các khối khác (hộp lưu ý chứa đoạn văn). */
|
|
16
|
+
export type BlockContent = "none" | "inline" | "blocks";
|
|
17
|
+
export interface BlockDefinitionInput {
|
|
18
|
+
/** Tên khối, duy nhất trong mọi plugin — nên đặt tiền tố plugin: "ten-plugin/callout" hoặc "tenPluginCallout". */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Tên hiển thị (slash menu, khung khối). */
|
|
21
|
+
title: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Biểu tượng (tên icon lucide, vd "star") — trình soạn hiển thị nếu có. */
|
|
24
|
+
icon?: string;
|
|
25
|
+
/** Từ khoá tìm trong slash menu (không dấu, cách nhau bởi khoảng trắng). */
|
|
26
|
+
keywords?: string;
|
|
27
|
+
category?: string;
|
|
28
|
+
content?: BlockContent;
|
|
29
|
+
attributes?: Record<string, BlockAttrSpec>;
|
|
30
|
+
/**
|
|
31
|
+
* Phiên bản cấu trúc thuộc tính (mặc định 1). Đổi cấu trúc → tăng version + thêm migrate[phiên-bản-cũ]:
|
|
32
|
+
* bài lưu bằng bản cũ được nâng khi đọc (trình soạn, website, máy chủ đều đọc qua normalizeContent).
|
|
33
|
+
*/
|
|
34
|
+
version?: number;
|
|
35
|
+
migrate?: Record<number, (attrs: Record<string, unknown>) => Record<string, unknown>>;
|
|
36
|
+
}
|
|
37
|
+
export interface BlockDefinition extends Required<Pick<BlockDefinitionInput, "name" | "title" | "content" | "version">> {
|
|
38
|
+
description?: string;
|
|
39
|
+
icon?: string;
|
|
40
|
+
keywords: string;
|
|
41
|
+
category?: string;
|
|
42
|
+
attributes: Record<string, BlockAttrSpec>;
|
|
43
|
+
migrate: Record<number, (attrs: Record<string, unknown>) => Record<string, unknown>>;
|
|
44
|
+
}
|
|
45
|
+
/** Thuộc tính lưu phiên bản của khối trong dữ liệu (chỉ khi version > 1). */
|
|
46
|
+
export declare const BLOCK_VERSION_ATTR = "_v";
|
|
47
|
+
/**
|
|
48
|
+
* Khai báo một khối — dùng chung cho trình soạn (admin) và website: cùng một file định nghĩa, import ở cả hai phía.
|
|
49
|
+
* Chỉ là dữ liệu (không phụ thuộc trình soạn nào): trình soạn tự sinh node + form cài đặt + mục slash menu, website
|
|
50
|
+
* chuẩn hoá thuộc tính rồi đưa cho component hiển thị của plugin. Khối của plugin đã tắt: dữ liệu giữ nguyên.
|
|
51
|
+
*/
|
|
52
|
+
export declare function defineBlock(input: BlockDefinitionInput): BlockDefinition;
|
|
53
|
+
/** Giá trị mặc định của các thuộc tính (khi chèn khối mới). */
|
|
54
|
+
export declare function blockDefaults(def: BlockDefinition): Record<string, unknown>;
|
|
55
|
+
/** Mô tả thuộc tính để chuẩn hoá (normalizeContent → options.nodes). */
|
|
56
|
+
export declare function blockNodeSpec(def: BlockDefinition): NodeSpec;
|
|
57
|
+
/** Nâng thuộc tính của một khối lưu bằng bản cũ lên bản hiện tại. */
|
|
58
|
+
export declare function migrateBlockAttrs(def: BlockDefinition, attrs: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
|
package/dist/block.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Thuộc tính lưu phiên bản của khối trong dữ liệu (chỉ khi version > 1). */
|
|
2
|
+
export const BLOCK_VERSION_ATTR = "_v";
|
|
3
|
+
const NAME = /^[a-zA-Z][\w-]*(\/[a-zA-Z][\w-]*)?$/;
|
|
4
|
+
/**
|
|
5
|
+
* Khai báo một khối — dùng chung cho trình soạn (admin) và website: cùng một file định nghĩa, import ở cả hai phía.
|
|
6
|
+
* Chỉ là dữ liệu (không phụ thuộc trình soạn nào): trình soạn tự sinh node + form cài đặt + mục slash menu, website
|
|
7
|
+
* chuẩn hoá thuộc tính rồi đưa cho component hiển thị của plugin. Khối của plugin đã tắt: dữ liệu giữ nguyên.
|
|
8
|
+
*/
|
|
9
|
+
export function defineBlock(input) {
|
|
10
|
+
if (!NAME.test(input.name))
|
|
11
|
+
throw new Error(`defineBlock: tên khối không hợp lệ "${input.name}" (chữ, số, -, _; tuỳ chọn tiền tố "plugin/")`);
|
|
12
|
+
const version = input.version ?? 1;
|
|
13
|
+
if (!Number.isInteger(version) || version < 1)
|
|
14
|
+
throw new Error(`defineBlock(${input.name}): version phải là số nguyên ≥ 1`);
|
|
15
|
+
for (let v = 1; v < version; v++)
|
|
16
|
+
if (!input.migrate?.[v])
|
|
17
|
+
throw new Error(`defineBlock(${input.name}): thiếu migrate[${v}] (nâng từ bản ${v} lên ${v + 1})`);
|
|
18
|
+
if (input.attributes?.[BLOCK_VERSION_ATTR])
|
|
19
|
+
throw new Error(`defineBlock(${input.name}): "${BLOCK_VERSION_ATTR}" là tên dành riêng`);
|
|
20
|
+
return Object.freeze({
|
|
21
|
+
name: input.name,
|
|
22
|
+
title: input.title,
|
|
23
|
+
description: input.description,
|
|
24
|
+
icon: input.icon,
|
|
25
|
+
keywords: input.keywords ?? "",
|
|
26
|
+
category: input.category,
|
|
27
|
+
content: input.content ?? "none",
|
|
28
|
+
attributes: { ...input.attributes },
|
|
29
|
+
version,
|
|
30
|
+
migrate: { ...input.migrate },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** Giá trị mặc định của các thuộc tính (khi chèn khối mới). */
|
|
34
|
+
export function blockDefaults(def) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [k, spec] of Object.entries(def.attributes))
|
|
37
|
+
if (spec.default !== undefined)
|
|
38
|
+
out[k] = structuredClone(spec.default);
|
|
39
|
+
if (def.version > 1)
|
|
40
|
+
out[BLOCK_VERSION_ATTR] = def.version;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Mô tả thuộc tính để chuẩn hoá (normalizeContent → options.nodes). */
|
|
44
|
+
export function blockNodeSpec(def) {
|
|
45
|
+
return { attrs: { ...def.attributes, [BLOCK_VERSION_ATTR]: { type: "integer", min: 1 } } };
|
|
46
|
+
}
|
|
47
|
+
/** Nâng thuộc tính của một khối lưu bằng bản cũ lên bản hiện tại. */
|
|
48
|
+
export function migrateBlockAttrs(def, attrs) {
|
|
49
|
+
if (def.version <= 1)
|
|
50
|
+
return attrs;
|
|
51
|
+
let current = { ...(attrs ?? {}) };
|
|
52
|
+
let v = typeof current[BLOCK_VERSION_ATTR] === "number" ? current[BLOCK_VERSION_ATTR] : 1;
|
|
53
|
+
if (v >= def.version)
|
|
54
|
+
return attrs;
|
|
55
|
+
delete current[BLOCK_VERSION_ATTR];
|
|
56
|
+
for (; v < def.version; v++)
|
|
57
|
+
current = def.migrate[v]({ ...current });
|
|
58
|
+
current[BLOCK_VERSION_ATTR] = def.version;
|
|
59
|
+
return current;
|
|
60
|
+
}
|
package/dist/embed.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nhúng theo đường dẫn (khối `embed`): chỉ nhà cung cấp trong danh sách dưới đây được hiển thị dạng khung nhúng.
|
|
3
|
+
* Địa chỉ khung luôn do hàm này dựng từ mã nhận ra trong đường dẫn (không dùng HTML/URL nhúng do người dùng dán) →
|
|
4
|
+
* dữ liệu bài viết không thể chèn khung tới trang tuỳ ý. Đường dẫn không nhận ra → website hiện liên kết thường.
|
|
5
|
+
* Trình soạn (xem trước) và website (theme-sdk) dùng chung hàm này.
|
|
6
|
+
*/
|
|
7
|
+
export interface EmbedInfo {
|
|
8
|
+
/** Mã nhà cung cấp (youtube, vimeo…). */
|
|
9
|
+
provider: string;
|
|
10
|
+
/** Tên hiển thị (title của iframe). */
|
|
11
|
+
title: string;
|
|
12
|
+
/** Địa chỉ khung nhúng. */
|
|
13
|
+
src: string;
|
|
14
|
+
/** Tỉ lệ rộng/cao (khung co giãn theo bề ngang). Không có → dùng `height` cố định. */
|
|
15
|
+
aspect?: number;
|
|
16
|
+
height?: number;
|
|
17
|
+
/** Bề ngang tối đa (vd video dọc TikTok). */
|
|
18
|
+
maxWidth?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Thông tin nhúng của một đường dẫn, hoặc null nếu không thuộc nhà cung cấp nào được hỗ trợ. */
|
|
21
|
+
export declare function embedInfo(raw: unknown): EmbedInfo | null;
|
|
22
|
+
/** Nhà cung cấp được hỗ trợ (để hiện gợi ý trong trình soạn / tài liệu). */
|
|
23
|
+
export declare const EMBED_PROVIDERS: readonly string[];
|
package/dist/embed.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const UrlCtor = globalThis.URL;
|
|
2
|
+
const enc = encodeURIComponent;
|
|
3
|
+
/** Giây bắt đầu từ ?t=1m30s / ?t=90 / ?start=90. */
|
|
4
|
+
function startSeconds(url) {
|
|
5
|
+
const t = url.searchParams.get("t") ?? url.searchParams.get("start") ?? "";
|
|
6
|
+
if (/^\d+$/.test(t))
|
|
7
|
+
return Number(t);
|
|
8
|
+
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
|
9
|
+
return m ? Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0) : 0;
|
|
10
|
+
}
|
|
11
|
+
const RULES = [
|
|
12
|
+
{ provider: "youtube", title: "YouTube", aspect: 16 / 9,
|
|
13
|
+
match: /^https:\/\/(?:www\.|m\.)?(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([\w-]{11})(?:[?&#/]|$)/,
|
|
14
|
+
src: (m, u) => `https://www.youtube-nocookie.com/embed/${m[1]}${startSeconds(u) ? `?start=${startSeconds(u)}` : ""}` },
|
|
15
|
+
{ provider: "vimeo", title: "Vimeo", aspect: 16 / 9, match: /^https:\/\/(?:www\.)?vimeo\.com\/(?:video\/)?(\d+)(?:[?#/]|$)/, src: (m) => `https://player.vimeo.com/video/${m[1]}?dnt=1` },
|
|
16
|
+
{ provider: "loom", title: "Loom", aspect: 16 / 9, match: /^https:\/\/(?:www\.)?loom\.com\/share\/([a-f0-9]+)(?:[?#]|$)/, src: (m) => `https://www.loom.com/embed/${m[1]}` },
|
|
17
|
+
{ provider: "spotify", title: "Spotify", height: (m) => (m[1] === "track" || m[1] === "episode" ? 152 : 352),
|
|
18
|
+
match: /^https:\/\/open\.spotify\.com\/(?:intl-[a-z-]+\/)?(track|album|playlist|episode|show|artist)\/([A-Za-z0-9]+)(?:[?#]|$)/, src: (m) => `https://open.spotify.com/embed/${m[1]}/${m[2]}` },
|
|
19
|
+
{ provider: "soundcloud", title: "SoundCloud", height: 166, match: /^https:\/\/(?:www\.)?soundcloud\.com\/[\w-]+\/[\w-]+(?:\/sets\/[\w-]+)?(?:[?#]|$)/,
|
|
20
|
+
src: (_m, u) => `https://w.soundcloud.com/player/?url=${enc(u.origin + u.pathname)}&visual=false` },
|
|
21
|
+
{ provider: "tiktok", title: "TikTok", aspect: 9 / 16, maxWidth: 340, match: /^https:\/\/(?:www\.)?tiktok\.com\/@[\w.-]+\/video\/(\d+)(?:[?#]|$)/, src: (m) => `https://www.tiktok.com/embed/v2/${m[1]}` },
|
|
22
|
+
{ provider: "x", title: "X (Twitter)", height: 520, maxWidth: 550, match: /^https:\/\/(?:www\.|mobile\.)?(?:twitter|x)\.com\/\w+\/status\/(\d+)(?:[?#/]|$)/,
|
|
23
|
+
src: (m) => `https://platform.twitter.com/embed/Tweet.html?id=${m[1]}&dnt=true` },
|
|
24
|
+
{ provider: "instagram", title: "Instagram", height: 640, maxWidth: 540, match: /^https:\/\/(?:www\.)?instagram\.com\/(p|reel)\/([\w-]+)(?:[?#/]|$)/, src: (m) => `https://www.instagram.com/${m[1]}/${m[2]}/embed` },
|
|
25
|
+
{ provider: "facebook", title: "Facebook", aspect: 16 / 9, match: /^https:\/\/(?:www\.)?facebook\.com\/[\w.-]+\/videos\/(?:[\w.-]+\/)?(\d+)(?:[?#/]|$)/,
|
|
26
|
+
src: (_m, u) => `https://www.facebook.com/plugins/video.php?href=${enc(u.origin + u.pathname)}` },
|
|
27
|
+
{ provider: "codepen", title: "CodePen", height: 420, match: /^https:\/\/codepen\.io\/([\w-]+)\/(?:pen|full|details)\/(\w+)(?:[?#/]|$)/, src: (m) => `https://codepen.io/${m[1]}/embed/${m[2]}?default-tab=result` },
|
|
28
|
+
{ provider: "figma", title: "Figma", aspect: 16 / 9, match: /^https:\/\/(?:www\.)?figma\.com\/(?:file|design|proto|board)\/[\w-]+/,
|
|
29
|
+
src: (_m, u) => `https://www.figma.com/embed?embed_host=cms&url=${enc(u.href)}` },
|
|
30
|
+
{ provider: "googlemaps", title: "Google Maps", aspect: 4 / 3, match: /^https:\/\/(?:www\.)?google\.com\/maps\/embed\?pb=[\w!.%-]+$/, src: (_m, u) => u.href },
|
|
31
|
+
];
|
|
32
|
+
/** Thông tin nhúng của một đường dẫn, hoặc null nếu không thuộc nhà cung cấp nào được hỗ trợ. */
|
|
33
|
+
export function embedInfo(raw) {
|
|
34
|
+
if (typeof raw !== "string" || raw.length > 2048)
|
|
35
|
+
return null;
|
|
36
|
+
let url;
|
|
37
|
+
try {
|
|
38
|
+
url = new UrlCtor(raw.trim().replace(/^http:\/\//i, "https://"));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (url.protocol !== "https:")
|
|
44
|
+
return null;
|
|
45
|
+
const href = url.href;
|
|
46
|
+
for (const r of RULES) {
|
|
47
|
+
const m = href.match(r.match);
|
|
48
|
+
if (!m)
|
|
49
|
+
continue;
|
|
50
|
+
const height = typeof r.height === "function" ? r.height(m) : r.height;
|
|
51
|
+
const info = { provider: r.provider, title: r.title, src: r.src(m, url) };
|
|
52
|
+
if (r.aspect)
|
|
53
|
+
info.aspect = r.aspect;
|
|
54
|
+
if (height)
|
|
55
|
+
info.height = height;
|
|
56
|
+
if (r.maxWidth)
|
|
57
|
+
info.maxWidth = r.maxWidth;
|
|
58
|
+
return info;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/** Nhà cung cấp được hỗ trợ (để hiện gợi ý trong trình soạn / tài liệu). */
|
|
63
|
+
export const EMBED_PROVIDERS = RULES.map((r) => r.title);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { CONTENT_VERSION, type Block, type ContentDoc, type RichMark, type RichNode } from "./types.js";
|
|
2
|
+
export { CORE_MARKS, CORE_NODES, DYNAMIC_NODES, coerceAttr, normalizeAttrs, type AttrSpec, type AttrType, type NodeSpec } from "./schema.js";
|
|
3
|
+
export { normalizeContent, type NormalizeOptions, type NormalizeResult } from "./normalize.js";
|
|
4
|
+
export { migrateContent } from "./migrate.js";
|
|
5
|
+
export { contentText, nodeText, visitNodes, wordCount } from "./text.js";
|
|
6
|
+
export { isSafeUrl, safeUrl } from "./url.js";
|
|
7
|
+
export { BLOCK_VERSION_ATTR, blockDefaults, blockNodeSpec, defineBlock, migrateBlockAttrs, type AttrControl, type BlockAttrSpec, type BlockContent, type BlockDefinition, type BlockDefinitionInput } from "./block.js";
|
|
8
|
+
export { EMBED_PROVIDERS, embedInfo, type EmbedInfo } from "./embed.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
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { CONTENT_VERSION } from "./types.js";
|
|
2
|
+
export { CORE_MARKS, CORE_NODES, DYNAMIC_NODES, coerceAttr, normalizeAttrs } from "./schema.js";
|
|
3
|
+
export { normalizeContent } from "./normalize.js";
|
|
4
|
+
export { migrateContent } from "./migrate.js";
|
|
5
|
+
export { contentText, nodeText, visitNodes, wordCount } from "./text.js";
|
|
6
|
+
export { isSafeUrl, safeUrl } from "./url.js";
|
|
7
|
+
export { BLOCK_VERSION_ATTR, blockDefaults, blockNodeSpec, defineBlock, migrateBlockAttrs } from "./block.js";
|
|
8
|
+
export { EMBED_PROVIDERS, embedInfo } from "./embed.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/migrate.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CONTENT_VERSION } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Nâng định dạng nội dung: MIGRATIONS[n] chuyển bản n → n+1. Bài lưu bằng bản cũ được nâng khi đọc,
|
|
4
|
+
* nên đổi cấu trúc không làm vỡ bài đã đăng. Chưa có bản nào cần nâng (định dạng 1).
|
|
5
|
+
*/
|
|
6
|
+
const MIGRATIONS = {};
|
|
7
|
+
export function migrateContent(doc, issues = []) {
|
|
8
|
+
let current = doc;
|
|
9
|
+
if (current.version > CONTENT_VERSION) {
|
|
10
|
+
// Nội dung từ bản CMS mới hơn: giữ nguyên, không đoán
|
|
11
|
+
issues.push(`content version ${current.version} is newer than ${CONTENT_VERSION}`);
|
|
12
|
+
return current;
|
|
13
|
+
}
|
|
14
|
+
while (current.version < CONTENT_VERSION) {
|
|
15
|
+
const step = MIGRATIONS[current.version];
|
|
16
|
+
current = step ? step(current) : { ...current, version: current.version + 1 };
|
|
17
|
+
}
|
|
18
|
+
return current;
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type BlockDefinition } from "./block.js";
|
|
2
|
+
import { type NodeSpec } from "./schema.js";
|
|
3
|
+
import { type ContentDoc } from "./types.js";
|
|
4
|
+
export interface NormalizeOptions {
|
|
5
|
+
/** Độ sâu lồng tối đa của nội dung soạn thảo (chống nội dung độc làm treo máy). Mặc định 64. */
|
|
6
|
+
maxDepth?: number;
|
|
7
|
+
/** Số nút tối đa của một bài. Mặc định 100 000. */
|
|
8
|
+
maxNodes?: number;
|
|
9
|
+
/** Khối của plugin (defineBlock): ép kiểu thuộc tính + nâng phiên bản khối lưu bằng bản cũ. */
|
|
10
|
+
blocks?: readonly BlockDefinition[];
|
|
11
|
+
/** Mô tả thuộc tính của nút ngoài lõi (cấp thấp — khối plugin dùng `blocks`). */
|
|
12
|
+
nodes?: Record<string, NodeSpec>;
|
|
13
|
+
marks?: Record<string, NodeSpec>;
|
|
14
|
+
}
|
|
15
|
+
export interface NormalizeResult {
|
|
16
|
+
doc: ContentDoc;
|
|
17
|
+
/** Những gì đã bị sửa/bỏ — để ghi log, không phải lỗi. */
|
|
18
|
+
issues: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 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"`),
|
|
22
|
+
* 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ạ.
|
|
23
|
+
* Chạy hai lần cho cùng kết quả (idempotent). Máy chủ chạy cùng quy tắc (ContentFormat.cs).
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeContent(input: unknown, options?: NormalizeOptions): NormalizeResult;
|