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 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
- /** Depth-first walk. Cycle-safe: a malformed document must not hang a save. */
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 child of s.children ?? []) {
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) => {
@@ -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
- return text(await request({
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
- return text(await request({
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
  }
@@ -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 dry-run preview goes through this, so it is the only thing standing
38
- * between a `dry_run` result and a bearer token sitting in a transcript. It keys
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.0",
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",