sbuilder-mcp 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/CHANGELOG.vi.md +23 -0
- package/dist/catalog/elements.generated.js +569 -0
- package/dist/core/tree.js +26 -1
- package/dist/domains/site/builder.js +68 -4
- package/dist/domains/site/findings.js +58 -0
- package/dist/domains/site/node.js +90 -1
- package/dist/domains/site/review.js +67 -1
- package/dist/domains/site/traps.js +24 -0
- package/dist/domains/site/validate.js +31 -1
- package/dist/smoke.js +29 -0
- package/dist/tools/page.js +61 -4
- package/dist/transport/http.js +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ All notable changes to this project are documented in this file.
|
|
|
6
6
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.2.1] - 2026-09-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_add and sb_move now seed the same content the editor gives an element at drop time: satellite nodes such as an accordion's item skin or a tab's shared button, and seeded subtrees for a list's empty state and for dropdown, select and popover, which render as an empty box without them.
|
|
13
|
+
- sb_page_open reports compose_warnings from the platform, including globalMissing, which means the server could not find a shared section's master and deleted the reference node from the document it handed back — saving from there makes the loss permanent.
|
|
14
|
+
- sb_page_create reports slug_renamed when the requested slug was already taken; the platform silently stores a suffixed slug and answers success, so every link authored to the requested slug would otherwise be dead.
|
|
15
|
+
- sb_publish reports not_published when a page has no saved draft, since the platform skips it and still answers success with whatever else did publish.
|
|
16
|
+
- sb_review reports unlinked_form when a form element names no form, since it composes nothing and publishes as an empty box with no warning from the platform.
|
|
17
|
+
- sb_review reports dead_menu_link when a menu has no entries or an entry has no href, since the renderer reads specials.menuItems and never menuId.
|
|
18
|
+
- sb_review reports extra_repeater_child when a repeater element (such as list-dataset) carries more than one child, since only the first is ever rendered per record.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- sb_add and sb_move now refuse adding or moving a second child into a repeater element, since only the first child renders; a reorder within the same parent is still allowed.
|
|
22
|
+
- sb_duplicate now refuses duplicating into a repeater element for the same reason, and deep-copies satellite nodes (such as an accordion's skin) with the owner's pointer rewritten instead of aiming the copy at the original's satellite.
|
|
23
|
+
- sb_duplicate strips the composition stamps (globalId, globalRef, globalKind, globalRev) from a duplicated node instead of copying them, since two nodes sharing one stamp is refused by the platform on the next save.
|
|
24
|
+
- sb_publish now returns only the fields a caller acts on (pageId, slug, isHomepage) for each published page instead of the platform's full rendered document, html and css for every page the cascade touched.
|
|
25
|
+
- sb_page_create's dry_run preview and live response now redact settings the same way sb_api_call does, since it is a free-form object a caller can pass a credential inside.
|
|
26
|
+
- The internal tree walk used by sb_remove, sb_duplicate and save validation now follows satellite nodes (attached through config, not the child list) in addition to the child list, so removing or duplicating a node also handles its satellites correctly.
|
|
27
|
+
- Save validation now refuses a document where a composition stamp (globalId, overlayId) sits on a node that is not a direct child of ROOT, or is duplicated across two nodes, since the platform refuses both later and reports it as an opaque error.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Reordering a node within the same repeater parent no longer trips the new second-child guard.
|
|
31
|
+
|
|
9
32
|
## [0.2.0] - 2026-09-07
|
|
10
33
|
|
|
11
34
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,29 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
|
|
|
6
6
|
Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
+
## [0.2.1] - 2026-09-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- sb_add và sb_move giờ seed đúng nội dung mà editor gán cho một element lúc kéo-thả: các node vệ tinh như skin của từng mục accordion hay nút dùng chung của tab, và các subtree được seed sẵn cho trạng thái rỗng của list cũng như cho dropdown, select và popover — vốn render thành hộp trống nếu thiếu chúng.
|
|
13
|
+
- sb_page_open giờ báo cáo compose_warnings từ nền tảng, bao gồm globalMissing — nghĩa là server không tìm thấy bản gốc của một shared section và đã xóa node tham chiếu khỏi document trả về; lưu từ đây sẽ khiến việc mất đó thành vĩnh viễn.
|
|
14
|
+
- sb_page_create giờ báo cáo slug_renamed khi slug yêu cầu đã bị chiếm; nền tảng âm thầm lưu một slug có hậu tố khác và trả về thành công, nên mọi link được gán theo slug yêu cầu ban đầu sẽ chết nếu không có cảnh báo này.
|
|
15
|
+
- sb_publish giờ báo cáo not_published khi một trang chưa có draft đã lưu, vì nền tảng bỏ qua trang đó và vẫn trả về thành công kèm những trang khác đã publish được.
|
|
16
|
+
- sb_review giờ báo cáo unlinked_form khi một form element không trỏ tới form nào, vì nó không compose được gì và publish thành một hộp trống mà nền tảng không hề cảnh báo.
|
|
17
|
+
- sb_review giờ báo cáo dead_menu_link khi một menu không có entry nào hoặc một entry không có href, vì renderer chỉ đọc specials.menuItems chứ không đọc menuId.
|
|
18
|
+
- sb_review giờ báo cáo extra_repeater_child khi một repeater element (như list-dataset) có nhiều hơn một child, vì chỉ child đầu tiên được render cho mỗi record.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- sb_add và sb_move giờ từ chối thêm hoặc di chuyển child thứ hai vào một repeater element, vì chỉ child đầu tiên được render; việc sắp xếp lại thứ tự trong cùng một parent vẫn được cho phép.
|
|
22
|
+
- sb_duplicate giờ cũng từ chối nhân bản vào một repeater element vì lý do tương tự, và deep-copy các node vệ tinh (như skin của accordion) kèm việc ghi lại con trỏ về đúng owner mới, thay vì để bản sao trỏ vào vệ tinh của bản gốc.
|
|
23
|
+
- sb_duplicate giờ loại bỏ các stamp composition (globalId, globalRef, globalKind, globalRev) khỏi node được nhân bản thay vì sao chép nguyên trạng, vì hai node dùng chung một stamp sẽ bị nền tảng từ chối ở lần lưu kế tiếp.
|
|
24
|
+
- sb_publish giờ chỉ trả về các trường mà caller thực sự dùng đến (pageId, slug, isHomepage) cho mỗi trang đã publish, thay vì toàn bộ document, html và css đã render của mọi trang mà cascade chạm tới.
|
|
25
|
+
- Bản xem trước dry_run và phản hồi thật của sb_page_create giờ redact settings giống cách sb_api_call làm, vì đây là một object tự do mà caller có thể lỡ truyền credential vào.
|
|
26
|
+
- Lượt duyệt cây nội bộ dùng bởi sb_remove, sb_duplicate và kiểm tra khi lưu giờ đi theo cả các node vệ tinh (gắn qua config, không qua child list) bên cạnh child list, nên việc xóa hoặc nhân bản một node giờ xử lý đúng cả các vệ tinh của nó.
|
|
27
|
+
- Kiểm tra khi lưu giờ từ chối một document có stamp composition (globalId, overlayId) nằm trên node không phải con trực tiếp của ROOT, hoặc bị lặp trên hai node, vì nền tảng cũng từ chối cả hai trường hợp này nhưng báo lỗi mơ hồ ở bước sau.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Sắp xếp lại một node trong cùng parent của repeater không còn bị chặn nhầm bởi guard child thứ hai mới thêm.
|
|
31
|
+
|
|
9
32
|
## [0.2.0] - 2026-09-07
|
|
10
33
|
|
|
11
34
|
### Added
|
|
@@ -32969,3 +32969,572 @@ export const TRAIT_WRITES = {
|
|
|
32969
32969
|
]
|
|
32970
32970
|
}
|
|
32971
32971
|
};
|
|
32972
|
+
export const SATELLITE_RULES = {
|
|
32973
|
+
"tab": [
|
|
32974
|
+
{
|
|
32975
|
+
"type": "tab-item",
|
|
32976
|
+
"configKey": "tabItemId"
|
|
32977
|
+
}
|
|
32978
|
+
],
|
|
32979
|
+
"accordion": [
|
|
32980
|
+
{
|
|
32981
|
+
"type": "accordion-item",
|
|
32982
|
+
"configKey": "accordionItemId"
|
|
32983
|
+
}
|
|
32984
|
+
],
|
|
32985
|
+
"menu": [
|
|
32986
|
+
{
|
|
32987
|
+
"type": "menu-item",
|
|
32988
|
+
"configKey": "menuItemId"
|
|
32989
|
+
},
|
|
32990
|
+
{
|
|
32991
|
+
"type": "menu-dropdown",
|
|
32992
|
+
"configKey": "menuDropdownId"
|
|
32993
|
+
}
|
|
32994
|
+
],
|
|
32995
|
+
"cart-order": [
|
|
32996
|
+
{
|
|
32997
|
+
"type": "list-empty",
|
|
32998
|
+
"configKey": "emptyStateId",
|
|
32999
|
+
"seed": {
|
|
33000
|
+
"type": "list-empty",
|
|
33001
|
+
"children": [
|
|
33002
|
+
{
|
|
33003
|
+
"type": "icon",
|
|
33004
|
+
"style": {
|
|
33005
|
+
"color": "#d4d4d4"
|
|
33006
|
+
},
|
|
33007
|
+
"config": {
|
|
33008
|
+
"iconSize": 32
|
|
33009
|
+
},
|
|
33010
|
+
"specials": {
|
|
33011
|
+
"name": "ShoppingCartLine"
|
|
33012
|
+
}
|
|
33013
|
+
},
|
|
33014
|
+
{
|
|
33015
|
+
"type": "heading",
|
|
33016
|
+
"style": {
|
|
33017
|
+
"width": "auto",
|
|
33018
|
+
"fontSize": "20px",
|
|
33019
|
+
"fontWeight": "600",
|
|
33020
|
+
"color": "#171717",
|
|
33021
|
+
"textAlign": "center"
|
|
33022
|
+
},
|
|
33023
|
+
"specials": {
|
|
33024
|
+
"htmlTag": "h3",
|
|
33025
|
+
"text": "Your cart is empty"
|
|
33026
|
+
}
|
|
33027
|
+
},
|
|
33028
|
+
{
|
|
33029
|
+
"type": "text",
|
|
33030
|
+
"style": {
|
|
33031
|
+
"width": "auto",
|
|
33032
|
+
"fontSize": "14px",
|
|
33033
|
+
"color": "#737373",
|
|
33034
|
+
"textAlign": "center"
|
|
33035
|
+
},
|
|
33036
|
+
"specials": {
|
|
33037
|
+
"text": "Add something you like and it will show up here."
|
|
33038
|
+
}
|
|
33039
|
+
}
|
|
33040
|
+
]
|
|
33041
|
+
}
|
|
33042
|
+
}
|
|
33043
|
+
],
|
|
33044
|
+
"quantity-dataset": [
|
|
33045
|
+
{
|
|
33046
|
+
"type": "quantity-button",
|
|
33047
|
+
"configKey": "quantityButtonId"
|
|
33048
|
+
},
|
|
33049
|
+
{
|
|
33050
|
+
"type": "quantity-input",
|
|
33051
|
+
"configKey": "quantityInputId"
|
|
33052
|
+
}
|
|
33053
|
+
],
|
|
33054
|
+
"product-variants": [
|
|
33055
|
+
{
|
|
33056
|
+
"type": "product-variant-label",
|
|
33057
|
+
"configKey": "variantLabelId"
|
|
33058
|
+
},
|
|
33059
|
+
{
|
|
33060
|
+
"type": "product-variant-option",
|
|
33061
|
+
"configKey": "variantOptionId"
|
|
33062
|
+
}
|
|
33063
|
+
],
|
|
33064
|
+
"dataset-block": [
|
|
33065
|
+
{
|
|
33066
|
+
"type": "list-empty",
|
|
33067
|
+
"configKey": "emptyStateId",
|
|
33068
|
+
"seed": {
|
|
33069
|
+
"type": "list-empty",
|
|
33070
|
+
"children": [
|
|
33071
|
+
{
|
|
33072
|
+
"type": "icon",
|
|
33073
|
+
"style": {
|
|
33074
|
+
"color": "#d4d4d4"
|
|
33075
|
+
},
|
|
33076
|
+
"config": {
|
|
33077
|
+
"iconSize": 32
|
|
33078
|
+
},
|
|
33079
|
+
"specials": {
|
|
33080
|
+
"name": "ShoppingBag3Line"
|
|
33081
|
+
}
|
|
33082
|
+
},
|
|
33083
|
+
{
|
|
33084
|
+
"type": "heading",
|
|
33085
|
+
"style": {
|
|
33086
|
+
"width": "auto",
|
|
33087
|
+
"fontSize": "20px",
|
|
33088
|
+
"fontWeight": "600",
|
|
33089
|
+
"color": "#171717",
|
|
33090
|
+
"textAlign": "center"
|
|
33091
|
+
},
|
|
33092
|
+
"specials": {
|
|
33093
|
+
"htmlTag": "h3",
|
|
33094
|
+
"text": "This item is unavailable"
|
|
33095
|
+
}
|
|
33096
|
+
},
|
|
33097
|
+
{
|
|
33098
|
+
"type": "text",
|
|
33099
|
+
"style": {
|
|
33100
|
+
"width": "auto",
|
|
33101
|
+
"fontSize": "14px",
|
|
33102
|
+
"color": "#737373",
|
|
33103
|
+
"textAlign": "center"
|
|
33104
|
+
},
|
|
33105
|
+
"specials": {
|
|
33106
|
+
"text": "It may have been removed. Have a look at the rest of the shop."
|
|
33107
|
+
}
|
|
33108
|
+
}
|
|
33109
|
+
]
|
|
33110
|
+
}
|
|
33111
|
+
}
|
|
33112
|
+
],
|
|
33113
|
+
"list-dataset": [
|
|
33114
|
+
{
|
|
33115
|
+
"type": "list-empty",
|
|
33116
|
+
"configKey": "emptyStateId",
|
|
33117
|
+
"seedBySource": {
|
|
33118
|
+
"product": {
|
|
33119
|
+
"type": "list-empty",
|
|
33120
|
+
"children": [
|
|
33121
|
+
{
|
|
33122
|
+
"type": "icon",
|
|
33123
|
+
"style": {
|
|
33124
|
+
"color": "#d4d4d4"
|
|
33125
|
+
},
|
|
33126
|
+
"config": {
|
|
33127
|
+
"iconSize": 32
|
|
33128
|
+
},
|
|
33129
|
+
"specials": {
|
|
33130
|
+
"name": "ShoppingBag3Line"
|
|
33131
|
+
}
|
|
33132
|
+
},
|
|
33133
|
+
{
|
|
33134
|
+
"type": "heading",
|
|
33135
|
+
"style": {
|
|
33136
|
+
"width": "auto",
|
|
33137
|
+
"fontSize": "20px",
|
|
33138
|
+
"fontWeight": "600",
|
|
33139
|
+
"color": "#171717",
|
|
33140
|
+
"textAlign": "center"
|
|
33141
|
+
},
|
|
33142
|
+
"specials": {
|
|
33143
|
+
"htmlTag": "h3",
|
|
33144
|
+
"text": "No products yet"
|
|
33145
|
+
}
|
|
33146
|
+
},
|
|
33147
|
+
{
|
|
33148
|
+
"type": "text",
|
|
33149
|
+
"style": {
|
|
33150
|
+
"width": "auto",
|
|
33151
|
+
"fontSize": "14px",
|
|
33152
|
+
"color": "#737373",
|
|
33153
|
+
"textAlign": "center"
|
|
33154
|
+
},
|
|
33155
|
+
"specials": {
|
|
33156
|
+
"text": "New arrivals will show up here. Check back soon."
|
|
33157
|
+
}
|
|
33158
|
+
}
|
|
33159
|
+
]
|
|
33160
|
+
},
|
|
33161
|
+
"category": {
|
|
33162
|
+
"type": "list-empty",
|
|
33163
|
+
"children": [
|
|
33164
|
+
{
|
|
33165
|
+
"type": "icon",
|
|
33166
|
+
"style": {
|
|
33167
|
+
"color": "#d4d4d4"
|
|
33168
|
+
},
|
|
33169
|
+
"config": {
|
|
33170
|
+
"iconSize": 32
|
|
33171
|
+
},
|
|
33172
|
+
"specials": {
|
|
33173
|
+
"name": "ArchiveLine"
|
|
33174
|
+
}
|
|
33175
|
+
},
|
|
33176
|
+
{
|
|
33177
|
+
"type": "heading",
|
|
33178
|
+
"style": {
|
|
33179
|
+
"width": "auto",
|
|
33180
|
+
"fontSize": "20px",
|
|
33181
|
+
"fontWeight": "600",
|
|
33182
|
+
"color": "#171717",
|
|
33183
|
+
"textAlign": "center"
|
|
33184
|
+
},
|
|
33185
|
+
"specials": {
|
|
33186
|
+
"htmlTag": "h3",
|
|
33187
|
+
"text": "No collections yet"
|
|
33188
|
+
}
|
|
33189
|
+
},
|
|
33190
|
+
{
|
|
33191
|
+
"type": "text",
|
|
33192
|
+
"style": {
|
|
33193
|
+
"width": "auto",
|
|
33194
|
+
"fontSize": "14px",
|
|
33195
|
+
"color": "#737373",
|
|
33196
|
+
"textAlign": "center"
|
|
33197
|
+
},
|
|
33198
|
+
"specials": {
|
|
33199
|
+
"text": "Collections you create will show up here."
|
|
33200
|
+
}
|
|
33201
|
+
}
|
|
33202
|
+
]
|
|
33203
|
+
},
|
|
33204
|
+
"article": {
|
|
33205
|
+
"type": "list-empty",
|
|
33206
|
+
"children": [
|
|
33207
|
+
{
|
|
33208
|
+
"type": "icon",
|
|
33209
|
+
"style": {
|
|
33210
|
+
"color": "#d4d4d4"
|
|
33211
|
+
},
|
|
33212
|
+
"config": {
|
|
33213
|
+
"iconSize": 32
|
|
33214
|
+
},
|
|
33215
|
+
"specials": {
|
|
33216
|
+
"name": "FileListLine"
|
|
33217
|
+
}
|
|
33218
|
+
},
|
|
33219
|
+
{
|
|
33220
|
+
"type": "heading",
|
|
33221
|
+
"style": {
|
|
33222
|
+
"width": "auto",
|
|
33223
|
+
"fontSize": "20px",
|
|
33224
|
+
"fontWeight": "600",
|
|
33225
|
+
"color": "#171717",
|
|
33226
|
+
"textAlign": "center"
|
|
33227
|
+
},
|
|
33228
|
+
"specials": {
|
|
33229
|
+
"htmlTag": "h3",
|
|
33230
|
+
"text": "No posts yet"
|
|
33231
|
+
}
|
|
33232
|
+
},
|
|
33233
|
+
{
|
|
33234
|
+
"type": "text",
|
|
33235
|
+
"style": {
|
|
33236
|
+
"width": "auto",
|
|
33237
|
+
"fontSize": "14px",
|
|
33238
|
+
"color": "#737373",
|
|
33239
|
+
"textAlign": "center"
|
|
33240
|
+
},
|
|
33241
|
+
"specials": {
|
|
33242
|
+
"text": "New stories will show up here. Check back soon."
|
|
33243
|
+
}
|
|
33244
|
+
}
|
|
33245
|
+
]
|
|
33246
|
+
},
|
|
33247
|
+
"blogCategory": {
|
|
33248
|
+
"type": "list-empty",
|
|
33249
|
+
"children": [
|
|
33250
|
+
{
|
|
33251
|
+
"type": "icon",
|
|
33252
|
+
"style": {
|
|
33253
|
+
"color": "#d4d4d4"
|
|
33254
|
+
},
|
|
33255
|
+
"config": {
|
|
33256
|
+
"iconSize": 32
|
|
33257
|
+
},
|
|
33258
|
+
"specials": {
|
|
33259
|
+
"name": "InboxLine"
|
|
33260
|
+
}
|
|
33261
|
+
},
|
|
33262
|
+
{
|
|
33263
|
+
"type": "heading",
|
|
33264
|
+
"style": {
|
|
33265
|
+
"width": "auto",
|
|
33266
|
+
"fontSize": "20px",
|
|
33267
|
+
"fontWeight": "600",
|
|
33268
|
+
"color": "#171717",
|
|
33269
|
+
"textAlign": "center"
|
|
33270
|
+
},
|
|
33271
|
+
"specials": {
|
|
33272
|
+
"htmlTag": "h3",
|
|
33273
|
+
"text": "No topics yet"
|
|
33274
|
+
}
|
|
33275
|
+
},
|
|
33276
|
+
{
|
|
33277
|
+
"type": "text",
|
|
33278
|
+
"style": {
|
|
33279
|
+
"width": "auto",
|
|
33280
|
+
"fontSize": "14px",
|
|
33281
|
+
"color": "#737373",
|
|
33282
|
+
"textAlign": "center"
|
|
33283
|
+
},
|
|
33284
|
+
"specials": {
|
|
33285
|
+
"text": "Topics you create will show up here."
|
|
33286
|
+
}
|
|
33287
|
+
}
|
|
33288
|
+
]
|
|
33289
|
+
},
|
|
33290
|
+
"course": {
|
|
33291
|
+
"type": "list-empty",
|
|
33292
|
+
"children": [
|
|
33293
|
+
{
|
|
33294
|
+
"type": "icon",
|
|
33295
|
+
"style": {
|
|
33296
|
+
"color": "#d4d4d4"
|
|
33297
|
+
},
|
|
33298
|
+
"config": {
|
|
33299
|
+
"iconSize": 32
|
|
33300
|
+
},
|
|
33301
|
+
"specials": {
|
|
33302
|
+
"name": "GraduationCapLine"
|
|
33303
|
+
}
|
|
33304
|
+
},
|
|
33305
|
+
{
|
|
33306
|
+
"type": "heading",
|
|
33307
|
+
"style": {
|
|
33308
|
+
"width": "auto",
|
|
33309
|
+
"fontSize": "20px",
|
|
33310
|
+
"fontWeight": "600",
|
|
33311
|
+
"color": "#171717",
|
|
33312
|
+
"textAlign": "center"
|
|
33313
|
+
},
|
|
33314
|
+
"specials": {
|
|
33315
|
+
"htmlTag": "h3",
|
|
33316
|
+
"text": "No courses yet"
|
|
33317
|
+
}
|
|
33318
|
+
},
|
|
33319
|
+
{
|
|
33320
|
+
"type": "text",
|
|
33321
|
+
"style": {
|
|
33322
|
+
"width": "auto",
|
|
33323
|
+
"fontSize": "14px",
|
|
33324
|
+
"color": "#737373",
|
|
33325
|
+
"textAlign": "center"
|
|
33326
|
+
},
|
|
33327
|
+
"specials": {
|
|
33328
|
+
"text": "Courses you publish will show up here."
|
|
33329
|
+
}
|
|
33330
|
+
}
|
|
33331
|
+
]
|
|
33332
|
+
},
|
|
33333
|
+
"cart": {
|
|
33334
|
+
"type": "list-empty",
|
|
33335
|
+
"children": [
|
|
33336
|
+
{
|
|
33337
|
+
"type": "icon",
|
|
33338
|
+
"style": {
|
|
33339
|
+
"color": "#d4d4d4"
|
|
33340
|
+
},
|
|
33341
|
+
"config": {
|
|
33342
|
+
"iconSize": 32
|
|
33343
|
+
},
|
|
33344
|
+
"specials": {
|
|
33345
|
+
"name": "ShoppingCartLine"
|
|
33346
|
+
}
|
|
33347
|
+
},
|
|
33348
|
+
{
|
|
33349
|
+
"type": "heading",
|
|
33350
|
+
"style": {
|
|
33351
|
+
"width": "auto",
|
|
33352
|
+
"fontSize": "20px",
|
|
33353
|
+
"fontWeight": "600",
|
|
33354
|
+
"color": "#171717",
|
|
33355
|
+
"textAlign": "center"
|
|
33356
|
+
},
|
|
33357
|
+
"specials": {
|
|
33358
|
+
"htmlTag": "h3",
|
|
33359
|
+
"text": "Your cart is empty"
|
|
33360
|
+
}
|
|
33361
|
+
},
|
|
33362
|
+
{
|
|
33363
|
+
"type": "text",
|
|
33364
|
+
"style": {
|
|
33365
|
+
"width": "auto",
|
|
33366
|
+
"fontSize": "14px",
|
|
33367
|
+
"color": "#737373",
|
|
33368
|
+
"textAlign": "center"
|
|
33369
|
+
},
|
|
33370
|
+
"specials": {
|
|
33371
|
+
"text": "Add something you like and it will show up here."
|
|
33372
|
+
}
|
|
33373
|
+
}
|
|
33374
|
+
]
|
|
33375
|
+
},
|
|
33376
|
+
"item": {
|
|
33377
|
+
"type": "list-empty",
|
|
33378
|
+
"children": [
|
|
33379
|
+
{
|
|
33380
|
+
"type": "icon",
|
|
33381
|
+
"style": {
|
|
33382
|
+
"color": "#d4d4d4"
|
|
33383
|
+
},
|
|
33384
|
+
"config": {
|
|
33385
|
+
"iconSize": 32
|
|
33386
|
+
},
|
|
33387
|
+
"specials": {
|
|
33388
|
+
"name": "ShoppingBag3Line"
|
|
33389
|
+
}
|
|
33390
|
+
},
|
|
33391
|
+
{
|
|
33392
|
+
"type": "heading",
|
|
33393
|
+
"style": {
|
|
33394
|
+
"width": "auto",
|
|
33395
|
+
"fontSize": "20px",
|
|
33396
|
+
"fontWeight": "600",
|
|
33397
|
+
"color": "#171717",
|
|
33398
|
+
"textAlign": "center"
|
|
33399
|
+
},
|
|
33400
|
+
"specials": {
|
|
33401
|
+
"htmlTag": "h3",
|
|
33402
|
+
"text": "This item is unavailable"
|
|
33403
|
+
}
|
|
33404
|
+
},
|
|
33405
|
+
{
|
|
33406
|
+
"type": "text",
|
|
33407
|
+
"style": {
|
|
33408
|
+
"width": "auto",
|
|
33409
|
+
"fontSize": "14px",
|
|
33410
|
+
"color": "#737373",
|
|
33411
|
+
"textAlign": "center"
|
|
33412
|
+
},
|
|
33413
|
+
"specials": {
|
|
33414
|
+
"text": "It may have been removed. Have a look at the rest of the shop."
|
|
33415
|
+
}
|
|
33416
|
+
}
|
|
33417
|
+
]
|
|
33418
|
+
}
|
|
33419
|
+
}
|
|
33420
|
+
},
|
|
33421
|
+
{
|
|
33422
|
+
"type": "list-loading",
|
|
33423
|
+
"configKey": "loadingStateId",
|
|
33424
|
+
"optional": true
|
|
33425
|
+
}
|
|
33426
|
+
]
|
|
33427
|
+
};
|
|
33428
|
+
export const ELEMENT_SEEDS = {
|
|
33429
|
+
"dropdown": [
|
|
33430
|
+
{
|
|
33431
|
+
"type": "dropdown-trigger",
|
|
33432
|
+
"children": [
|
|
33433
|
+
{
|
|
33434
|
+
"type": "text",
|
|
33435
|
+
"style": {
|
|
33436
|
+
"width": "fit-content"
|
|
33437
|
+
},
|
|
33438
|
+
"specials": {
|
|
33439
|
+
"text": "Dropdown"
|
|
33440
|
+
}
|
|
33441
|
+
},
|
|
33442
|
+
{
|
|
33443
|
+
"type": "icon",
|
|
33444
|
+
"config": {
|
|
33445
|
+
"iconSize": 16
|
|
33446
|
+
},
|
|
33447
|
+
"specials": {
|
|
33448
|
+
"name": "ArrowDownSLine"
|
|
33449
|
+
}
|
|
33450
|
+
}
|
|
33451
|
+
]
|
|
33452
|
+
},
|
|
33453
|
+
{
|
|
33454
|
+
"type": "dropdown-panel",
|
|
33455
|
+
"children": [
|
|
33456
|
+
{
|
|
33457
|
+
"type": "text",
|
|
33458
|
+
"style": {
|
|
33459
|
+
"padding": "6px 8px"
|
|
33460
|
+
},
|
|
33461
|
+
"specials": {
|
|
33462
|
+
"text": "Option 1"
|
|
33463
|
+
}
|
|
33464
|
+
},
|
|
33465
|
+
{
|
|
33466
|
+
"type": "text",
|
|
33467
|
+
"style": {
|
|
33468
|
+
"padding": "6px 8px"
|
|
33469
|
+
},
|
|
33470
|
+
"specials": {
|
|
33471
|
+
"text": "Option 2"
|
|
33472
|
+
}
|
|
33473
|
+
},
|
|
33474
|
+
{
|
|
33475
|
+
"type": "text",
|
|
33476
|
+
"style": {
|
|
33477
|
+
"padding": "6px 8px"
|
|
33478
|
+
},
|
|
33479
|
+
"specials": {
|
|
33480
|
+
"text": "Option 3"
|
|
33481
|
+
}
|
|
33482
|
+
}
|
|
33483
|
+
]
|
|
33484
|
+
}
|
|
33485
|
+
],
|
|
33486
|
+
"select": [
|
|
33487
|
+
{
|
|
33488
|
+
"type": "dropdown-trigger"
|
|
33489
|
+
},
|
|
33490
|
+
{
|
|
33491
|
+
"type": "dropdown-panel"
|
|
33492
|
+
}
|
|
33493
|
+
],
|
|
33494
|
+
"popover": [
|
|
33495
|
+
{
|
|
33496
|
+
"type": "dropdown-trigger",
|
|
33497
|
+
"children": [
|
|
33498
|
+
{
|
|
33499
|
+
"type": "text",
|
|
33500
|
+
"style": {
|
|
33501
|
+
"width": "fit-content"
|
|
33502
|
+
},
|
|
33503
|
+
"specials": {
|
|
33504
|
+
"text": "Popover"
|
|
33505
|
+
}
|
|
33506
|
+
}
|
|
33507
|
+
]
|
|
33508
|
+
},
|
|
33509
|
+
{
|
|
33510
|
+
"type": "dropdown-panel",
|
|
33511
|
+
"style": {
|
|
33512
|
+
"padding": "12px 16px",
|
|
33513
|
+
"gap": "6px",
|
|
33514
|
+
"minWidth": "240px",
|
|
33515
|
+
"maxWidth": "320px"
|
|
33516
|
+
},
|
|
33517
|
+
"children": [
|
|
33518
|
+
{
|
|
33519
|
+
"type": "heading",
|
|
33520
|
+
"style": {
|
|
33521
|
+
"fontSize": "15px"
|
|
33522
|
+
},
|
|
33523
|
+
"specials": {
|
|
33524
|
+
"htmlTag": "h4",
|
|
33525
|
+
"text": "Tiêu đề"
|
|
33526
|
+
}
|
|
33527
|
+
},
|
|
33528
|
+
{
|
|
33529
|
+
"type": "text",
|
|
33530
|
+
"specials": {
|
|
33531
|
+
"text": "Một câu giải thích ngắn về thứ khách vừa bấm vào."
|
|
33532
|
+
}
|
|
33533
|
+
}
|
|
33534
|
+
]
|
|
33535
|
+
}
|
|
33536
|
+
]
|
|
33537
|
+
};
|
|
33538
|
+
export const FIRST_CHILD_ONLY = [
|
|
33539
|
+
"list-dataset"
|
|
33540
|
+
];
|
package/dist/core/tree.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SATELLITE_RULES } from '../catalog/elements.generated.js';
|
|
1
2
|
/** The specials stamp a composed site overlay carries. */
|
|
2
3
|
export const SPEC_OVERLAY_ID = 'overlayId';
|
|
3
4
|
/** The specials stamps a composed global section carries. */
|
|
@@ -37,7 +38,26 @@ export function isOverlay(doc, id) {
|
|
|
37
38
|
export function pageChildren(doc) {
|
|
38
39
|
return childrenOf(doc, doc.root_node_id).filter((id) => !isOverlay(doc, id));
|
|
39
40
|
}
|
|
40
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Depth-first walk, children AND satellites. Cycle-safe: a malformed document
|
|
43
|
+
* must not hang a save.
|
|
44
|
+
*
|
|
45
|
+
* A satellite is a real node referenced from `config[configKey]` rather than
|
|
46
|
+
* from `data.nodes`, so a walk that follows child lists alone cannot see it. The
|
|
47
|
+
* platform states the requirement in as many words
|
|
48
|
+
* (`server/render/generated/schema_gen.go:245`): "Anything that asks 'what is
|
|
49
|
+
* inside this node?' (subtree collection, copy, delete) must consult this table
|
|
50
|
+
* as well, exactly as the editor's `subtreeIds` does."
|
|
51
|
+
*
|
|
52
|
+
* Satellite-aware is the DEFAULT, and the name stays short, for the same reason
|
|
53
|
+
* `pageChildren` has the short name and `childrenOf` the explicit one: a caller
|
|
54
|
+
* who writes the obvious thing must not be silently wrong. Copying an accordion
|
|
55
|
+
* with the child-only walk gave the copy a pointer to the ORIGINAL's skin.
|
|
56
|
+
*
|
|
57
|
+
* A `configKey` naming a node that is not in the document is skipped rather than
|
|
58
|
+
* reported here — a walk is not a validator, and a trimmed subtree is a real
|
|
59
|
+
* shape the editor handles the same way (`stores/node.ts:408-411`).
|
|
60
|
+
*/
|
|
41
61
|
export function walk(doc, id, visit) {
|
|
42
62
|
const seen = new Set();
|
|
43
63
|
const go = (cur) => {
|
|
@@ -50,6 +70,11 @@ export function walk(doc, id, visit) {
|
|
|
50
70
|
visit(n);
|
|
51
71
|
for (const k of n.data.nodes)
|
|
52
72
|
go(k);
|
|
73
|
+
for (const rule of SATELLITE_RULES[n.data.type] ?? []) {
|
|
74
|
+
const sat = n.config?.[rule.configKey];
|
|
75
|
+
if (typeof sat === 'string' && sat && doc.nodes[sat])
|
|
76
|
+
go(sat);
|
|
77
|
+
}
|
|
53
78
|
};
|
|
54
79
|
go(id);
|
|
55
80
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { isOverlay, subtreeIds, ancestors, appBlockRoot } from '../../core/tree.js';
|
|
2
|
-
import { ELEMENTS } from '../../catalog/elements.generated.js';
|
|
3
|
-
import { createNode } from './node.js';
|
|
1
|
+
import { isOverlay, subtreeIds, ancestors, appBlockRoot, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, SPEC_GLOBAL_REF, } from '../../core/tree.js';
|
|
2
|
+
import { ELEMENTS, ELEMENT_SEEDS, SATELLITE_RULES } from '../../catalog/elements.generated.js';
|
|
3
|
+
import { createNode, mintSatellites } from './node.js';
|
|
4
|
+
import { refuseSecondTemplate } from './traps.js';
|
|
4
5
|
import { genId } from './ids.js';
|
|
5
6
|
/** Append sentinel: splice clamps a too-large index, and `isSyncablePatch`
|
|
6
7
|
* deliberately allows one — an append is a legitimate thing to describe. */
|
|
@@ -78,6 +79,7 @@ export function addSubtree(doc, parentId, spec, index) {
|
|
|
78
79
|
refuseAppBlockParent(doc, parentId, 'adding');
|
|
79
80
|
requireContainer(parent.data.type, parentId);
|
|
80
81
|
requireAllowed(parent.data.type, spec.type);
|
|
82
|
+
refuseSecondTemplate(doc.doc, parentId, 'Adding');
|
|
81
83
|
const patches = [];
|
|
82
84
|
const ids = [];
|
|
83
85
|
const build = (s, parentNodeId) => {
|
|
@@ -89,9 +91,22 @@ export function addSubtree(doc, parentId, spec, index) {
|
|
|
89
91
|
config: s.config,
|
|
90
92
|
specials: s.specials,
|
|
91
93
|
});
|
|
94
|
+
// Before the owner is handed to a patch: minting rewrites its `config`.
|
|
95
|
+
const sats = mintSatellites(n);
|
|
92
96
|
patches.push({ op: 'set', path: ['nodes', n.id], value: n });
|
|
93
97
|
ids.push(n.id);
|
|
94
|
-
for (const
|
|
98
|
+
for (const sat of sats) {
|
|
99
|
+
patches.push({ op: 'set', path: ['nodes', sat.id], value: sat });
|
|
100
|
+
ids.push(sat.id);
|
|
101
|
+
}
|
|
102
|
+
// SEEDED CONTENT, when the caller brought none of its own.
|
|
103
|
+
//
|
|
104
|
+
// `ELEMENT_SEEDS` is the content an element "is not USABLE without": a
|
|
105
|
+
// dropdown with no trigger and no panel is a bare relative box, and a select
|
|
106
|
+
// renders INTO those two nodes and draws an empty box without them. A caller
|
|
107
|
+
// who passed children has expressed an intent and is never overridden.
|
|
108
|
+
const children = s.children?.length ? s.children : (ELEMENT_SEEDS[s.type] ?? []);
|
|
109
|
+
for (const child of children) {
|
|
95
110
|
requireContainer(s.type, n.id);
|
|
96
111
|
requireAllowed(s.type, child.type);
|
|
97
112
|
const childId = build(child, n.id);
|
|
@@ -114,6 +129,14 @@ export function addSubtree(doc, parentId, spec, index) {
|
|
|
114
129
|
* whole subtree, and every page carrying it goes blank. That is not a
|
|
115
130
|
* hypothetical — it took four pages down before this check existed.
|
|
116
131
|
*/
|
|
132
|
+
/**
|
|
133
|
+
* The composition stamps a DUPLICATE must not inherit.
|
|
134
|
+
*
|
|
135
|
+
* `globalRev` is the fence the editor writes against a shared master
|
|
136
|
+
* (`features/globalsections/api.ts:65`); a local copy has no master and so no
|
|
137
|
+
* revision to be stale against.
|
|
138
|
+
*/
|
|
139
|
+
const COPY_STRIPPED_SPECIALS = [SPEC_GLOBAL_ID, SPEC_GLOBAL_REF, SPEC_GLOBAL_KIND, 'globalRev'];
|
|
117
140
|
const COMPOSED_STAMPS = {
|
|
118
141
|
globalId: 'globalRef',
|
|
119
142
|
appBlockId: 'appBlockRef',
|
|
@@ -272,6 +295,11 @@ export function duplicateNode(doc, id) {
|
|
|
272
295
|
if (!parentId || !doc.has(parentId)) {
|
|
273
296
|
throw new Error(`sbuilder: ${id} has no parent to be duplicated beside`);
|
|
274
297
|
}
|
|
298
|
+
// A copy lands BESIDE the original, so duplicating a repeater's template makes
|
|
299
|
+
// the second child the renderer will never draw. sb_add and sb_move already
|
|
300
|
+
// refuse that; duplicate is the likeliest way to reach for it, since
|
|
301
|
+
// "duplicate the card" is the move a designer makes constantly.
|
|
302
|
+
refuseSecondTemplate(doc.doc, parentId, 'Duplicating into');
|
|
275
303
|
const patches = [];
|
|
276
304
|
const ids = [];
|
|
277
305
|
// One pass, parent-first, so a child's `parent` always names an id already
|
|
@@ -281,12 +309,44 @@ export function duplicateNode(doc, id) {
|
|
|
281
309
|
const clone = JSON.parse(JSON.stringify(src));
|
|
282
310
|
clone.id = genId(src.data.type);
|
|
283
311
|
clone.data = { ...clone.data, parent: newParent, nodes: [] };
|
|
312
|
+
// A COPY IS NOT THE SHARED MASTER.
|
|
313
|
+
//
|
|
314
|
+
// The stamps came over verbatim, so duplicating a global header produced two
|
|
315
|
+
// ROOT children carrying one globalId — ErrDuplicateGlobal
|
|
316
|
+
// (server/internal/page/decompose.go:293), refused on a LATER save, by which
|
|
317
|
+
// time the agent has kept editing and reads it as a transport error. Strip
|
|
318
|
+
// rather than refuse: unlike an overlay or an app block, whose copies the
|
|
319
|
+
// SERVER would destroy, a stripped global copy is a perfectly valid
|
|
320
|
+
// document, and it is what a designer means by duplicating a header to make
|
|
321
|
+
// a variant.
|
|
322
|
+
for (const stamp of COPY_STRIPPED_SPECIALS)
|
|
323
|
+
delete clone.specials?.[stamp];
|
|
284
324
|
patches.push({ op: 'set', path: ['nodes', clone.id], value: clone });
|
|
285
325
|
ids.push(clone.id);
|
|
286
326
|
for (const kid of src.data.nodes) {
|
|
287
327
|
const kidId = copy(kid, clone.id);
|
|
288
328
|
patches.push({ op: 'insert', path: ['nodes', clone.id, 'data', 'nodes'], index: APPEND, value: kidId });
|
|
289
329
|
}
|
|
330
|
+
// SATELLITES, deep-copied with the owner's pointer rewritten — the editor's
|
|
331
|
+
// copyNode does exactly this (`editor/src/stores/node.ts:373,403`). Without
|
|
332
|
+
// it the copy pointed at the ORIGINAL's skin: editing one changed both, and
|
|
333
|
+
// removing the original deleted the skin out from under the copy.
|
|
334
|
+
//
|
|
335
|
+
// Written as an explicit patch rather than by mutating `clone` after it has
|
|
336
|
+
// been handed to one, so the emitted patch list says what it does.
|
|
337
|
+
for (const rule of SATELLITE_RULES[src.data.type] ?? []) {
|
|
338
|
+
const satId = src.config?.[rule.configKey];
|
|
339
|
+
const path = ['nodes', clone.id, 'config', rule.configKey];
|
|
340
|
+
if (typeof satId === 'string' && satId && doc.has(satId)) {
|
|
341
|
+
patches.push({ op: 'set', path, value: copy(satId, clone.id) });
|
|
342
|
+
}
|
|
343
|
+
else if (satId !== undefined) {
|
|
344
|
+
// A satellite id that resolves to nothing is a trimmed subtree, which the
|
|
345
|
+
// editor handles the same way (`stores/node.ts:408-411`). Carrying the
|
|
346
|
+
// pointer would aim the copy at a node it does not own.
|
|
347
|
+
patches.push({ op: 'unset', path });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
290
350
|
return clone.id;
|
|
291
351
|
};
|
|
292
352
|
const rootId = copy(id, parentId);
|
|
@@ -317,6 +377,10 @@ export function moveNode(doc, id, newParentId, index) {
|
|
|
317
377
|
}
|
|
318
378
|
requireContainer(newParent.data.type, newParentId);
|
|
319
379
|
requireAllowed(newParent.data.type, n.data.type);
|
|
380
|
+
// Not for a REORDER: a node already in this parent is not a second template,
|
|
381
|
+
// and refusing it would block the one move that is always safe.
|
|
382
|
+
if (n.data.parent !== newParentId)
|
|
383
|
+
refuseSecondTemplate(doc.doc, newParentId, 'Moving');
|
|
320
384
|
const patches = [];
|
|
321
385
|
const oldParentId = n.data.parent;
|
|
322
386
|
if (oldParentId && doc.has(oldParentId)) {
|
|
@@ -25,6 +25,13 @@ export const FIX = {
|
|
|
25
25
|
'sb_remove id "<id>". Setting "<key>" on this one would show the same value in every row.',
|
|
26
26
|
unbound_dataset_element: 'Bind it: sb_bind id "<id>", field "specials.<key>", and the source that names the record ' +
|
|
27
27
|
'field you want (sb_bind refuses an unknown source and lists every valid one).',
|
|
28
|
+
unlinked_form: 'Point it at a real form: sb_set id "<id>", namespace specials, keys ' +
|
|
29
|
+
'{ "formId": "<a form id from sb_api_find \'list forms\'>" }.',
|
|
30
|
+
dead_menu_link: 'Write the entries the renderer actually reads: sb_set id "<id>", namespace specials, keys ' +
|
|
31
|
+
'{ "menuItems": [{ "id": "mi-1", "label": "Shop", "href": "/shop" }] }. Setting menuId alone ' +
|
|
32
|
+
'publishes an empty nav — the Go renderer never reads it.',
|
|
33
|
+
extra_repeater_child: 'Keep one template: sb_remove the extra children of "<id>", or sb_move them out. Design the ' +
|
|
34
|
+
'single remaining child — it is what every record is drawn with.',
|
|
28
35
|
dead_binding_source: `Rebind with sb_bind using one of: ${BINDING_SOURCES.join(', ')}.`,
|
|
29
36
|
// "<key>" here is documentation, not a placeholder: this template is never
|
|
30
37
|
// filled with a key, so the reader sees the form a field must take.
|
|
@@ -58,3 +65,54 @@ export function compactFindings(items) {
|
|
|
58
65
|
});
|
|
59
66
|
return { findings, fixes };
|
|
60
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* What a COMPOSE WARNING means for the document now in hand.
|
|
70
|
+
*
|
|
71
|
+
* These ride on `GET .../source` beside the page. The client typed the field and
|
|
72
|
+
* read it nowhere, which mattered most for the one that is destructive:
|
|
73
|
+
* `globalMissing` means the server could not find the master and DELETED the
|
|
74
|
+
* reference node from the tree it handed back (`compose.go:118,127`), so the
|
|
75
|
+
* page opens with the section already gone and the next save stores that loss
|
|
76
|
+
* permanently — with no error at any step.
|
|
77
|
+
*/
|
|
78
|
+
export const COMPOSE_WARNINGS = {
|
|
79
|
+
globalMissing: 'The shared section is GONE from the tree you just opened — the server could not find its ' +
|
|
80
|
+
'master and removed the reference. Saving from here makes that permanent. Re-add the section, ' +
|
|
81
|
+
'or restore the master, before you save.',
|
|
82
|
+
globalStale: 'A shared section was edited elsewhere while this copy was held; the master write was refused.',
|
|
83
|
+
overlayStale: 'The overlay master was edited elsewhere while this copy was held; that write was refused. ' +
|
|
84
|
+
'The page edits in the same request were kept.',
|
|
85
|
+
appBlockMissing: 'An app block on this page no longer resolves; its subtree composed as nothing.',
|
|
86
|
+
appBlockEdited: 'An edit inside an app block was reduced back to its reference on save, and is stored nowhere.',
|
|
87
|
+
formMissing: 'A form placement names a form that no longer exists, so it publishes as an EMPTY BOX rather ' +
|
|
88
|
+
'than an error.',
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Turn the server's warnings into something a reader can act on.
|
|
92
|
+
*
|
|
93
|
+
* The two id fields are separate on the wire on purpose — they name rows in
|
|
94
|
+
* different tables, and one field holding "an id of whichever kind the code
|
|
95
|
+
* implies" is the shape that makes a client resolve it against the wrong store.
|
|
96
|
+
* An unknown code is passed through rather than dropped: a warning this build
|
|
97
|
+
* has never heard of is still the platform telling the caller something.
|
|
98
|
+
*/
|
|
99
|
+
export function composeWarnings(raw) {
|
|
100
|
+
if (!raw?.length)
|
|
101
|
+
return [];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const w of raw) {
|
|
104
|
+
if (!w || typeof w !== 'object')
|
|
105
|
+
continue;
|
|
106
|
+
const { code, globalId, overlayId, name } = w;
|
|
107
|
+
if (typeof code !== 'string' || !code)
|
|
108
|
+
continue;
|
|
109
|
+
const id = typeof overlayId === 'string' && overlayId ? overlayId : globalId;
|
|
110
|
+
out.push({
|
|
111
|
+
code,
|
|
112
|
+
...(typeof id === 'string' && id ? { id } : {}),
|
|
113
|
+
...(typeof name === 'string' && name ? { name } : {}),
|
|
114
|
+
effect: COMPOSE_WARNINGS[code] ?? 'The platform reported this about the page it composed.',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ELEMENTS } from '../../catalog/elements.generated.js';
|
|
1
|
+
import { ELEMENTS, SATELLITE_RULES } from '../../catalog/elements.generated.js';
|
|
2
2
|
import { genId } from './ids.js';
|
|
3
3
|
/** Structured clone via JSON — the defaults are plain data, and this is what
|
|
4
4
|
* stops two nodes of the same type sharing one nested object. */
|
|
@@ -57,3 +57,92 @@ export function createNode(type, opts = {}) {
|
|
|
57
57
|
bindings: copy(d.bindings ?? []),
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Mint the SATELLITES a freshly created node owns, and point it at them.
|
|
62
|
+
*
|
|
63
|
+
* A satellite is a real node in the document's node map referenced from
|
|
64
|
+
* `config[configKey]` instead of `data.nodes` — a style-holder like the tab's
|
|
65
|
+
* shared button skin. The editor's node store mints them the moment an element
|
|
66
|
+
* is added (`editor/src/element/seeds.ts:5-7`, `stores/node.ts` addDetachedNode)
|
|
67
|
+
* and this server did not, so an accordion it created carried no
|
|
68
|
+
* `accordionItemId` at all. A missing satellite "simply resolves to nothing at
|
|
69
|
+
* assemble time and the renderer takes its degrade path"
|
|
70
|
+
* (`server/render/scope/capture.go:98-105`) — for a repeater that means ghost
|
|
71
|
+
* cards shown to a shopper, which the platform's own meta calls a lie.
|
|
72
|
+
*
|
|
73
|
+
* MUTATES `owner.config`, so call it before the owner is handed to a patch, and
|
|
74
|
+
* only on a node this process just created.
|
|
75
|
+
*
|
|
76
|
+
* An `optional` rule is skipped deliberately: `list-loading` is the one opt-in
|
|
77
|
+
* satellite in the platform, because a list with no loading design shows a
|
|
78
|
+
* silhouette of its own cards, which is the better answer for almost every site.
|
|
79
|
+
* A `configKey` the caller already filled is left alone — an explicit id beats a
|
|
80
|
+
* minted one.
|
|
81
|
+
*/
|
|
82
|
+
export function mintSatellites(owner, seen = new Set()) {
|
|
83
|
+
// A type is expanded once per call chain. No element owns itself today, and a
|
|
84
|
+
// future one that did would otherwise mint until the stack ran out.
|
|
85
|
+
if (seen.has(owner.data.type))
|
|
86
|
+
return [];
|
|
87
|
+
seen.add(owner.data.type);
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const rule of SATELLITE_RULES[owner.data.type] ?? []) {
|
|
90
|
+
if (rule.optional)
|
|
91
|
+
continue;
|
|
92
|
+
const existing = owner.config[rule.configKey];
|
|
93
|
+
if (typeof existing === 'string' && existing)
|
|
94
|
+
continue;
|
|
95
|
+
// A satellite is usually one bare skin node, but the three list-empty
|
|
96
|
+
// owners are born as a SUBTREE — the editor calls addDetachedTree for them,
|
|
97
|
+
// and a bare list-empty is blank space where it shows a glyph, a headline
|
|
98
|
+
// and a line of body.
|
|
99
|
+
const seed = seedFor(rule, owner);
|
|
100
|
+
const born = seed
|
|
101
|
+
? buildFromSeed(seed, owner.id, seen)
|
|
102
|
+
: [createNode(rule.type, { parent: owner.id })];
|
|
103
|
+
owner.config[rule.configKey] = born[0].id;
|
|
104
|
+
out.push(...born, ...(seed ? [] : mintSatellites(born[0], seen)));
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Which seed subtree this satellite is born as, if any.
|
|
110
|
+
*
|
|
111
|
+
* A repeater's empty state says "No products yet" or "No posts yet" depending on
|
|
112
|
+
* what it lists, so the table is keyed by the owner's `config.datasetSource`.
|
|
113
|
+
* An unknown source falls back to `product`, exactly as the editor's `copyFor`
|
|
114
|
+
* does — a list whose source this build does not know still gets a designed
|
|
115
|
+
* empty state rather than a blank one.
|
|
116
|
+
*/
|
|
117
|
+
function seedFor(rule, owner) {
|
|
118
|
+
if (rule.seed)
|
|
119
|
+
return rule.seed;
|
|
120
|
+
if (!rule.seedBySource)
|
|
121
|
+
return undefined;
|
|
122
|
+
const src = owner.config.datasetSource;
|
|
123
|
+
const key = typeof src === 'string' ? src : 'product';
|
|
124
|
+
return rule.seedBySource[key] ?? rule.seedBySource.product;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Mint a whole seeded subtree, ROOT FIRST.
|
|
128
|
+
*
|
|
129
|
+
* Root-first matters to every caller: the owner points `config[configKey]` at
|
|
130
|
+
* `[0]`, and `addSubtree` links a child by the same index. Each node is created
|
|
131
|
+
* through `createNode`, so it still gets its element's own defaults and
|
|
132
|
+
* bindings; the seed carries only what the seed decided.
|
|
133
|
+
*/
|
|
134
|
+
export function buildFromSeed(seed, parent, seen) {
|
|
135
|
+
const n = createNode(seed.type, {
|
|
136
|
+
parent,
|
|
137
|
+
style: seed.style,
|
|
138
|
+
config: seed.config,
|
|
139
|
+
specials: seed.specials,
|
|
140
|
+
});
|
|
141
|
+
const out = [n, ...mintSatellites(n, seen ? new Set(seen) : undefined)];
|
|
142
|
+
for (const child of seed.children ?? []) {
|
|
143
|
+
const kids = buildFromSeed(child, n.id, seen);
|
|
144
|
+
n.data.nodes.push(kids[0].id);
|
|
145
|
+
out.push(...kids);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { childrenOf, isOverlay, pageChildren, appBlockRoot } from '../../core/tree.js';
|
|
2
|
-
import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS } from '../../catalog/elements.generated.js';
|
|
2
|
+
import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY } from '../../catalog/elements.generated.js';
|
|
3
3
|
import { fill } from './findings.js';
|
|
4
4
|
/**
|
|
5
5
|
* Shipped with every non-empty finding list.
|
|
@@ -175,6 +175,72 @@ export function reviewDesign(doc) {
|
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
+
// A FORM NOBODY LINKED publishes as an empty box, and the platform stays
|
|
179
|
+
// deliberately quiet about it: form.go:221 skips an empty formId with the
|
|
180
|
+
// comment that reporting it "would cry wolf on every page mid-edit". True
|
|
181
|
+
// for a human mid-drag, wrong for an agent that has finished — and since the
|
|
182
|
+
// element seeds `formId: ""` and forms compose on the RENDER path only, the
|
|
183
|
+
// canvas looks identical either way. An unlinked form is the DEFAULT.
|
|
184
|
+
if (type === 'form') {
|
|
185
|
+
const formId = (n.specials ?? {}).formId;
|
|
186
|
+
if (typeof formId !== 'string' || formId.trim() === '') {
|
|
187
|
+
out.push({
|
|
188
|
+
code: 'unlinked_form',
|
|
189
|
+
nodeId: id,
|
|
190
|
+
type,
|
|
191
|
+
problem: 'This form names no form, so it composes nothing and publishes as an empty box — ' +
|
|
192
|
+
'and the platform reports no warning for it.',
|
|
193
|
+
key: 'formId',
|
|
194
|
+
fix: fill('unlinked_form', { id, key: 'formId' }),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// A MENU ENTRY WITH NO HREF is a link that goes nowhere. The Go renderer
|
|
199
|
+
// reads specials.menuItems and never menuId, so picking a menu by id
|
|
200
|
+
// publishes an empty nav, and the element's own seed ships one entry
|
|
201
|
+
// ("Home") whose href is "". Resolution from a menu id to real addresses is
|
|
202
|
+
// client-side (editor/src/features/menus/snapshot.ts), so nothing fills it
|
|
203
|
+
// in on the way to publish.
|
|
204
|
+
if (type === 'menu') {
|
|
205
|
+
const items = (n.specials ?? {}).menuItems;
|
|
206
|
+
const rows = Array.isArray(items) ? items : [];
|
|
207
|
+
const dead = rows.filter((r) => {
|
|
208
|
+
const row = (r ?? {});
|
|
209
|
+
const href = row.href;
|
|
210
|
+
const panel = row.panelId;
|
|
211
|
+
return ((typeof href !== 'string' || href.trim() === '') &&
|
|
212
|
+
(typeof panel !== 'string' || panel.trim() === ''));
|
|
213
|
+
});
|
|
214
|
+
if (rows.length === 0 || dead.length > 0) {
|
|
215
|
+
out.push({
|
|
216
|
+
code: 'dead_menu_link',
|
|
217
|
+
nodeId: id,
|
|
218
|
+
type,
|
|
219
|
+
problem: rows.length === 0
|
|
220
|
+
? 'This menu has no entries, so it publishes as an empty nav. The renderer reads ' +
|
|
221
|
+
'specials.menuItems and never menuId.'
|
|
222
|
+
: `${dead.length} of ${rows.length} entries have no href, so those links go ` +
|
|
223
|
+
'nowhere. The renderer reads specials.menuItems and never menuId.',
|
|
224
|
+
key: 'menuItems',
|
|
225
|
+
fix: fill('dead_menu_link', { id, key: 'menuItems' }),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// A REPEATER RENDERS ITS FIRST CHILD AND DROPS THE REST. `templateID`
|
|
230
|
+
// returns Data.Nodes[0], and list-dataset is the only renderer that does —
|
|
231
|
+
// which is why the list is generated rather than derived from the obvious
|
|
232
|
+
// "is a dataset container" predicate, since dataset-block renders all of
|
|
233
|
+
// its children.
|
|
234
|
+
if (FIRST_CHILD_ONLY.includes(type) && n.data.nodes.length > 1) {
|
|
235
|
+
out.push({
|
|
236
|
+
code: 'extra_repeater_child',
|
|
237
|
+
nodeId: id,
|
|
238
|
+
type,
|
|
239
|
+
problem: `"${type}" clones only its FIRST child per record, so the other ` +
|
|
240
|
+
`${n.data.nodes.length - 1} never appear on the published page.`,
|
|
241
|
+
fix: fill('extra_repeater_child', { id }),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
178
244
|
// A dataset element with NO binding at all is the same silence from the other
|
|
179
245
|
// direction: its renderer is waiting for a bound special that nothing writes,
|
|
180
246
|
// so it falls back to whatever the document authored — once, for every record.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FIRST_CHILD_ONLY } from '../../catalog/elements.generated.js';
|
|
1
2
|
import { pageChildren, isOverlay, SPEC_GLOBAL_ID, SPEC_GLOBAL_KIND, } from '../../core/tree.js';
|
|
2
3
|
/**
|
|
3
4
|
* Which band a direct child of ROOT belongs to.
|
|
@@ -95,4 +96,27 @@ export const RESPONSIVE_NOTICE = 'Written per breakpoint, which is the default b
|
|
|
95
96
|
'legitimate too — it is the cascade\'s fallback layer, below every breakpoint slot, and ' +
|
|
96
97
|
'where an element\'s own defaults live. Use base for a value that genuinely should not ' +
|
|
97
98
|
'vary; use a breakpoint for anything a narrower screen should change.';
|
|
99
|
+
/**
|
|
100
|
+
* Refuse a child a repeater would never render.
|
|
101
|
+
*
|
|
102
|
+
* `list-dataset` clones `Data.Nodes[0]` per record and ignores every sibling
|
|
103
|
+
* after it (`server/render/nodes/list-dataset/html.go:60`). A second child is a
|
|
104
|
+
* perfectly valid document: it stores, it publishes, and it simply never appears
|
|
105
|
+
* — so the agent designs a card nobody will ever see and nothing says why.
|
|
106
|
+
*
|
|
107
|
+
* The list is GENERATED from the renderers. `dataset-block` is a dataset
|
|
108
|
+
* container too and renders all of its children, so the obvious
|
|
109
|
+
* `isContainer && category === 'dataset'` predicate would have restricted the
|
|
110
|
+
* wrong element.
|
|
111
|
+
*/
|
|
112
|
+
export function refuseSecondTemplate(doc, parentId, verb) {
|
|
113
|
+
const parent = doc.nodes[parentId];
|
|
114
|
+
if (!parent || !FIRST_CHILD_ONLY.includes(parent.data.type))
|
|
115
|
+
return;
|
|
116
|
+
if (parent.data.nodes.length === 0)
|
|
117
|
+
return;
|
|
118
|
+
throw new Error(`sbuilder: "${parent.data.type}" (${parentId}) renders only its FIRST child, once per ` +
|
|
119
|
+
`record — Data.Nodes[0] is the template. ${verb} a second one stores fine and never ` +
|
|
120
|
+
'appears on the published page. Design the existing template, or sb_remove it first.');
|
|
121
|
+
}
|
|
98
122
|
export { isOverlay };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { subtreeIds } from '../../core/tree.js';
|
|
1
|
+
import { subtreeIds, childrenOf, SPEC_GLOBAL_ID, SPEC_OVERLAY_ID, } from '../../core/tree.js';
|
|
2
2
|
import { checkBandOrder } from './traps.js';
|
|
3
3
|
/**
|
|
4
4
|
* Everything that would make the platform refuse this document on save.
|
|
@@ -16,6 +16,36 @@ export function validateForSave(doc) {
|
|
|
16
16
|
const band = checkBandOrder(d);
|
|
17
17
|
if (band)
|
|
18
18
|
problems.push(band);
|
|
19
|
+
// COMPOSITION STAMPS: only a DIRECT child of ROOT may carry one, and never the
|
|
20
|
+
// same id twice.
|
|
21
|
+
//
|
|
22
|
+
// The platform refuses both — ErrGlobalNested / ErrDuplicateGlobal
|
|
23
|
+
// (server/internal/page/decompose.go:256,293) and their overlay twins
|
|
24
|
+
// (overlay.go:46-55). It refuses them LATE, though: the agent keeps editing
|
|
25
|
+
// and one autosave later gets a bare `duplicate_global`, which reads like a
|
|
26
|
+
// transport error rather than something it did. The overlay pair is worse
|
|
27
|
+
// still — it has no mapped error code and falls through to writeErr's default.
|
|
28
|
+
const rootKids = new Set(childrenOf(d, d.root_node_id));
|
|
29
|
+
for (const stamp of [SPEC_GLOBAL_ID, SPEC_OVERLAY_ID]) {
|
|
30
|
+
const seen = new Map();
|
|
31
|
+
for (const [id, n] of Object.entries(d.nodes)) {
|
|
32
|
+
const value = n.specials?.[stamp];
|
|
33
|
+
if (typeof value !== 'string' || !value)
|
|
34
|
+
continue;
|
|
35
|
+
if (!rootKids.has(id)) {
|
|
36
|
+
problems.push(`Node ${id} carries ${stamp} "${value}" but is not a direct child of ROOT. ` +
|
|
37
|
+
'The platform refuses the save; move it to ROOT or remove the stamp.');
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const first = seen.get(value);
|
|
41
|
+
if (first) {
|
|
42
|
+
problems.push(`Nodes ${first} and ${id} both carry ${stamp} "${value}". One page may reference ` +
|
|
43
|
+
'it only once — remove one, or drop the stamp to make it a plain local section.');
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
seen.set(value, id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
19
49
|
// Dangling child ids: a parent naming a node that is not in the map. The
|
|
20
50
|
// renderer walks children by id, so this is a hole in the rendered page.
|
|
21
51
|
for (const [id, n] of Object.entries(d.nodes)) {
|
package/dist/smoke.js
CHANGED
|
@@ -98,6 +98,35 @@ export async function runSmoke() {
|
|
|
98
98
|
doc.apply(add2(doc, 'rt', { type: 'flex-section', children: [{ type: 'text' }] }).patches);
|
|
99
99
|
const codes = reviewDesign(doc).map((f) => f.code);
|
|
100
100
|
check('an unfilled placeholder IS reported', codes.includes('placeholder_content'));
|
|
101
|
+
// A REPEATER ARRIVES WITH ITS EMPTY STATE.
|
|
102
|
+
//
|
|
103
|
+
// On its OWN document, because a repeater with no card template is a genuinely
|
|
104
|
+
// empty container and would answer the review check below with a finding it is
|
|
105
|
+
// right to make.
|
|
106
|
+
//
|
|
107
|
+
// The satellite hangs off `config.emptyStateId`, not the child list, so every
|
|
108
|
+
// walk that follows children only reports this document as correct while the
|
|
109
|
+
// renderer takes its degrade path and ships ghost cards to a shopper.
|
|
110
|
+
const satDoc = PageDoc.from({
|
|
111
|
+
schema_version: 2,
|
|
112
|
+
root_node_id: 'rt',
|
|
113
|
+
nodes: {
|
|
114
|
+
rt: {
|
|
115
|
+
id: 'rt',
|
|
116
|
+
data: { type: 'root', parent: null, nodes: [], isCanvas: true, hidden: false, custom: {} },
|
|
117
|
+
style: {}, config: {}, specials: {}, responsive: {}, events: [], bindings: [],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const satSection = addSubtree(satDoc, 'rt', { type: 'flex-section' });
|
|
122
|
+
satDoc.apply(satSection.patches);
|
|
123
|
+
const listed = addSubtree(satDoc, satSection.ids[0], { type: 'list-dataset' });
|
|
124
|
+
satDoc.apply(listed.patches);
|
|
125
|
+
const list = satDoc.node(listed.ids[0]);
|
|
126
|
+
const emptyId = list.config.emptyStateId;
|
|
127
|
+
check('a repeater is minted with its empty state', typeof emptyId === 'string' && satDoc.has(emptyId) && satDoc.node(emptyId).data.type === 'list-empty');
|
|
128
|
+
check('the empty state carries its design, not a blank box', satDoc.node(String(emptyId)).data.nodes.length === 3);
|
|
129
|
+
check('a document carrying satellites still stores', validateForSave(satDoc).length === 0);
|
|
101
130
|
console.error('ALL GOOD');
|
|
102
131
|
}
|
|
103
132
|
runSmoke().catch((err) => {
|
package/dist/tools/page.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { composeWarnings } from '../domains/site/findings.js';
|
|
2
3
|
import { text } from '../mcp/response.js';
|
|
3
4
|
import { loadSource, saveSource } from '../transport/pages.js';
|
|
4
5
|
import { PageDoc } from '../domains/site/document.js';
|
|
5
6
|
import { addSubtree, setMany, moveNode, removeNode, duplicateNode, } from '../domains/site/builder.js';
|
|
6
|
-
import { request } from '../transport/http.js';
|
|
7
|
+
import { request, redact } from '../transport/http.js';
|
|
7
8
|
import { siteToken } from './credentialpick.js';
|
|
8
9
|
import { validateForSave } from '../domains/site/validate.js';
|
|
9
10
|
import { reviewDesign, REVIEW_NOTICE } from '../domains/site/review.js';
|
|
@@ -42,6 +43,7 @@ export class PageSession {
|
|
|
42
43
|
pageId = '';
|
|
43
44
|
live = null;
|
|
44
45
|
stale = null;
|
|
46
|
+
warnings = [];
|
|
45
47
|
boxes = [];
|
|
46
48
|
constructor(ctx) {
|
|
47
49
|
this.ctx = ctx;
|
|
@@ -90,8 +92,15 @@ export class PageSession {
|
|
|
90
92
|
this.doc = PageDoc.from(src.document);
|
|
91
93
|
this.siteId = siteId;
|
|
92
94
|
this.pageId = pageId;
|
|
95
|
+
// The platform's own account of what it could not compose. Typed on the
|
|
96
|
+
// response since the transport was written and read by nothing until now.
|
|
97
|
+
this.warnings = composeWarnings(src.warnings);
|
|
93
98
|
return this.doc.outline();
|
|
94
99
|
}
|
|
100
|
+
/** What the server said it could not compose when this page was opened. */
|
|
101
|
+
composeWarnings() {
|
|
102
|
+
return this.warnings;
|
|
103
|
+
}
|
|
95
104
|
current() {
|
|
96
105
|
if (!this.doc)
|
|
97
106
|
throw new Error('sbuilder: no page is open — call sb_page_open first');
|
|
@@ -151,9 +160,11 @@ export function registerPageTools(server, ctx) {
|
|
|
151
160
|
'the renderer finds no root and publishes an EMPTY BODY. The next save from here writes ' +
|
|
152
161
|
'the canonical key and fixes it; publish afterwards.'
|
|
153
162
|
: undefined;
|
|
163
|
+
const warnings = session.composeWarnings();
|
|
154
164
|
return text({
|
|
155
165
|
outline,
|
|
156
166
|
...(blank_page_repair ? { blank_page_repair } : {}),
|
|
167
|
+
...(warnings.length ? { compose_warnings: warnings } : {}),
|
|
157
168
|
...reviewField(ctx, doc),
|
|
158
169
|
});
|
|
159
170
|
});
|
|
@@ -423,9 +434,12 @@ export function registerPageTools(server, ctx) {
|
|
|
423
434
|
...(is_homepage !== undefined ? { isHomepage: is_homepage } : {}),
|
|
424
435
|
...(settings ? { settings } : {}),
|
|
425
436
|
};
|
|
437
|
+
// `settings` is the one free-form object a caller hands this server, so the
|
|
438
|
+
// preview and the echo both go through redact — everything else on this
|
|
439
|
+
// path is built from narrow arguments.
|
|
426
440
|
if (dry_run !== false)
|
|
427
|
-
return text({ dry_run: true, would_post: path, body });
|
|
428
|
-
|
|
441
|
+
return text({ dry_run: true, would_post: path, body: redact(body) });
|
|
442
|
+
const res = redact(await request({
|
|
429
443
|
base: ctx.base,
|
|
430
444
|
method: 'POST',
|
|
431
445
|
path,
|
|
@@ -433,6 +447,24 @@ export function registerPageTools(server, ctx) {
|
|
|
433
447
|
body,
|
|
434
448
|
fetchImpl: ctx.fetchImpl,
|
|
435
449
|
}));
|
|
450
|
+
// A COLLIDING SLUG IS RENAMED, NOT REFUSED. `uniqueSlug` suffixes -1, -2 …
|
|
451
|
+
// and its own comment says it "never errors"
|
|
452
|
+
// (server/internal/page/service.go:877). ErrSlugConflict exists and maps to
|
|
453
|
+
// 409; this path never reaches it. So the create answers 200 carrying a
|
|
454
|
+
// DIFFERENT slug than the one asked for, and every link the caller then
|
|
455
|
+
// authors to the slug it requested is dead.
|
|
456
|
+
const got = res.page?.slug;
|
|
457
|
+
const renamed = slug && typeof got === 'string' && got !== slug;
|
|
458
|
+
return text({
|
|
459
|
+
...res,
|
|
460
|
+
...(renamed
|
|
461
|
+
? {
|
|
462
|
+
slug_renamed: `The slug "${slug}" was already taken, so the platform stored ` +
|
|
463
|
+
`"${got}" instead and reported success. Link to "${got}", or free the name and ` +
|
|
464
|
+
'create it again.',
|
|
465
|
+
}
|
|
466
|
+
: {}),
|
|
467
|
+
});
|
|
436
468
|
});
|
|
437
469
|
server.registerTool('sb_publish', {
|
|
438
470
|
description: 'Compile the draft into the live page. PUBLISH CASCADES: a page sharing a global ' +
|
|
@@ -451,7 +483,7 @@ export function registerPageTools(server, ctx) {
|
|
|
451
483
|
const body = { pageIds: [page_id] };
|
|
452
484
|
if (dry_run !== false)
|
|
453
485
|
return text({ dry_run: true, would_post: path, body });
|
|
454
|
-
|
|
486
|
+
const res = (await request({
|
|
455
487
|
base: ctx.base,
|
|
456
488
|
method: 'POST',
|
|
457
489
|
path,
|
|
@@ -459,6 +491,31 @@ export function registerPageTools(server, ctx) {
|
|
|
459
491
|
body,
|
|
460
492
|
fetchImpl: ctx.fetchImpl,
|
|
461
493
|
}));
|
|
494
|
+
// A PUBLISHED ROW CARRIES THE WHOLE RENDERED PAGE — document, html and css
|
|
495
|
+
// — and publish CASCADES, so returning the response as it arrives pours
|
|
496
|
+
// every republished page's markup into the reader. Kept: what identifies
|
|
497
|
+
// the row and what a caller would act on.
|
|
498
|
+
const published = (res.published ?? []).map((p) => ({
|
|
499
|
+
pageId: p.pageId,
|
|
500
|
+
...(p.slug !== undefined ? { slug: p.slug } : {}),
|
|
501
|
+
...(p.isHomepage ? { isHomepage: true } : {}),
|
|
502
|
+
}));
|
|
503
|
+
// PUBLISH SKIPS A PAGE WITH NO SAVED DRAFT and still answers 200 with
|
|
504
|
+
// whatever did publish (`server/internal/page/service.go:650`, a bare
|
|
505
|
+
// `continue`). sb_page_create followed by sb_publish does exactly that:
|
|
506
|
+
// the call succeeds, the page never flips to published, and the URL 404s.
|
|
507
|
+
const landed = published.some((p) => p.pageId === page_id);
|
|
508
|
+
return text({
|
|
509
|
+
published,
|
|
510
|
+
...(published.length !== (res.total ?? published.length) ? { total: res.total } : {}),
|
|
511
|
+
...(landed
|
|
512
|
+
? {}
|
|
513
|
+
: {
|
|
514
|
+
not_published: `Page ${page_id} has no saved draft, so the platform published ` +
|
|
515
|
+
'nothing for it and reported success anyway. Open it with sb_page_open, save an ' +
|
|
516
|
+
'edit, then publish again.',
|
|
517
|
+
}),
|
|
518
|
+
});
|
|
462
519
|
});
|
|
463
520
|
return session;
|
|
464
521
|
}
|
package/dist/transport/http.js
CHANGED
|
@@ -34,8 +34,10 @@ const SECRET_KEYS = /^(authorization|token|access_?token|refresh_?token|password
|
|
|
34
34
|
/**
|
|
35
35
|
* Replace credential-shaped values with a marker, recursively.
|
|
36
36
|
*
|
|
37
|
-
* Every
|
|
38
|
-
*
|
|
37
|
+
* Every preview that can carry a FREE-FORM object goes through this — `sb_api_call`'s
|
|
38
|
+
* body and `sb_page_create`'s `settings`, the two places a caller supplies a shape
|
|
39
|
+
* this server does not type. The rest of the dry-run previews echo patch counts or
|
|
40
|
+
* bodies built from narrow arguments, which cannot hold a credential. It keys
|
|
39
41
|
* off the FIELD NAME rather than the value's shape on purpose: a token format
|
|
40
42
|
* can change tomorrow, while the field name is what this repo controls.
|
|
41
43
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/sbuilder-mcp",
|
|
6
6
|
"type": "module",
|