webcake-storefront-mcp 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -139,8 +139,16 @@ export class WebcakeCmsApi {
139
139
  listPages() {
140
140
  return this.request("GET", `/api/v1/site/${this.siteId}/pages`);
141
141
  }
142
+ /** Create a page. The backend creates the page AND its source in one call, so `source`
143
+ * is REQUIRED and must be a JSON string (stringified here if an object is passed).
144
+ * `slug`/`is_homepage` are NOT applied at create — set them afterwards via updatePage. */
142
145
  createPage(params) {
143
- return this.request("POST", `/api/v1/site/${this.siteId}/page`, { body: params });
146
+ const body = { ...params };
147
+ if (body.source != null && typeof body.source !== "string")
148
+ body.source = JSON.stringify(body.source);
149
+ if (body.source == null)
150
+ body.source = JSON.stringify({ sections: [] });
151
+ return this.request("POST", `/api/v1/site/${this.siteId}/page`, { body });
144
152
  }
145
153
  updatePage(pageId, params) {
146
154
  return this.request("POST", `/api/v1/site/${this.siteId}/${pageId}/update_page`, { body: params });
@@ -169,8 +177,21 @@ export class WebcakeCmsApi {
169
177
  saveSite(params = {}) {
170
178
  return this.request("POST", `/api/v1/site/${this.siteId}/save`, { body: params, timeout: 60000 });
171
179
  }
172
- publishSite(params = {}) {
173
- return this.request("POST", `/api/v1/site/${this.siteId}/publish`, { body: params, timeout: 60000 });
180
+ /** Publish the site live. /publish runs the full "save" pipeline, which OVERWRITES
181
+ * site.settings with the body's `settings` so we send the CURRENT settings (else
182
+ * they'd be nulled, disabling use_store/use_blog/etc.). Other collections default to []. */
183
+ async publishSite(params = {}) {
184
+ let settings = params.settings;
185
+ if (settings === undefined) {
186
+ settings = await this.getSiteSettings().catch(() => ({}));
187
+ }
188
+ // The save pipeline stores site.settings as a JSON STRING — an object body is
189
+ // rejected (422). Stringify unless the caller already passed a string.
190
+ const settingsStr = typeof settings === "string" ? settings : JSON.stringify(settings || {});
191
+ return this.request("POST", `/api/v1/site/${this.siteId}/publish`, {
192
+ body: { global_sources: [], global_sections: [], page_contents: [], ...params, settings: settingsStr },
193
+ timeout: 60000,
194
+ });
174
195
  }
175
196
  uploadImageBase64({ base64, content_type } = {}) {
176
197
  return this.request("POST", `/api/v1/site/${this.siteId}/media/content/b64`, {
@@ -0,0 +1,340 @@
1
+ // Per-element attribute reference, so get_element can teach an AI the MEANINGFUL keys
2
+ // of each BuilderX element (specials/config/events/bindings) — not just the empty
3
+ // factory skeleton (117/132 factories ship empty specials). Sources of truth:
4
+ // - builderx_spa/src/common/data.js (real example nodes)
5
+ // - builderx_spa/src/common/factory.js (per-type factories)
6
+ // - builderx_spa panel/trait components
7
+ // - real published pages mined from the backend DB
8
+ // Only real keys are listed here (no invented attributes).
9
+ import { readFileSync } from "node:fs";
10
+ let OBSERVED = {};
11
+ try {
12
+ OBSERVED = JSON.parse(readFileSync(new URL("./element-keys.generated.json", import.meta.url), "utf8"));
13
+ }
14
+ catch {
15
+ OBSERVED = {};
16
+ }
17
+ // Editor-internal / junk keys to hide from the attribute report.
18
+ const NOISE_KEYS = new Set(["undefined", "locked", "loaded", "isChangeSection"]);
19
+ // Cross-cutting specials available on virtually every element.
20
+ export const SHARED_SPECIALS = {
21
+ custom_class: "CSS class(es) on the element — target it with .your-class in site custom CSS.",
22
+ custom_css: "Raw CSS scoped to this element.",
23
+ element_async: "Mark the element async/lazy (advanced — usually leave unset).",
24
+ event_name_custom: "Custom analytics event name fired on interaction.",
25
+ };
26
+ // Generic layout keys every node's runtime.config may carry (documented in the build
27
+ // guide); listed here so describeAttributes can flag them as 'generic, not element-specific'.
28
+ export const GENERIC_CONFIG = new Set([
29
+ "grid", "columns", "rows", "columnStart", "columnEnd", "rowStart", "rowEnd",
30
+ "constraintX", "constraintY", "heightUnit", "widthUnit", "loaded", "area", "lockCellGrid",
31
+ "isHidden", "position", "isChangeSection", "useStickyLayoutOther", "columnGap", "rowGap",
32
+ // absolute/relative position internals (set by the editor, not authored by hand):
33
+ "absBottom", "absBottomCenterY", "absLeftCenterX", "absRight", "absRightCenterX", "absTopCenterY",
34
+ "relBottom", "relBottomCenterY", "relLeftCenterX", "relRight", "relRightCenterX", "relTop",
35
+ "relTopCenterY", "relLeft", "relWidth", "relHeight", "vhHeight", "vwHeight", "relMaxWidth",
36
+ "leftUnit", "rightUnit", "topUnit", "bottomUnit", "topCenterYUnit", "rightCenterXUnit",
37
+ "widthScreen", "cornerRadiusUnit", "borderEdges", "widthBg", "heightBg", "topBg", "leftBg",
38
+ "rightBg", "bottomBg", "scaleX", "scaleY", "scaleL", "scaleT", "origin", "activeIndex",
39
+ ]);
40
+ // Cross-element key dictionary: meanings for keys that recur across many element types
41
+ // (so even un-curated elements get useful descriptions). specials + config keys mixed.
42
+ export const SHARED_KEY_DICT = {
43
+ // content / generic
44
+ text: "Text / HTML content.",
45
+ tag: "Semantic tag (h1..h6, p).",
46
+ src: "Media URL (hosted).",
47
+ image_alt: "Alt text (SEO / accessibility).",
48
+ typographyTheme: "Apply a site typography preset.",
49
+ text_animation_type: "Text entrance animation.",
50
+ marker: "List marker style (none/dotList/numberList/imageList).",
51
+ tabIndex: "Tab/focus order.",
52
+ shapeType: "Shape kind (rectangle/etc.).",
53
+ // layout / display
54
+ displayStyle: "Visual layout variant of the element.",
55
+ columns: "Number of columns / items per row.",
56
+ responsive: "Responsive sizing mode (autofit/fixed).",
57
+ gap_column: "Horizontal gap between items (px).",
58
+ gap_row: "Vertical gap between items (px).",
59
+ image_ratio: "Image aspect ratio (16/9, 4/3, 1/1, …).",
60
+ img_object_fit: "Image fit: cover/contain/fill/stretch.",
61
+ image_width: "Image width within the card.",
62
+ image_placement: "Image position relative to text (column/row).",
63
+ slideItems: "Visible slides/items in a slider.",
64
+ slideWidth: "Slide/card width (px).",
65
+ item_show: "How many items to display.",
66
+ autoplay: "Auto-advance (boolean).",
67
+ loop: "Loop playback/slides (boolean).",
68
+ showControl: "Show playback/nav controls (boolean).",
69
+ minimum_card_width: "Minimum card width (px, autofit).",
70
+ // links / nav
71
+ link: "Destination URL (custom link).",
72
+ linkType: "Link kind: page|category|blog_category|brand|product_tag|custom.",
73
+ linkPage: "Target page id (linkType=page).",
74
+ linkPageTarget: "Open target for the page link (_self/_blank).",
75
+ linkCategory: "Target product category id.",
76
+ linkBlogCategory: "Target blog category id.",
77
+ linkTarget: "Open target (_self/_blank).",
78
+ categoryId: "Category id this item points to.",
79
+ isCustom: "Use a custom link instead of an internal target (boolean).",
80
+ isSync: "Auto-sync with site pages/categories (boolean).",
81
+ name: "Display label.",
82
+ // commerce
83
+ category_id: "Product category id to source items from.",
84
+ products_per_load: "Products fetched per page/scroll.",
85
+ promotion_id: "Linked promotion id.",
86
+ original_price_position: "Where the compare-at price shows (left/right).",
87
+ show_categories: "Show category labels (boolean).",
88
+ // form (also in FIELD below)
89
+ options: "Choices [{id,name}] for a select/radio/checkbox.",
90
+ field_type: "Underlying field type.",
91
+ };
92
+ // Common form-field specials reused by every input-like element.
93
+ const FIELD = {
94
+ field_name: "Submission key this field maps to (e.g. full_name, email, phone_number, note, address, password).",
95
+ label: "Visible label text.",
96
+ placeholder: "Placeholder text.",
97
+ show_label: "Show the label (boolean).",
98
+ required: "Field is required (boolean).",
99
+ validate: "Enable validation (boolean).",
100
+ };
101
+ export const ELEMENT_ATTRS = {
102
+ // ── content ──
103
+ text: {
104
+ specials: { text: "Rich text / HTML content.", tag: "Semantic tag: h1|h2|h3|h4|h5|h6|p.", typographyTheme: "Apply a site typography preset.", marker: "List marker: none|dotList|numberList|imageList." },
105
+ config: { strokeColor: "Text stroke colour." },
106
+ events: "click, hover (in events[]) — links/actions on text are common.",
107
+ notes: "Set specials.text (HTML) + specials.tag. Style via runtime.style (color, fontSize, fontWeight, textAlign, lineHeight, letterSpacing).",
108
+ },
109
+ "text-dataset": {
110
+ config: {}, events: "none",
111
+ bindings: ["product::product_name", "product::product_price", "cart_item::cart_item_name", "cart_item::cart_item_price", "order_item::product_name", "customer_address::full_name"],
112
+ notes: "Text bound to live data — set bindings[].target to a source::field; no static specials.text.",
113
+ },
114
+ image: {
115
+ specials: { src: "Image URL (hosted).", image_alt: "Alt text (SEO/accessibility)." },
116
+ config: { src: "Image URL (mirror of specials.src).", ratio: "Aspect ratio.", minWidth: "Min width (px).", minHeight: "Min height (px).", sizeThumbnail: "Thumbnail size." },
117
+ events: "click, hover (link/lightbox) — very common.",
118
+ notes: "Put the hosted URL in runtime.config.src (and specials.src). Use search_images/upload_images to get URLs.",
119
+ },
120
+ "image-dataset": {
121
+ events: "none",
122
+ bindings: ["product::product_image", "cart_item::cart_item_image", "order_item::product_image", "customer_address::pdc"],
123
+ notes: "Image bound to live data — set bindings[].target; no static src.",
124
+ },
125
+ video: {
126
+ specials: { src: "Video URL.", thumbnail: "Poster image URL.", time: "Duration label (e.g. '10.0s').", type: "Source type (e.g. store)." },
127
+ events: "none",
128
+ },
129
+ "video-dataset": { events: "none", bindings: ["lesson::lesson_video"], notes: "Video bound to a dataset field." },
130
+ rectangle: {
131
+ specials: { shapeType: "Shape kind.", custom_css: "Raw CSS for the shape." },
132
+ config: { mask: "Mask id/shape.", maskId: "Mask reference." },
133
+ events: "click, hover — often used as a clickable card/background.",
134
+ notes: "Coloured box / divider / background. Style via runtime.style (background, border*, boxShadow).",
135
+ },
136
+ line: { events: "none", notes: "Separator line — style via runtime.style background/border." },
137
+ gallery: { specials: { media: "Array of media (image) objects." }, config: { showThumbnail: "Show thumbnails.", showNavigation: "Show nav arrows.", thumbnailPosition: "none|top|bottom|left|right." }, events: "none" },
138
+ carousel: { config: { slideWidth: "Slide width (px).", slideItems: "Visible slides count." }, events: "none", notes: "Holds slide children." },
139
+ swiper: { config: {}, events: "none", notes: "Swiper carousel — holds slide children." },
140
+ slide: { events: "none", notes: "A single slide inside a carousel/swiper." },
141
+ embed: { specials: { iframe: "Raw HTML / iframe markup." }, events: "none" },
142
+ googlemap: { specials: { iframe: "Google Maps embed iframe string." }, events: "none" },
143
+ countdown: { specials: { time: "Target date/time.", type: "Countdown mode." }, events: "none", notes: "Timer; check the live skeleton for exact specials." },
144
+ breadcrumb: { specials: { options: "Per-context paths (search/product/category/blog/post/default).", icon: "Separator string e.g. '>'." }, events: "none" },
145
+ "list-ordered": { specials: { tableItems: "Column definitions [{name,key,i18nKey}]." }, events: "none" },
146
+ // ── layout ──
147
+ section: {
148
+ specials: { global: "Mark as a global/shared section (reused across pages).", custom_class: "CSS class.", layout: "Nested layout payload (advanced)." },
149
+ events: "none",
150
+ notes: "Top-level band; centred 3-column grid. Children go in the centre column (new_section handles this).",
151
+ },
152
+ container: { specials: { tabIndex: "Tab order.", custom_class: "CSS class.", custom_css: "Raw CSS." }, config: { maxWidth: "Max width.", maxWidthUnit: "Max-width unit.", fixedPosition: "Sticky/fixed positioning.", animation: "Entrance animation." }, events: "click, hover — clickable groups are common.", notes: "Generic grid box; nest children with their own grid." },
153
+ row: { events: "none", notes: "Simple horizontal wrapper." },
154
+ tabs: { specials: { activeTab: "Default active tab index." }, events: "none", notes: "Tabbed container." },
155
+ collapse: { events: "none", notes: "Accordion container (collapse-item children)." },
156
+ "collapse-item": { events: "none", notes: "One accordion panel." },
157
+ "custom-layout": { events: "none", notes: "Free-form layout container." },
158
+ "layout-dataset": { events: "none", bindings: ["product::*"], notes: "Repeating layout bound to a dataset." },
159
+ // ── button ──
160
+ button: { specials: { text: "Button label." }, events: "click — set the action in events[] (open page/link/scroll/popup/add-to-cart...).", notes: "Label in specials.text; behaviour in events[]." },
161
+ "submit-button": { specials: { text: "Button label (e.g. 'Đăng nhập')." }, events: "submit — submits its parent form.", notes: "Place inside a form." },
162
+ "paypal-button": { events: "none", notes: "PayPal payment button (provider-handled)." },
163
+ "button-login-google": { specials: { text: "Label e.g. 'Đăng nhập với Google'." }, events: "click (OAuth)." },
164
+ "button-login-facebook": { specials: { text: "Label e.g. 'Đăng nhập với Facebook'." }, events: "click (OAuth)." },
165
+ "current-location": { specials: { text: "Label.", field_name: "current location.", field_type: "current-location." }, events: "click (geolocation)." },
166
+ // ── form ──
167
+ form: {
168
+ specials: {
169
+ type: "Form behaviour — one of: form_login | form_signup | form_order | form_discount | subscribe | order_tracking | form_booking | form_search_agency | question_form.",
170
+ submit_success: "Action after a successful submit (e.g. show a message / redirect).",
171
+ message: "Success message text.",
172
+ show_message: "Show a success message after submit (boolean).",
173
+ show_message_time: "How long the success message stays (seconds).",
174
+ },
175
+ config: { backgroundInput: "Input background.", labelColor: "Label colour.", placeholderColor: "Placeholder colour.", textPadding: "Input padding (px).", labelMarginBottom: "Gap below label (px)." },
176
+ events: "none",
177
+ notes: "Wrap input fields as children; put a submit-button inside. specials.type selects what the form does on submit.",
178
+ },
179
+ input: { specials: { ...FIELD, type: "Input type (default text)." }, config: { textPadding: "Padding.", backgroundInput: "Background." }, events: "none" },
180
+ email: { specials: { ...FIELD, validate_email: "Email regex." }, events: "none", notes: "field_name usually 'email'." },
181
+ "phone-number": { specials: { ...FIELD }, events: "none", notes: "field_name usually 'phone_number'." },
182
+ "retype-phone-number": { specials: { ...FIELD }, events: "none" },
183
+ password: { specials: { ...FIELD, field_type: "password.", validate_password: "Password regex." }, events: "none", notes: "field_name 'password'." },
184
+ "retype-password": { specials: { ...FIELD, validate_password: "Password regex." }, events: "none" },
185
+ "current-password": { specials: { ...FIELD, validate_password: "Password regex." }, events: "none" },
186
+ "text-area": { specials: { ...FIELD }, events: "none", notes: "Multi-line; field_name often 'note'." },
187
+ select: { specials: { ...FIELD, options: "Choices [{id,name}] (or {type,name,image,active}).", defaultValue: "Selected option id." }, config: { arrowColor: "Dropdown arrow colour.", optionBorderColor: "Option border." }, events: "none" },
188
+ "group-select": { specials: { ...FIELD, options: "Choices [{id,name}]." }, events: "none" },
189
+ checkbox: { specials: { field_name: FIELD.field_name, label: FIELD.label, show_label: FIELD.show_label, required: FIELD.required }, events: "none" },
190
+ "checkbox-group": { specials: { ...FIELD, options: "Choices [{id,name}]." }, config: { gap: "Item spacing.", column: "Columns." }, events: "none" },
191
+ "checkbox-item": { specials: { label: "Option text." }, events: "none" },
192
+ radio: { specials: { field_name: FIELD.field_name, label: FIELD.label, show_label: FIELD.show_label, required: FIELD.required }, events: "none" },
193
+ "radio-group": { specials: { ...FIELD, options: "Choices [{id,name,events?,connects?}]." }, config: { gap: "Item spacing.", column: "Columns." }, events: "none" },
194
+ address: { specials: { field_name: "province_id/district_id/commune_id.", label: FIELD.label, placeholder: FIELD.placeholder, show_label: FIELD.show_label, country: "Country code (e.g. 84).", required_province: "Require province.", required_districts: "Require district.", required_commune: "Require commune." }, events: "none" },
195
+ "detect-address": { specials: { ...FIELD, field_type: "detect_address." }, events: "none" },
196
+ "postal-code": { specials: { ...FIELD }, events: "none" },
197
+ country: { specials: { ...FIELD }, events: "none" },
198
+ "input-file": { specials: { ...FIELD, isTransferImage: "Transfer as image (boolean)." }, events: "none" },
199
+ "input-number": { specials: { ...FIELD }, events: "none" },
200
+ "input-date": { specials: { ...FIELD, type: "date | time." }, events: "none" },
201
+ "input-search": { specials: { field_name: "search.", label: FIELD.label, placeholder: FIELD.placeholder }, events: "none" },
202
+ "otp-input": { specials: { ...FIELD, isOpenModalOtp: "Open OTP modal (boolean)." }, events: "none" },
203
+ "rating-input": { specials: { field_name: FIELD.field_name, label: FIELD.label, show_label: FIELD.show_label }, events: "none" },
204
+ identity: { specials: { ...FIELD }, events: "none", notes: "Email-or-phone login field; field_name 'identity'." },
205
+ switch: { events: "none", notes: "Toggle; style width/height." },
206
+ // ── commerce ──
207
+ "grid-product": {
208
+ specials: { displayStyle: "default | only_image | border.", original_price_position: "left | right." },
209
+ config: { columns: "Products per row.", responsive: "autofit | fixed.", item_show: "How many products to show.", image_ratio: "16/9|3/2|4/3|1/1|9/16|2/3|3/4.", img_object_fit: "cover|contain|fill|stretch.", gap_column: "Column gap.", gap_row: "Row gap.", product_info_alignment: "left|center|right." },
210
+ events: "none",
211
+ bindings: ["product::product_image", "product::product_name", "product::product_price"],
212
+ notes: "Lists store products. Requires a 'store' page / use_store. Configure columns + image_ratio.",
213
+ },
214
+ "slider-product": { specials: { displayStyle: "default|only_image|border.", original_price_position: "left|right." }, config: { slideItems: "Visible products.", slideWidth: "Card width.", image_ratio: "Aspect ratio." }, events: "none", bindings: ["product::product_image", "product::product_name", "product::product_price"] },
215
+ "product-gallery": { events: "none", notes: "Product detail image gallery (product page)." },
216
+ "product-image-carousel": { events: "none", notes: "Product image carousel (product page)." },
217
+ "cart-items": { events: "none", bindings: ["cart_item::cart_item_image", "cart_item::cart_item_name", "cart_item::cart_item_price", "cart_item::cart_item_total_price", "cart_item::cart_item_prod_attr"], notes: "Cart line items (cart page)." },
218
+ "cart-icon": { specials: { svg: "Icon SVG.", itemCount: "Badge count." }, events: "click — opens cart." },
219
+ "quantity-input": { events: "none", notes: "Quantity stepper (product/cart)." },
220
+ attr: { events: "none", notes: "Product attribute selector (variations)." },
221
+ payment: { events: "none", notes: "Payment methods block (checkout)." },
222
+ "delivery-method": { specials: { options: "[{type: delivery|store_pickup, name, active}]." }, events: "none" },
223
+ "order-items": { events: "none", bindings: ["order_item::product_image", "order_item::product_name", "order_item::product_quantity", "order_item::product_attrs", "order_item::items_sum_up_price"], notes: "Order summary lines." },
224
+ "order-history": { events: "none", notes: "Member order history (member page)." },
225
+ coupon: { events: "none", notes: "Coupon / discount code input." },
226
+ "product-overlay": { specials: { type: "discount | custom." }, events: "none", notes: "Badge overlaid on a product card." },
227
+ "product-review": {
228
+ specials: {
229
+ title: "Heading text.", show_heading: "Show the heading (boolean).", reviews_per_page: "Reviews per page.",
230
+ show_avatar: "Show reviewer avatar (boolean).", avatar_size: "Avatar size (px).", reply_avatar_size: "Reply avatar size (px).",
231
+ avatar_border_radius: "Avatar corner radius.", avatar_border_color: "Avatar border colour.", avatar_border_size: "Avatar border width.",
232
+ show_rating_breakdown: "Show the star breakdown (boolean).", show_sorting: "Show sort control (boolean).", show_filter: "Show filter (boolean).",
233
+ show_publish_date: "Show the review date (boolean).", require_email: "Require email to review (boolean).", require_phone_number: "Require phone (boolean).",
234
+ type_view_more: "How more reviews load (paginate/expand).",
235
+ },
236
+ events: "none", notes: "Product reviews block (product page).",
237
+ },
238
+ "number-step": {
239
+ specials: {
240
+ minValue: "Minimum value.", maxValue: "Maximum value.", numberUnit: "Unit label.", count: "Step amount.",
241
+ filter: "Filter mode.", use_button_filter: "Use buttons to filter (boolean).", shortened: "Abbreviate large numbers (boolean).",
242
+ },
243
+ events: "none", notes: "Numeric stepper / counter.",
244
+ },
245
+ "masonry-review": { specials: { view_product_mode: "How linked products display in the review wall." }, events: "none", notes: "Masonry wall of reviews." },
246
+ "flash-sale": { specials: { promotion_advance_id: "Promotion id (or 'none')." }, events: "none" },
247
+ promotions: { events: "none", bindings: ["promotion_item::image", "promotion_item::name", "promotion_item::code", "promotion_item::description", "promotion_item::end_date"] },
248
+ "promotions-short": { events: "none", bindings: ["promotion_item::name", "promotion_item::code"] },
249
+ "grid-category": { config: { columns: "Categories per row." }, events: "none", notes: "Lists product categories. Create categories first (create_product_category)." },
250
+ "slider-category": { config: { columns: "Visible categories." }, events: "none" },
251
+ "customer-address": { events: "none", bindings: ["customer_address::full_name", "customer_address::address", "customer_address::phone_number", "customer_address::pdc", "customer_address::is_default"], notes: "Member saved addresses." },
252
+ wishlist: { specials: { svg: "Icon SVG.", itemCount: "Badge count." }, events: "click." },
253
+ "favorite-icon": { events: "click — toggle favourite." },
254
+ "bonus-items": { specials: { typeBonus: "combo_product | other." }, events: "none" },
255
+ // ── navigation ──
256
+ menu: { specials: { type: "horizontal | vertical | hamburger | anchor.", sync: "Auto-sync items with site pages (boolean)." }, events: "none", notes: "Holds menu-item children." },
257
+ "menu-item": { specials: { name: "Link label.", isCustom: "Custom link (boolean).", linkType: "page | category | blog_category | brand | product_tag | custom.", linkCategory: "Linked category id.", categoryId: "Category id.", syncSubmenu: "Auto-build submenu (boolean)." }, events: "click — navigates.", notes: "Set linkType + the matching id (or a custom href) to point the link." },
258
+ "menu-anchor-item": { specials: { name: "Label.", isCustom: "Custom.", linkType: "Link kind.", categoryId: "Category id." }, events: "click — scrolls to an anchor." },
259
+ submenu: { events: "none", notes: "Dropdown under a menu-item." },
260
+ "menu-droppable": { events: "none" },
261
+ "member-bar": { events: "none", notes: "Logged-in member bar (account links)." },
262
+ "member-dropdown": { events: "none" },
263
+ dropdown: { events: "click/hover — toggles dropdown-content." },
264
+ "dropdown-content": { events: "none" },
265
+ "language-menu": { events: "none", notes: "Language switcher." },
266
+ // ── blog ──
267
+ "post-list": { events: "none", notes: "Lists blog articles. Create blog category + articles first." },
268
+ "slider-post": { config: { slideItems: "Visible posts.", slideWidth: "Card width." }, events: "none" },
269
+ "grid-blog": { specials: { showBlogs: "Categories to show [{...}]." }, config: { columns: "Posts per row." }, events: "none" },
270
+ "slider-blog": { specials: { showBlogs: "Categories to show." }, config: { columns: "Visible posts." }, events: "none" },
271
+ "post-overlay": { specials: { type: "regular | custom." }, events: "none", notes: "Post card overlay (post page)." },
272
+ "blog-overlay": { specials: { type: "regular | custom." }, events: "none" },
273
+ };
274
+ const describe = (type, key, where) => {
275
+ const curated = ELEMENT_ATTRS[type] || {};
276
+ return (curated[where] && curated[where][key]) || SHARED_KEY_DICT[key] || (where === "specials" ? SHARED_SPECIALS[key] : undefined);
277
+ };
278
+ /**
279
+ * Build a COMPLETE attribute report for an element type, merging three sources:
280
+ * - curated tables (ELEMENT_ATTRS) + dictionaries (human meaning),
281
+ * - the full real key surface mined from published pages + factory defaults (OBSERVED),
282
+ * - the live skeleton (factory defaults).
283
+ * Every real key is surfaced; ones we have meaning for are described, the rest are
284
+ * listed by name so the AI knows they exist. No invented keys.
285
+ */
286
+ export function describeAttributes(type, skeleton) {
287
+ const curated = ELEMENT_ATTRS[type] || {};
288
+ const obs = OBSERVED[type] || { specials: [], config: [], style: [], bindings: [], events: false };
289
+ const skSpecials = (skeleton && skeleton.specials) || {};
290
+ const skConfig = (skeleton && ((skeleton.runtime && skeleton.runtime.config) || skeleton.config)) || {};
291
+ // ── specials: every real key, described where known ──
292
+ const specialKeys = new Set([
293
+ ...Object.keys(curated.specials || {}),
294
+ ...obs.specials,
295
+ ...Object.keys(skSpecials),
296
+ ]);
297
+ const specials = {};
298
+ const moreSpecials = [];
299
+ for (const k of [...specialKeys].sort()) {
300
+ if (NOISE_KEYS.has(k) || k in SHARED_SPECIALS)
301
+ continue; // shared ones listed separately
302
+ const d = describe(type, k, "specials");
303
+ if (d)
304
+ specials[k] = d;
305
+ else
306
+ moreSpecials.push(k);
307
+ }
308
+ // ── config: element-specific only (drop generic grid/position internals) ──
309
+ const configKeys = new Set([
310
+ ...Object.keys(curated.config || {}),
311
+ ...obs.config,
312
+ ...Object.keys(skConfig),
313
+ ]);
314
+ const config = {};
315
+ const moreConfig = [];
316
+ for (const k of [...configKeys].sort()) {
317
+ if (NOISE_KEYS.has(k) || GENERIC_CONFIG.has(k))
318
+ continue;
319
+ const d = describe(type, k, "config");
320
+ if (d)
321
+ config[k] = d;
322
+ else
323
+ moreConfig.push(k);
324
+ }
325
+ const style = (obs.style || []).filter((k) => !NOISE_KEYS.has(k)).sort();
326
+ return {
327
+ specials: Object.keys(specials).length ? specials : undefined,
328
+ more_specials: moreSpecials.length ? moreSpecials : undefined, // real but un-annotated
329
+ common_specials: Object.keys(SHARED_SPECIALS), // available on almost every element
330
+ config: Object.keys(config).length ? config : undefined,
331
+ more_config: moreConfig.length ? moreConfig : undefined, // real but un-annotated (mostly visual)
332
+ style: style.length ? style : undefined,
333
+ events: curated.events || (obs.events ? "supports events[] (click/hover/submit actions)" : "none observed"),
334
+ bindings: (curated.bindings && curated.bindings.length ? curated.bindings : obs.bindings) || undefined,
335
+ notes: curated.notes,
336
+ common_specials_note: "custom_class/custom_css/element_async/event_name_custom work on almost every element.",
337
+ layout_hint: "runtime.style holds CSS (width/height/color/fontSize/background/border…); runtime.config holds grid placement (see get_build_guide). On save these expand into bp1..bp4.",
338
+ coverage: obs.seen ? `${obs.seen} real instances mined` : "factory default only",
339
+ };
340
+ }
@@ -5,6 +5,7 @@
5
5
  // create* function, (2) attach curated, human-readable metadata (category + summary)
6
6
  // so an AI agent can understand the palette, and (3) expose helpers the MCP tools use.
7
7
  import * as F from "./factory.js";
8
+ import { describeAttributes } from "./attributes.js";
8
9
  // Probe every factory once with safe default opts to learn the type string it produces
9
10
  // and whether it is a container (has a children array). Calling with {children:[]} is
10
11
  // safe for all 132 factories (verified) — none throw.
@@ -180,7 +181,9 @@ export function getElement(type) {
180
181
  const d = describeType(type);
181
182
  // A live skeleton straight from the factory = authoritative shape for this type.
182
183
  const skeleton = buildElement(type, {});
183
- return { ...d, skeleton };
184
+ // Curated + skeleton-derived attribute reference so the agent knows the meaningful keys.
185
+ const attributes = describeAttributes(type, skeleton);
186
+ return { ...d, attributes, skeleton };
184
187
  }
185
188
  export const ELEMENT_TYPES = ALL_TYPES;
186
189
  export const CONTAINER_TYPE_SET = CONTAINER_TYPES;