Store Builder

Xây một block

Ship một block do app bạn sở hữu, để chủ cửa hàng kéo được nó vào trang trong trình sửa, và sau đó nó vẫn tiếp tục render từ app của bạn.

Block là thứ app của bạn đóng góp vào trình sửa trang của chủ cửa hàng: một mảnh trang dựng sẵn, kèm một nhóm nhỏ các nút điều khiển mà chủ cửa hàng được đổi trên đó. Họ kéo nó ra từ bảng phần tử như mọi phần tử khác; nó render trên storefront của họ; và khi bạn ship một bản sửa, mọi cửa hàng đã cài app bạn đều nhận được.

Trang này giả định bạn đã có một app. Nếu chưa, bắt đầu từ đó — một block thuộc về một phiên bản của app, và phiên bản mới là thứ được duyệt.


1. Block là gì, và không phải là gì

Một block là một cây con gồm các phần tử của chính nền tảng, cộng một thanh trait được khai báo.

Nó không phải markup. Bạn không viết HTML, CSS hay template. Bạn mô tả một cây node — flex-block, heading, button, image — với đúng những thuộc tính mà phần tử của chủ cửa hàng có, rồi nền tảng render nó.

Ràng buộc đó định hình mọi thứ bạn dựng ở đây, nên xin nói thẳng vì sao. Nền tảng render mỗi trang hai lần: một lần trong trình duyệt, trên canvas của trình sửa, và một lần bằng Go khi xuất bản. Hai bộ render phải cho ra HTML giống nhau đến từng byte — đó là bất biến cả sản phẩm đứng trên. Markup chỉ một bên biết vẽ sẽ phá vỡ nó. Nên block được diễn đạt bằng vốn từ cả hai bộ render đã có, và cái giá là bạn không tự nghĩ ra phần tử mới được. Thứ bạn nhận lại: block của bạn hành xử y hệt phần tử gốc — theo breakpoint, theme, font của chủ cửa hàng, và vẫn đúng khi bất kỳ thứ nào trong đó đổi.

Nếu block cần làm gì đó — đếm ngược, carousel, gọi fetch — thì đó là một island, ở mục 6.


2. Manifest

Một lệnh PUT thay thế toàn bộ phần đóng góp vào bảng phần tử của phiên bản. Cả tập hợp mới là thứ người duyệt phê duyệt, nên không có route riêng cho từng block: gửi hết, mọi lần.

PUT /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/blocks
Authorization: Bearer <user token của bạn>
Content-Type: application/json

Một manifest đầy đủ và hợp lệ:

{
  "blocks": [
    {
      "key": "loyalty-badge",
      "name": "Loyalty badge",
      "icon": "award",
      "rootId": "wrap",
      "nodes": [
        {
          "id": "wrap",
          "type": "flex-block",
          "children": ["title"],
          "props": { "style": { "gap": "8px" }, "config": {}, "specials": {} }
        },
        {
          "id": "title",
          "type": "heading",
          "props": { "style": {}, "config": {}, "specials": { "text": "Members save 10%" } }
        }
      ],
      "slots": [{ "name": "title", "nodeId": "title" }],
      "traits": [
        {
          "key": "look",
          "label": "Appearance",
          "attributes": [
            { "widget": "text_color", "slot": "title", "target": "style", "writeKey": "color" }
          ]
        }
      ]
    }
  ]
}
trường nó là gì
key id của bạn cho dòng này, slug viết thường. Nó là một nửa của tham chiếu mà trang chủ cửa hàng lưu, nên đã ship thì không đổi.
name thứ chủ cửa hàng đọc trên bảng phần tử.
icon biểu tượng trên bảng phần tử. Tên không tra được lùi về mặc định — trường duy nhất không bị đối chiếu.
rootId node nào được thả xuống. Nêu rõ, không suy đoán.
nodes[] cây con. id là của bạn và chỉ có nghĩa trong block; id trên trang chủ cửa hàng được cấp lúc thả.
nodes[].type một loại phần tử nền tảng render được. Có kiểm tra.
nodes[].props style / config / specials của node, đưa qua nguyên văn.
slots[] đặt tên cho một node bên trong để trait trỏ tới.
traits[] các nút điều khiển của chủ cửa hàng — mục 3.

Giới hạn: 24 block mỗi phiên bản, 1 MiB cho cả tài liệu.

Vốn từ đến từ đâu. type và widget được đối chiếu với thứ trình sửa thật sự đang ship, không phải một danh sách trong tài liệu này. Hai hệ quả: một cái tên hôm nay đúng có thể bị đổi tên hay xoá bởi bản phát hành sau của trình sửa — bản nháp đã lưu được kiểm lại lúc gửi duyệt đúng vì thế; và trang này không liệt kê các loại phần tử, vì danh sách đó sẽ sai trong vòng một bản phát hành. Thông báo lỗi nói bạn sai ở đâu, kèm tên.


3. Thanh trait

Thanh trait là toàn bộ bề mặt chỉnh sửa của chủ cửa hàng trên block. Bạn khai báo gì thì họ đổi được thứ đó; còn lại là của bạn.

Một attribute có bốn phần:

{ "widget": "text_color", "slot": "title", "target": "style", "writeKey": "color" }
  • widget — trình sửa render nút điều khiển nào, đối chiếu với vốn từ widget của trình sửa.
  • slot — sửa node nào, theo tên bạn đặt trong slots[]. Slot dành riêng $root trỏ tới node gốc của block.
  • target — một trong style, config, specials.
  • writeKey — khoá widget đó ghi vào.

Một khai báo mở cả VÙNG TÊN, không phải một khoá. Khai báo { target: "style", writeKey: "color" } trên một slot mở toàn bộ style của node đó cho chủ cửa hàng, không riêng color. Đó là chủ ý: phần lớn widget ghi nhiều hơn một khoá — viền ghi bốn cạnh, đổ bóng ghi độ lệch, độ nhoè và màu — và phân quyền theo khoá làm những widget đó chạy nửa vời. writeKey vẫn bắt buộc vì nó ghi lại nút đó dùng để làm gì và bị đối chiếu với vốn từ. Hệ quả: nếu bạn không muốn chủ cửa hàng đổi bất cứ gì trong style của một node, đừng khai báo attribute style trên node đó — tách thiết kế thành nhiều node hơn rồi khai báo đúng cái bạn định cho.

target chứa
style CSS theo từng breakpoint — màu, kích thước, khoảng cách
config dữ liệu theo từng breakpoint nhưng không phải CSS
specials nội dung và định danh, chỉ ở mức gốc — chữ, thẻ được chọn

4. Chủ cửa hàng đổi được gì và không đổi được gì

Một block đã thả xuống là niêm phong. Chủ cửa hàng không chọn, sửa, di chuyển, nhân bản hay xoá được gì bên trong, và không dán được gì vào. Thứ họ làm được là xoá chính block, và đổi bất cứ gì thanh trait mở ra.

Thiết lập của họ sống sót qua bản cập nhật của bạn. Thay đổi của chủ cửa hàng lưu trên tham chiếu — đúng một node trang họ giữ — đánh khoá theo slot, và áp lại mỗi lần block được dựng. Nên khi bạn ship phiên bản mới, màu họ chọn vẫn là màu họ chọn. Hai hệ quả:

  • thiết lập cho nút bạn gỡ bỏ ở phiên bản sau nằm im — không bị xoá, không được áp lại; thêm nút đó trở lại thì thiết lập quay về cùng nó.
  • trang của họ lưu một tham chiếu tới block, không phải bản sao. Không có bản rẽ nhánh nào của markup nằm ở đâu. Đó là điều làm "ship một bản sửa và mọi site khách hàng đều được sửa" thành thật — và cũng là lý do manifest phải qua duyệt.

5. Phiên bản

Việc duyệt gắn vào một phiên bản, không bao giờ gắn vào app, vì duyệt là ghim chặt một payload: gắn vào app thì bạn đổi được chính thứ người duyệt đã phê.

Khi một phiên bản đã được duyệt:

  • phiên bản cùng bộ scope tự chuyển mọi bản cài sang. Chủ cửa hàng không phải làm gì, không phải đồng ý lại — scope không đổi. Đây là mục đích của cả thiết kế.
  • phiên bản đòi NHIỀU hơn thứ chủ cửa hàng đã cấp thì dừng lại và hỏi, trên màn hình đồng ý của họ.
  • chủ cửa hàng có thể quay về bất kỳ phiên bản đã duyệt nào trước đó, và được cho biết trước những block nào phiên bản đó không có — một lần quay lui làm mất một mảng trên trang đang chạy sẽ nói ra trước.

Ship rồi phát hiện hỏng thì rút về:

POST   /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/withdraw
DELETE /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/withdraw   (khôi phục)

Rút về cố ý bất đối xứng: không ai mới đáp xuống phiên bản đó nữa, và mọi cửa hàng đang chạy nó vẫn chạy tiếp. Giật một block đang sống khỏi trang của chủ cửa hàng tệ hơn căn bệnh. Muốn chuyển họ đi, ship phiên bản đã sửa — họ được chuyển tự động nếu scope khớp.

Khi nào bản cập nhật tới storefront. Ngay lập tức trong trình sửa của chủ cửa hàng. Trên trang đã xuất bản thì vào lần xuất bản kế tiếp — nền tảng render lại mọi trang đang phục vụ bản dựng cũ của block bạn vào lần chủ cửa hàng xuất bản bất cứ thứ gì. Nó không tự xuất bản lại site của họ thay bạn.


6. Island: làm cho một block biết làm việc

Island là một module JavaScript do app bạn ship. Một node trong manifest nêu tên nó, và trên trang đã xuất bản, node đó được hydrate bằng mã của bạn.

Ba bước.

Khai báo cái tên. Chỉ tên — mã đến sau, bằng đường khác.

PUT /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/islands

{ "islands": [{ "name": "countdown" }] }

Trỏ một node vào nó, bằng trường island trên node trong manifest:

{ "id": "wrap", "type": "flex-block", "island": "countdown", "children": ["title"] }

Tải module lên. Body là chính đoạn JavaScript — không multipart, vì đây là một bước build chứ không phải hộp thoại chọn tệp.

POST /api/orgs/{orgId}/apps/{appId}/versions/{versionId}/islands/module?name=countdown
Content-Type: text/javascript

export default class { … }

Module tự đăng ký dưới cái tên nó được cấp:

window.WB.register('app.app_1a2b3c4d.countdown', MyIsland);

Bạn sẽ gửi request đó ở mỗi lần sửa. sb dev theo dõi tệp và gửi lại mỗi lần lưu — vẫn lệnh POST đó, và vẫn phải tải lại trang, vì mã island được phục vụ từ object store của nền tảng chứ không từ máy bạn. sb deploy gửi manifest cùng mọi module, khai báo đi trước.

Cái tên bạn khai báo được gắn vùng tên. Bạn khai báo countdown; trang mang app.<appId của bạn>.countdown. Cả trang có một bảng đăng ký, nên hai app cùng ship một Countdown sẽ âm thầm thay thế nhau — gắn vùng tên khiến điều đó bất khả thi. Hệ quả: tên khai báo không được chứa . (ký tự phân tách), dấu cách hay #; và hãy đăng ký cái tên đã gắn vùng tên, không phải tên trần — app id nằm trên app của bạn.

Chúng tôi lưu trữ module, bạn không trỏ link tới nó. Byte được tải lên cùng phiên bản và nền tảng phục vụ. Khoá lưu trữ là SHA-256 của byte: tải hai lần cùng nội dung cho cùng khoá, hai phiên bản ship cùng module dùng chung một object, và module của phiên bản đã duyệt không bao giờ đổi được — tải lên sau vào một bản nháp không chạm tới nó. Lưu trữ thay vì đi lấy từ URL của bạn, vì URL chỉ là con trỏ: người duyệt sẽ phê một địa chỉ mà nội dung bạn đổi được ngay hôm sau.

Giới hạn: 12 island mỗi phiên bản, 512 KiB mỗi module.

Chúng tôi kiểm được gì. Kích thước, và byte là văn bản. Không kiểm được module có phải JavaScript hay không — JavaScript không có phần đầu tệp nhận diện. Thứ kiểm soát mã bạn làm gì là khâu duyệt, và chỉ nó. Việc duyệt mang lại cho người dùng một hiện vật ổn định và một danh tính nhà phát hành; nó không chứng minh hành vi — một island gọi API của bạn lúc chạy là bình thường. Chủ cửa hàng được cho biết, ngay trên thẻ app ở chợ trước khi cài, rằng app của bạn chạy mã trên cửa hàng của họ.


7. Dữ liệu do app sở hữu: đưa nội dung của bạn vào HTML

Một island chạy sau khi tài liệu đã về, nên không thứ gì nó vẽ ra nằm trong HTML mà máy tìm kiếm đọc. Nếu thứ app bạn bán là nội dung — đánh giá, xếp hạng, thông số, huy hiệu — thì island một mình để nó vô hình với tìm kiếm.

Dữ liệu do app sở hữu khoả lấp chỗ đó. Bạn lưu giá trị trên cửa hàng của chủ cửa hàng; trang của họ ràng buộc vào chúng; server in thẳng vào HTML đã xuất bản. Không island, không markup từ bạn.

Lưu một giá trị

PUT /api/v1/app-data
Authorization: Bearer <token wba_ của app bạn>

{ "key": "rating", "value": "4.6 out of 5" }

Không có app id trong body, và không có trường cho nó. Vùng tên đến từ token, nên bạn không ghi được vào vùng tên của app khác — cùng quy tắc với tên island.

PUT không có id trên đường dẫn: một giá trị được gọi tên bằng chính khoá của nó, nên đặt hai lần cũng là đặt một lần. Không phải đọc trước khi ghi, và thử lại sau timeout không tạo bản trùng.

Cần scope appdata.write lúc cài, và appdata.read để đọc lại bằng GET /api/v1/app-data. Xoá bằng DELETE /api/v1/app-data/{key}.

Hai kiểu chủ sở hữu

chủ sở hữu bạn gửi giá trị nói về
site (mặc định) không gửi gì, hoặc "owner":"site" cả cửa hàng
product "owner":"product","ownerId":"<id sản phẩm>" đúng sản phẩm đó
PUT /api/v1/app-data
{ "owner": "product", "ownerId": "prd_1a2b", "key": "rating", "value": "4.9" }

Chọn product cho bất cứ gì thay đổi theo sản phẩm — phần lớn những gì khiến việc này đáng làm. Một rating ở mức site in cùng con số lên mọi trang sản phẩm, tệ hơn không in. Giá trị product thiếu ownerId bị từ chối, thay vì âm thầm lưu thành mức site ở nơi không ai tìm thấy.

Giới hạn: 500 giá trị cho mỗi app trên mỗi cửa hàng (409 too_many_keys), 8 KiB mỗi giá trị. Giá trị rốt cuộc nằm trong HTML mà người mua tải về, nên sức nặng của nó là sức nặng trang của chủ cửa hàng.

Đọc nó từ một block

Khai báo một binding trên node của block, nguồn app.<key>:

{
  "id": "rating-text",
  "type": "heading",
  "props": {
    "specials": { "text": "No rating yet" },
    "bindings": [{ "source": "app.rating", "field": "specials.text" }]
  }
}

Bạn viết app.rating, không bao giờ app.<appId>.rating. Nền tảng điền app id lúc dựng block, và chính điều đó khiến vùng tên của app khác không với tới được. Giá trị site phân giải ở bất cứ đâu trên trang; giá trị product phân giải trên node đã ràng buộc vào sản phẩm đó.

Khi app của bạn ra đi

Gỡ cài đặt ngừng phân giải dữ liệu ngay lập tức và không xoá gì. Phần tử ràng buộc vào giá trị của bạn lùi về đúng đoạn chữ soạn ban đầu — không phải khoảng trống — nên trang không bao giờ vỡ vì một app rời đi. Cài lại là mọi thứ trở về. Dữ liệu giữ vô thời hạn; chủ cửa hàng có thể chủ động xoá sạch nếu họ muốn. Tắt app cũng vậy: bản cài đã tắt không phân giải gì, bật lại là trở về.

Giới hạn: chủ cửa hàng không ràng buộc dữ liệu của bạn lên phần tử của chính họ được. Cách này chạy cho binding do BLOCK khai báo — trong manifest của bạn. Đừng dựng sản phẩm dựa trên việc chủ cửa hàng tự nối dây; đặt binding vào block.


8. Khi có thứ bị thiếu

Block trước hết là HTML render từ server. Island chỉ thêm hành vi vào markup đã render xong, nên:

  • người mua tắt JavaScript, hoặc module không tải được — block vẫn render đúng, chỉ thiếu hành vi. Một dòng trong console.
  • island đã khai báo nhưng chưa tải module — không gửi duyệt được.
  • chủ cửa hàng gỡ app — mọi block của bạn biến khỏi trang họ ở lần đọc kế tiếp. Họ được cảnh báo trước, kèm những trang và block bị ảnh hưởng.
  • app bị đình chỉ, hoặc phiên bản bị rút về mà họ chưa ở trên đó — như gỡ cài đặt, xét về block; riêng rút về không ảnh hưởng cửa hàng đã chạy phiên bản đó.

Thiết kế cho trường hợp đầu: đặt nội dung vào markup và dùng island để tăng cường, không phải lấp một cái vỏ rỗng.


9. Những lời từ chối bạn sẽ gặp

Mọi lời từ chối là JSON, có code cho máy và một thông báo nói cái gì sai.

Manifest (PUT …/blocks)

code status nghĩa là
unknown_element_type 400 một nodes[].type nền tảng không render
unknown_trait_widget 400 một widget trình sửa không có
unknown_write_key 400 một writeKey không hợp lệ với target đó
invalid_block 400 sai hình dạng: key hỏng, thiếu name, không có rootId
invalid_block_subtree 400 các node không tạo thành một cây từ rootId
unknown_island 409 một node gọi tên island phiên bản không khai báo
duplicate_block_key 409 hai block trùng key
too_many_blocks 409 vượt quá 24
not_draft 409 phiên bản đã rời trạng thái nháp — tạo bản mới
body_too_large 413 vượt quá 1 MiB
block_vocabulary_unavailable 503 server đang không kiểm tên được; không gì được lưu

Island (PUT …/islands, POST …/islands/module)

code status nghĩa là
invalid_island_name 400 không phải slug viết thường, hoặc chứa ., dấu cách hay #
island_name_required 400 lần tải lên không nói là island nào
island_module_not_text 400 rỗng, hoặc không phải UTF-8 hợp lệ
duplicate_island_name 409 hai island trùng tên
too_many_islands 409 vượt quá 12
island_not_found 404 tải lên cho tên phiên bản không khai báo
island_module_too_large 413 vượt quá 512 KiB
image_storage_unavailable 503 bản triển khai này không lưu được tệp tải lên

Gửi duyệt (POST …/submit) chạy lại mọi phép kiểm tra trên phần đã lưu, vì một widget có thể bị đổi tên giữa ngày bạn lưu và ngày bạn gửi. Thêm một mã riêng:

code status nghĩa là
island_no_module 409 một island đã khai báo mà chưa tải gì lên

10. Những giới hạn đã biết

  • Island không chạy trên canvas của trình sửa. Dấu hiệu chỉ phát ra lúc xuất bản, nên chủ cửa hàng thấy markup trong trình sửa còn hành vi thì chỉ trên storefront. Đó là hợp đồng render ở mục 1 hoạt động đúng ý.
  • Việc module hydrate trên trang đã xuất bản là do bạn kiểm thử. Nền tảng kiểm mọi lớp bao quanh — manifest, tải lên, dấu hiệu, địa chỉ — nhưng không chạy một dòng nào của island bên thứ ba. Thử trên một trang đã xuất bản thật trước khi gửi duyệt.
  • key của block không có đánh phiên bản. Đổi tên key ở phiên bản sau không di trú trang của chủ cửa hàng — nó gỡ block khỏi trang họ. Chọn key sống chung được lâu.
  • Một block không chứa được block khác. Node trong manifest tự nhận là tham chiếu tới block sẽ bị tước lời tự nhận đó.

Cập nhật 26/9/2026