sbuilder-mcp 0.29.0 → 0.30.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 +22 -0
- package/CHANGELOG.vi.md +22 -0
- package/README.md +1 -0
- package/README.vi.md +1 -0
- package/dist/catalog/api.generated.js +22 -6
- package/dist/catalog/element-search.js +7 -1
- package/dist/catalog/elements.generated.js +34 -0
- package/dist/domains/site/vocabulary.js +88 -1
- package/dist/server.js +2 -0
- package/dist/tools/importpage.js +7 -1
- package/dist/tools/page.js +8 -3
- package/dist/tools/theme.js +136 -0
- package/dist/vision/capture.js +311 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@ 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.30.1] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- sb_import and sb_import_site's default `max_images` and `max_nodes` rise from 24/300 to 60/900, so a real shop homepage's full catalogue is no longer cut off mid-band.
|
|
13
|
+
- A flex or grid container that wraps is now capped at 60 columns per row instead of 12, so a wide product shelf is captured as its own row instead of being coerced into a vertical stack.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- sb_import and sb_import_site no longer read images from `src` alone: a lazy-loaded `data-src`/`data-original`/`data-lazy-src`/`srcset` (widest candidate) is now read as the real photo, so a lazy-loaded product gallery is no longer imported as a handful of pictures with the rest reported as missing.
|
|
17
|
+
- Content hidden inside a tab panel, carousel slide or filtered grid (`display:none` beside a visible sibling of the same kind) is now revealed and captured, so a homepage that lays its catalogue out in tabs no longer loses every tab but the one shown at load.
|
|
18
|
+
- `<button>` elements are no longer skipped as form controls: a button with words in it is now captured as a call-to-action or link the same way an `<a>` is, so "Add to cart"-style buttons are no longer dropped from the import.
|
|
19
|
+
- The section-candidate fallback now fires whenever the chosen sections cover under half of the page's non-chrome text, not only when they capture nothing at all, so a page whose only `<section>` elements are a breadcrumb no longer imports as that breadcrumb alone.
|
|
20
|
+
|
|
21
|
+
## [0.30.0] - 2026-09-10
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- A new tool, sb_theme, reads the site's colour tokens and text styles and can now write them: `colors` and `text_styles` patch the saved theme document, keeping every field you do not name, and an unknown token id or style slug is refused with the real ones listed. A site that has never saved a theme reads back the starter theme rather than an empty document.
|
|
25
|
+
- sb_traits_for now describes `config.animation` on every element that offers it (73 of 111), naming the four ways a write silently renders nothing: it must be an object rather than a bare string, `active: true` is required, the `type` value is spelled with underscores, and the write is base-only.
|
|
26
|
+
- sb_set now warns when a `config.animation` write will not animate: a value that is not an object, a missing `active: true`, an unrecognised `type`, or an unrecognised `easing` (which falls back to a curve you did not choose) are each named individually.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- sb_set now routes a `config.animation` write to the base breakpoint instead of a per-breakpoint slot the renderer never reads, closing the fourth silent way to lose the animation.
|
|
30
|
+
|
|
9
31
|
## [0.29.0] - 2026-09-10
|
|
10
32
|
|
|
11
33
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,28 @@ 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.30.1] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Giá trị mặc định `max_images` và `max_nodes` của sb_import và sb_import_site tăng từ 24/300 lên 60/900, nên toàn bộ catalogue trên trang chủ một shop thật không còn bị cắt cụt giữa chừng một band.
|
|
13
|
+
- Một container flex hoặc grid có wrap giờ được giới hạn 60 cột mỗi hàng thay vì 12, nên một kệ sản phẩm rộng được chụp lại đúng thành một hàng thay vì bị ép thành một cột dọc.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- sb_import và sb_import_site không còn chỉ đọc ảnh từ `src`: một ảnh lazy-load qua `data-src`/`data-original`/`data-lazy-src`/`srcset` (chọn ứng viên rộng nhất) giờ được đọc như ảnh thật, nên một thư viện ảnh sản phẩm lazy-load không còn bị nhập vào với vài tấm còn lại bị báo là thiếu.
|
|
17
|
+
- Nội dung ẩn trong một tab panel, slide carousel hay lưới lọc (`display:none` cạnh một anh em cùng loại đang hiển thị) giờ được hiện ra và chụp lại, nên một trang chủ trình bày catalogue theo dạng tab không còn mất hết các tab trừ tab đang mở lúc tải.
|
|
18
|
+
- Phần tử `<button>` không còn bị bỏ qua như một control của form: một button có chữ bên trong giờ được chụp lại như một nút kêu gọi hành động hoặc liên kết, giống cách một `<a>` được xử lý, nên các nút kiểu "Thêm vào giỏ" không còn bị rớt khỏi bản nhập.
|
|
19
|
+
- Cơ chế dự phòng chọn section giờ kích hoạt bất cứ khi nào các section được chọn chỉ phủ dưới một nửa nội dung chữ không phải chrome của trang, chứ không chỉ khi chúng không chụp được gì cả, nên một trang mà các phần tử `<section>` duy nhất là một breadcrumb sẽ không còn bị nhập vào chỉ như breadcrumb đó.
|
|
20
|
+
|
|
21
|
+
## [0.30.0] - 2026-09-10
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- Một tool mới, sb_theme, đọc bảng màu và text style của site và giờ có thể ghi chúng: `colors` và `text_styles` patch vào theme document đã lưu, giữ nguyên mọi trường không được nêu tên, còn một token id hay style slug không tồn tại sẽ bị từ chối kèm danh sách các id thật. Một site chưa từng lưu theme sẽ đọc lại theme mặc định (starter) thay vì một document rỗng.
|
|
25
|
+
- sb_traits_for giờ mô tả `config.animation` trên mọi element có cung cấp nó (73 trong 111 element), nêu rõ bốn cách khiến việc ghi âm thầm không tạo ra hiệu ứng nào: nó phải là một object thay vì một chuỗi đơn thuần, `active: true` là bắt buộc, giá trị `type` được viết bằng dấu gạch dưới, và việc ghi chỉ áp dụng ở base breakpoint.
|
|
26
|
+
- sb_set giờ cảnh báo khi một lệnh ghi `config.animation` sẽ không tạo hiệu ứng: giá trị không phải object, thiếu `active: true`, `type` không nhận diện được, hoặc `easing` không nhận diện được (sẽ rơi về một đường cong bạn không hề chọn) — mỗi trường hợp được nêu tên riêng.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- sb_set giờ định tuyến lệnh ghi `config.animation` vào breakpoint base thay vì một vị trí theo từng breakpoint mà renderer không bao giờ đọc tới, khép lại cách thứ tư khiến hiệu ứng bị mất một cách âm thầm.
|
|
30
|
+
|
|
9
31
|
## [0.29.0] - 2026-09-10
|
|
10
32
|
|
|
11
33
|
### Added
|
package/README.md
CHANGED
|
@@ -108,6 +108,7 @@ in this client: one would put the very key the platform exists to hold back into
|
|
|
108
108
|
| `sb_bind` | Bind a node's content to real store data, or make a button add to the cart |
|
|
109
109
|
| `sb_import` | Read a page from any public URL and add its structure and content to the open page as real elements, styled with THIS page's own tokens — a translation, not a clone |
|
|
110
110
|
| `sb_import_site` | Read a WHOLE site from one URL — its sitemap, or the links on that page — and give each page found its own draft page here, built from this site's tokens |
|
|
111
|
+
| `sb_theme` | Read or patch the site's palette and type scale — the layer every style preset resolves from, so one token repaints every page |
|
|
111
112
|
| `sb_store` | Run a store flow that must happen in a fixed order — the four writes that make a working checkout, or any of the platform's 17 form templates (login, register, forgot, contact, subscribe …) with its own field document |
|
|
112
113
|
| `sb_undo` | Put back what a PUT replaced — the platform has no page history or restore, so this is the only way back |
|
|
113
114
|
|
package/README.vi.md
CHANGED
|
@@ -105,6 +105,7 @@ nhét ngược lại vào mọi bản cài.
|
|
|
105
105
|
| `sb_bind` | Gắn nội dung một node vào dữ liệu cửa hàng thật, hoặc biến một nút thành nút thêm vào giỏ |
|
|
106
106
|
| `sb_import` | Đọc một trang từ URL công khai bất kỳ và thêm cấu trúc + nội dung của nó vào trang đang mở dưới dạng element thật, mang token của CHÍNH trang này — là dịch lại, không phải sao chép |
|
|
107
107
|
| `sb_import_site` | Đọc CẢ website từ một URL — sitemap của nó, hoặc các link trên trang đó — và tạo cho mỗi trang tìm được một trang nháp riêng ở đây, dựng bằng token của site này |
|
|
108
|
+
| `sb_theme` | Đọc hoặc vá bảng màu và thang chữ của site — tầng mà mọi style preset phân giải từ đó, nên một token thay áo cho mọi trang |
|
|
108
109
|
| `sb_store` | Chạy một luồng cửa hàng bắt buộc đúng thứ tự — bốn lệnh ghi tạo nên trang thanh toán, hoặc gieo bất kỳ template nào trong 17 form của nền tảng (login, register, forgot, contact, subscribe …) kèm field document của nó |
|
|
109
110
|
| `sb_undo` | Trả lại thứ mà một lệnh PUT đã ghi đè — nền tảng không có lịch sử trang hay restore, nên đây là đường về duy nhất |
|
|
110
111
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const SWAGGER_SOURCE = {
|
|
2
2
|
"operations": 501,
|
|
3
|
-
"definitions":
|
|
4
|
-
"bodyCarrying":
|
|
3
|
+
"definitions": 105,
|
|
4
|
+
"bodyCarrying": 179,
|
|
5
5
|
"bodyUndescribed": 64,
|
|
6
6
|
"generatedFrom": "server/docs/swagger.json"
|
|
7
7
|
};
|
|
@@ -12958,18 +12958,25 @@ export const API_OPERATIONS = [
|
|
|
12958
12958
|
"tags": [
|
|
12959
12959
|
"templates"
|
|
12960
12960
|
],
|
|
12961
|
-
"summary": "
|
|
12961
|
+
"summary": "Create a site from a template",
|
|
12962
12962
|
"params": [
|
|
12963
12963
|
{
|
|
12964
12964
|
"name": "siteId",
|
|
12965
12965
|
"in": "path",
|
|
12966
12966
|
"required": true,
|
|
12967
12967
|
"type": "string",
|
|
12968
|
-
"description": "
|
|
12968
|
+
"description": "Template site to start from"
|
|
12969
|
+
},
|
|
12970
|
+
{
|
|
12971
|
+
"name": "body",
|
|
12972
|
+
"in": "body",
|
|
12973
|
+
"required": true,
|
|
12974
|
+
"type": "object",
|
|
12975
|
+
"description": "The new site's name"
|
|
12969
12976
|
}
|
|
12970
12977
|
],
|
|
12971
|
-
"bodyDescribed":
|
|
12972
|
-
"bodyRef":
|
|
12978
|
+
"bodyDescribed": true,
|
|
12979
|
+
"bodyRef": "internal_templates_rest.useBody",
|
|
12973
12980
|
"credential": "siteScoped"
|
|
12974
12981
|
},
|
|
12975
12982
|
{
|
|
@@ -17923,5 +17930,14 @@ export const API_DEFINITIONS = {
|
|
|
17923
17930
|
"type": "integer"
|
|
17924
17931
|
}
|
|
17925
17932
|
}
|
|
17933
|
+
},
|
|
17934
|
+
"internal_templates_rest.useBody": {
|
|
17935
|
+
"type": "object",
|
|
17936
|
+
"properties": {
|
|
17937
|
+
"name": {
|
|
17938
|
+
"description": "Name is the new site's name. Required rather than defaulted to the\ntemplate's own name: a default would quietly produce two sites called\n\"Fashion starter\" for a user who pressed the button twice.",
|
|
17939
|
+
"type": "string"
|
|
17940
|
+
}
|
|
17941
|
+
}
|
|
17926
17942
|
}
|
|
17927
17943
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ELEMENTS, TRAIT_WRITES } from './elements.generated.js';
|
|
2
|
-
import { vocabulariesForWrites } from '../domains/site/vocabulary.js';
|
|
2
|
+
import { animationVocabulary, vocabulariesForWrites } from '../domains/site/vocabulary.js';
|
|
3
3
|
import { neverTranslatedOn, translatableSpecials } from '../domains/site/translate.js';
|
|
4
4
|
/** Eight to choose from; the hints for the chosen one come with sb_traits_for. */
|
|
5
5
|
export const DEFAULT_CATALOG_LIMIT = 8;
|
|
@@ -125,6 +125,12 @@ export function traitsFor(type, control) {
|
|
|
125
125
|
const vocab = vocabulariesForWrites([...keys]);
|
|
126
126
|
return Object.keys(vocab).length ? { config_values: vocab } : {};
|
|
127
127
|
})(),
|
|
128
|
+
// THE ENTRANCE ANIMATION, for the 73 element types that offer it. It does
|
|
129
|
+
// not ride in `config_values` because it is not a word — it is an OBJECT
|
|
130
|
+
// with a required gate, and the three ways to get it wrong all render
|
|
131
|
+
// NOTHING rather than something else. An element that does not offer the
|
|
132
|
+
// control says nothing, so this is silent on the other 38.
|
|
133
|
+
...(el.controls.includes('animation') ? { animation: animationVocabulary() } : {}),
|
|
128
134
|
isContainer: el.isContainer,
|
|
129
135
|
isRootOnly: el.isRootOnly,
|
|
130
136
|
childAllows: el.childAllows,
|
|
@@ -36188,6 +36188,7 @@ export const BASE_ONLY_CONFIG = [
|
|
|
36188
36188
|
"collectionId",
|
|
36189
36189
|
"collectionType",
|
|
36190
36190
|
"quantity",
|
|
36191
|
+
"animation",
|
|
36191
36192
|
"rowLimit"
|
|
36192
36193
|
];
|
|
36193
36194
|
/**
|
|
@@ -36198,3 +36199,36 @@ export const BASE_ONLY_CONFIG = [
|
|
|
36198
36199
|
export const BASE_ONLY_EXCEPTIONS = [
|
|
36199
36200
|
"quantity-button:iconSize"
|
|
36200
36201
|
];
|
|
36202
|
+
/**
|
|
36203
|
+
* The ENTRANCE ANIMATION's vocabulary — config.animation, offered by 73 of the
|
|
36204
|
+
* 111 element types and describable by nothing until now.
|
|
36205
|
+
*
|
|
36206
|
+
* Three ways to miss, all silent (AnimationTypeOf answers "" and no keyframes,
|
|
36207
|
+
* no rule and no error are emitted, through save, publish and render):
|
|
36208
|
+
* - it is an OBJECT, not a string: {active, type, easing, delay, duration}
|
|
36209
|
+
* - active:true is REQUIRED; a stored type is deliberately NOT consent,
|
|
36210
|
+
* because the panel keeps the type when the switch goes off
|
|
36211
|
+
* - type is a keyframe key spelled with UNDERSCORES: fade_in, never fade-in
|
|
36212
|
+
*
|
|
36213
|
+
* easing is the mild one: an unrecognised value falls back to "ease".
|
|
36214
|
+
*
|
|
36215
|
+
* It is also BASE-ONLY (see BASE_ONLY_CONFIG) — render/css.go emits it into the
|
|
36216
|
+
* base lane because the config object is read with no responsive merge.
|
|
36217
|
+
*/
|
|
36218
|
+
export const ANIMATION = {
|
|
36219
|
+
"types": [
|
|
36220
|
+
"fade_in",
|
|
36221
|
+
"slide_down",
|
|
36222
|
+
"slide_up",
|
|
36223
|
+
"zoom_in"
|
|
36224
|
+
],
|
|
36225
|
+
"easings": [
|
|
36226
|
+
"ease",
|
|
36227
|
+
"ease-in",
|
|
36228
|
+
"ease-out",
|
|
36229
|
+
"linear"
|
|
36230
|
+
],
|
|
36231
|
+
"easingFallback": "ease",
|
|
36232
|
+
"durationDefault": 0.5,
|
|
36233
|
+
"readBy": "AnimationTypeOf + CompileEntranceAnimationCSS"
|
|
36234
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONFIG_VALUES } from '../../catalog/elements.generated.js';
|
|
1
|
+
import { ANIMATION, CONFIG_VALUES } from '../../catalog/elements.generated.js';
|
|
2
2
|
/** The vocabulary for a config key, or null when the key has no fixed one. */
|
|
3
3
|
export function vocabularyFor(key) {
|
|
4
4
|
return CONFIG_VALUES[key] ?? null;
|
|
@@ -55,3 +55,90 @@ export function unknownValueNote(key, value) {
|
|
|
55
55
|
: '') +
|
|
56
56
|
'.');
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* THE ENTRANCE ANIMATION, WHICH 73 OF 111 ELEMENTS OFFER AND NOTHING DESCRIBED.
|
|
60
|
+
*
|
|
61
|
+
* Same family as the vocabularies above and worse in one way: there are THREE
|
|
62
|
+
* ways to miss and all of them are silent, because `AnimationTypeOf` answers ""
|
|
63
|
+
* and `render/css.go` then emits no keyframes, no rule and no error — through
|
|
64
|
+
* save, publish and render.
|
|
65
|
+
*
|
|
66
|
+
* - IT IS AN OBJECT, not the string the control's name invites.
|
|
67
|
+
* `readAnimConfig` asserts `map[string]interface{}`, so a bare
|
|
68
|
+
* `"fade_in"` is the zero value and the node never animates.
|
|
69
|
+
* - `active: true` IS REQUIRED, and a stored `type` is deliberately NOT
|
|
70
|
+
* consent. The platform's own comment gives the reason: the panel keeps
|
|
71
|
+
* `type` when the switch goes off, so switching back restores the choice —
|
|
72
|
+
* "treating a stored type as consent would animate a node the author had
|
|
73
|
+
* explicitly turned off".
|
|
74
|
+
* - THE TYPE IS UNDERSCORED. `AnimKeyframes`'s comment flags it outright:
|
|
75
|
+
* "keyed by the STORED value (`fade_in`, not `fade-in`)". `fade-in` is the
|
|
76
|
+
* spelling every other web tool uses and the one an agent reaches for.
|
|
77
|
+
*
|
|
78
|
+
* `easing` is the mild case and is reported differently: an unrecognised value
|
|
79
|
+
* falls back to `ease`, so the animation still runs — it is a wrong answer, not
|
|
80
|
+
* a missing one.
|
|
81
|
+
*
|
|
82
|
+
* And it is BASE-ONLY, which is a fourth way to lose it — but that one is
|
|
83
|
+
* ROUTED rather than warned about, by `baseonly.ts`, because the platform's
|
|
84
|
+
* ledger names the key and `sb_set` can simply write it to the right layer.
|
|
85
|
+
*/
|
|
86
|
+
export const ANIMATION_VOCAB = ANIMATION;
|
|
87
|
+
/**
|
|
88
|
+
* Everything a caller needs to write config.animation correctly, in ONE LINE.
|
|
89
|
+
*
|
|
90
|
+
* It rides on 73 of the 111 elements, and `sb_traits_for` is the result an agent
|
|
91
|
+
* reads before every styling decision — so the first version of this, a
|
|
92
|
+
* six-field object, was 400 bytes of dilution on two thirds of the catalog and
|
|
93
|
+
* pushed the search-result budget over its ceiling. The budget test was right:
|
|
94
|
+
* the four facts fit in a sentence, and a sentence is what a reader takes in
|
|
95
|
+
* anyway. The long form lives in `animationNote`, which fires at the moment the
|
|
96
|
+
* mistake is actually made.
|
|
97
|
+
*/
|
|
98
|
+
export function animationVocabulary() {
|
|
99
|
+
return (`config.animation is an OBJECT: { active: true, type: ${ANIMATION.types.join('|')}, ` +
|
|
100
|
+
`easing?: ${ANIMATION.easings.join('|')}, delay?, duration? }. active:true is REQUIRED ` +
|
|
101
|
+
'(a type alone is not consent), the type is UNDERSCORED, and it is base-only. Anything ' +
|
|
102
|
+
'else renders no animation at all, with no error.');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The warning for a `config.animation` write that will not animate.
|
|
106
|
+
*
|
|
107
|
+
* A WARNING rather than a refusal, for the reason `unknownValueNote` records:
|
|
108
|
+
* the platform STORES whatever it is given. But unlike a normalised string,
|
|
109
|
+
* every miss here renders NOTHING rather than something else — so the note says
|
|
110
|
+
* what will happen, not merely what the value is not.
|
|
111
|
+
*/
|
|
112
|
+
export function animationNote(value) {
|
|
113
|
+
const has = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
114
|
+
const types = ANIMATION.types.join(', ');
|
|
115
|
+
if (!has(value)) {
|
|
116
|
+
return (`config.animation is an OBJECT, not a ${typeof value === 'string' ? 'string' : typeof value}: ` +
|
|
117
|
+
`{ active: true, type: "<one of ${types}>", easing, delay, duration }. The renderer reads it ` +
|
|
118
|
+
'as map[string]interface{}, so this value is the zero value and the node will not animate — ' +
|
|
119
|
+
'stored, saved and published with no error at any step.');
|
|
120
|
+
}
|
|
121
|
+
const notes = [];
|
|
122
|
+
if (value.active !== true) {
|
|
123
|
+
notes.push('active:true is missing, and a type alone is deliberately not consent — the panel keeps the ' +
|
|
124
|
+
'type when the switch goes off, so the renderer emits nothing without it');
|
|
125
|
+
}
|
|
126
|
+
const t = value.type;
|
|
127
|
+
if (typeof t !== 'string' || !ANIMATION.types.includes(t)) {
|
|
128
|
+
const near = typeof t === 'string' ? t.replace(/-/g, '_') : '';
|
|
129
|
+
notes.push(`type ${JSON.stringify(t)} is not one of ${types}` +
|
|
130
|
+
(near && ANIMATION.types.includes(near)
|
|
131
|
+
? ` — the stored spelling uses UNDERSCORES, so you want "${near}"`
|
|
132
|
+
: '') +
|
|
133
|
+
'; an unrecognised type emits no keyframes and no rule');
|
|
134
|
+
}
|
|
135
|
+
const e = value.easing;
|
|
136
|
+
if (e !== undefined && (typeof e !== 'string' || !ANIMATION.easings.includes(e))) {
|
|
137
|
+
// The mild one: the animation still RUNS, wearing the wrong curve.
|
|
138
|
+
notes.push(`easing ${JSON.stringify(e)} is outside ${ANIMATION.easings.join(', ')}, so the renderer ` +
|
|
139
|
+
`uses "${ANIMATION.easingFallback}" — the animation runs, with a curve you did not choose`);
|
|
140
|
+
}
|
|
141
|
+
if (!notes.length)
|
|
142
|
+
return null;
|
|
143
|
+
return `config.animation will not do what this says. ${notes.join('. ')}.`;
|
|
144
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerLiveTools } from './tools/live.js';
|
|
|
13
13
|
import { registerStoreTools } from './tools/store.js';
|
|
14
14
|
import { registerImportTools } from './tools/importpage.js';
|
|
15
15
|
import { registerUndoTools } from './tools/undo.js';
|
|
16
|
+
import { registerThemeTools } from './tools/theme.js';
|
|
16
17
|
/**
|
|
17
18
|
* Sent on every handshake, so it is short, and says nothing a tool description
|
|
18
19
|
* already says. Counts come from the generated source records, never literals.
|
|
@@ -70,6 +71,7 @@ export function createServer(ctx = buildContext()) {
|
|
|
70
71
|
const pageSession = registerPageTools(server, ctx);
|
|
71
72
|
registerLiveTools(server, ctx, pageSession);
|
|
72
73
|
registerStoreTools(server, ctx);
|
|
74
|
+
registerThemeTools(server, ctx);
|
|
73
75
|
registerImportTools(server, ctx, pageSession);
|
|
74
76
|
registerUndoTools(server, ctx);
|
|
75
77
|
return server;
|
package/dist/tools/importpage.js
CHANGED
|
@@ -464,7 +464,13 @@ export function registerImportTools(server, ctx, session) {
|
|
|
464
464
|
}
|
|
465
465
|
}
|
|
466
466
|
const tokens = tokenDoc ? tokensFromPage(tokenDoc.doc) : {};
|
|
467
|
-
const shots = await captureMany(plan.pages.map((p) => p.url),
|
|
467
|
+
const shots = await captureMany(plan.pages.map((p) => p.url),
|
|
468
|
+
// 900, not 300. The old ceiling was chosen against marketing pages;
|
|
469
|
+
// a real shop's homepage is a different quantity — ttgshop.vn measured
|
|
470
|
+
// 9,829px of catalogue across a dozen collection bands, and 300 cut it
|
|
471
|
+
// off in the middle of the third. A cap is here to stop a runaway page,
|
|
472
|
+
// not to decide how much of an ordinary one survives.
|
|
473
|
+
{ maxImages: max_images ?? 60, maxNodes: max_nodes ?? 900 });
|
|
468
474
|
const byUrl = new Map(shots.map((s) => [s.url, s]));
|
|
469
475
|
// ONE UPLOAD PER IMAGE FOR THE WHOLE SITE, not per page. A logo, a payment
|
|
470
476
|
// strip and a footer badge appear on every page of a real site, and
|
package/dist/tools/page.js
CHANGED
|
@@ -8,7 +8,7 @@ import { baseOnlyNote } from '../domains/site/baseonly.js';
|
|
|
8
8
|
import { detachNote, presetIdOf, presetLayer } from '../domains/site/theme.js';
|
|
9
9
|
import { inertHintsFor } from '../domains/site/inert.js';
|
|
10
10
|
import { hasSeed, seedDocument, seedSummary, seededTypes } from '../domains/site/storepage.js';
|
|
11
|
-
import { unknownValueNote } from '../domains/site/vocabulary.js';
|
|
11
|
+
import { animationNote, unknownValueNote } from '../domains/site/vocabulary.js';
|
|
12
12
|
import { skinLevelNote } from '../domains/site/fieldskin.js';
|
|
13
13
|
import { siteTheme } from '../domains/site/theme-fetch.js';
|
|
14
14
|
import { request, redact } from '../transport/http.js';
|
|
@@ -520,10 +520,15 @@ export function registerPageTools(server, ctx) {
|
|
|
520
520
|
if (e.namespace !== 'config')
|
|
521
521
|
continue;
|
|
522
522
|
for (const [k, v] of Object.entries(e.keys)) {
|
|
523
|
-
|
|
523
|
+
// THE ENTRANCE ANIMATION IS ITS OWN QUESTION, because it is an object
|
|
524
|
+
// rather than a word and every way of missing it renders NOTHING
|
|
525
|
+
// rather than a normalised something. Keyed on the whole value: two
|
|
526
|
+
// nodes given the same wrong animation deserve one answer, and two
|
|
527
|
+
// given different wrong ones deserve two.
|
|
528
|
+
const n = k === 'animation' ? animationNote(v) : unknownValueNote(k, v);
|
|
524
529
|
if (!n)
|
|
525
530
|
continue;
|
|
526
|
-
const once = ctx.notices.once(`config-value:${k}=${
|
|
531
|
+
const once = ctx.notices.once(`config-value:${k}=${JSON.stringify(v)}`, n);
|
|
527
532
|
if (once)
|
|
528
533
|
valueNotes.push(once);
|
|
529
534
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { text } from '../mcp/response.js';
|
|
3
|
+
import { request } from '../transport/http.js';
|
|
4
|
+
import { siteToken } from './credentialpick.js';
|
|
5
|
+
import { siteFor } from './context.js';
|
|
6
|
+
import { STARTER_THEME } from '../domains/site/theme.js';
|
|
7
|
+
/** The site's theme, or the starter when it has never saved one. */
|
|
8
|
+
async function readTheme(ctx, siteId) {
|
|
9
|
+
const got = (await request({
|
|
10
|
+
base: ctx.base,
|
|
11
|
+
method: 'GET',
|
|
12
|
+
path: `/api/sites/${encodeURIComponent(siteId)}/theme`,
|
|
13
|
+
token: siteToken(ctx),
|
|
14
|
+
fetchImpl: ctx.fetchImpl,
|
|
15
|
+
}));
|
|
16
|
+
// A NEW SITE ANSWERS 200 {"theme": null} — the platform's own comment calls
|
|
17
|
+
// that "the NORMAL first-visit state, not a failure". Starting from the
|
|
18
|
+
// starter rather than from `{}` is what makes the first write store a
|
|
19
|
+
// COMPLETE theme instead of a palette with one token in it.
|
|
20
|
+
if (got?.theme && typeof got.theme === 'object')
|
|
21
|
+
return { theme: got.theme, origin: 'site' };
|
|
22
|
+
return { theme: structuredClone(STARTER_THEME), origin: 'starter' };
|
|
23
|
+
}
|
|
24
|
+
export function registerThemeTools(server, ctx) {
|
|
25
|
+
server.registerTool('sb_theme', {
|
|
26
|
+
description: "The site's palette and type scale — the layer every element's style preset resolves " +
|
|
27
|
+
'from, so one token repaints every page at once. Call it with nothing to read what the ' +
|
|
28
|
+
'site actually has. `colors` and `text_styles` PATCH the saved document: what you do not ' +
|
|
29
|
+
'name is kept.',
|
|
30
|
+
inputSchema: {
|
|
31
|
+
site_id: z.string().optional(),
|
|
32
|
+
colors: z
|
|
33
|
+
.record(z.string())
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Token id -> CSS colour, e.g. { "heading": "#2E2A3B", "primary": "#E8557A" }'),
|
|
36
|
+
text_styles: z
|
|
37
|
+
.record(z.record(z.string()))
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Text style slug -> base declarations, e.g. { "h1": { "fontSize": "48px" } }'),
|
|
40
|
+
dry_run: z.boolean().optional(),
|
|
41
|
+
},
|
|
42
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
43
|
+
}, async ({ site_id: given, colors, text_styles, dry_run }) => {
|
|
44
|
+
const site_id = siteFor(ctx, given);
|
|
45
|
+
const { theme, origin } = await readTheme(ctx, site_id);
|
|
46
|
+
// READ. What the site actually paints from, which is what rule 0 wants
|
|
47
|
+
// before anything is decided.
|
|
48
|
+
if (!colors && !text_styles) {
|
|
49
|
+
return text({
|
|
50
|
+
origin: origin === 'site'
|
|
51
|
+
? 'this site, saved'
|
|
52
|
+
: 'the STARTER theme — this site has never saved one, so this is what it renders ' +
|
|
53
|
+
'with and what a first write would be built from',
|
|
54
|
+
version: theme.version,
|
|
55
|
+
colors: Object.fromEntries((theme.colors ?? []).map((c) => [c.id, c.value])),
|
|
56
|
+
text_styles: Object.fromEntries((theme.textStyles ?? []).map((t) => [t.slug, t.base ?? {}])),
|
|
57
|
+
schemes: (theme.schemes ?? []).map((s) => s.id),
|
|
58
|
+
presets: (theme.presets ?? []).length,
|
|
59
|
+
note: ctx.notices.once('theme_leverage', 'A style preset compiles to a class rule BENEATH a node\'s own values, so every node ' +
|
|
60
|
+
'that has not been given a literal follows these tokens. Changing one here is the ' +
|
|
61
|
+
'cheapest way to restyle a whole site — and a literal written with sb_set detaches ' +
|
|
62
|
+
'that node from it permanently.'),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// PATCH. Unknown ids are REFUSED rather than added: a token nothing
|
|
66
|
+
// resolves from is a value the platform stores and no renderer reads,
|
|
67
|
+
// which is the silent-failure family this server exists to close — and a
|
|
68
|
+
// typo would land there rather than on the colour the caller meant.
|
|
69
|
+
const changes = [];
|
|
70
|
+
const known = new Set((theme.colors ?? []).map((c) => c.id));
|
|
71
|
+
const unknown = Object.keys(colors ?? {}).filter((id) => !known.has(id));
|
|
72
|
+
if (unknown.length) {
|
|
73
|
+
throw new Error(`sbuilder: no colour token named ${unknown.map((u) => JSON.stringify(u)).join(', ')} on ` +
|
|
74
|
+
`this site. Its tokens are: ${[...known].join(', ')}. Every style preset resolves ` +
|
|
75
|
+
'through these ids, so a new one would be stored and read by nothing.');
|
|
76
|
+
}
|
|
77
|
+
for (const [id, value] of Object.entries(colors ?? {})) {
|
|
78
|
+
const token = theme.colors.find((c) => c.id === id);
|
|
79
|
+
if (token.value === value)
|
|
80
|
+
continue;
|
|
81
|
+
changes.push({ what: `colors.${id}`, from: token.value, to: value });
|
|
82
|
+
token.value = value;
|
|
83
|
+
}
|
|
84
|
+
const slugs = new Set((theme.textStyles ?? []).map((t) => t.slug));
|
|
85
|
+
const badSlugs = Object.keys(text_styles ?? {}).filter((s) => !slugs.has(s));
|
|
86
|
+
if (badSlugs.length) {
|
|
87
|
+
throw new Error(`sbuilder: no text style named ${badSlugs.map((b) => JSON.stringify(b)).join(', ')} on ` +
|
|
88
|
+
`this site. Its slugs are: ${[...slugs].join(', ')}. A preset names a style by SLUG ` +
|
|
89
|
+
'and the compiled --wb-ts-<slug>-<prop> variables are built from it.');
|
|
90
|
+
}
|
|
91
|
+
for (const [slug, decls] of Object.entries(text_styles ?? {})) {
|
|
92
|
+
const style = theme.textStyles.find((t) => t.slug === slug);
|
|
93
|
+
style.base = style.base ?? {};
|
|
94
|
+
for (const [prop, value] of Object.entries(decls)) {
|
|
95
|
+
const before = style.base[prop] ?? '(unset)';
|
|
96
|
+
if (before === value)
|
|
97
|
+
continue;
|
|
98
|
+
changes.push({ what: `textStyles.${slug}.${prop}`, from: before, to: value });
|
|
99
|
+
style.base[prop] = value;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!changes.length) {
|
|
103
|
+
return text({ unchanged: true, note: 'Every value named already holds that value.' });
|
|
104
|
+
}
|
|
105
|
+
// The document goes back WHOLE, so this can only ever be true — asserted
|
|
106
|
+
// anyway, because the one failure this endpoint cannot take back is a
|
|
107
|
+
// theme with nothing in it.
|
|
108
|
+
if (!theme.colors?.length || !theme.presets?.length) {
|
|
109
|
+
throw new Error('sbuilder: refusing to save a theme with no colours or no presets.');
|
|
110
|
+
}
|
|
111
|
+
if (dry_run !== false) {
|
|
112
|
+
return text({
|
|
113
|
+
dry_run: true,
|
|
114
|
+
would_change: changes,
|
|
115
|
+
on: site_id,
|
|
116
|
+
built_from: origin === 'site' ? "this site's saved theme" : 'the starter theme',
|
|
117
|
+
note: 'Nothing was sent. This is SITE-WIDE: every page, and every node that has not been ' +
|
|
118
|
+
'given a literal of its own. Re-call with dry_run:false.',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
await request({
|
|
122
|
+
base: ctx.base,
|
|
123
|
+
method: 'PUT',
|
|
124
|
+
path: `/api/sites/${encodeURIComponent(site_id)}/theme`,
|
|
125
|
+
token: siteToken(ctx),
|
|
126
|
+
body: { theme },
|
|
127
|
+
fetchImpl: ctx.fetchImpl,
|
|
128
|
+
});
|
|
129
|
+
return text({
|
|
130
|
+
changed: changes,
|
|
131
|
+
on: site_id,
|
|
132
|
+
next: 'Republish the pages that should show it — a theme is compiled into each page\'s ' +
|
|
133
|
+
'stylesheet, so a saved page keeps the old palette until it is published again.',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
package/dist/vision/capture.js
CHANGED
|
@@ -74,7 +74,16 @@ function capturePage(limits) {
|
|
|
74
74
|
// nothing, so a contact page says it had one. The CONTROLS stay ignored —
|
|
75
75
|
// a stray input outside a form is chrome, and the fields of a form that IS
|
|
76
76
|
// reported are counted there rather than walked into.
|
|
77
|
-
|
|
77
|
+
//
|
|
78
|
+
// BUTTON IS NOT HERE EITHER, AND USED TO BE — filed with the form controls,
|
|
79
|
+
// on the reasoning that fits `input` and does not fit it. A `<button>` with
|
|
80
|
+
// words in it is a CALL TO ACTION, which on a shop is the most important
|
|
81
|
+
// interactive thing on the page: MEASURED on ttgshop.vn, 27 of them dropped
|
|
82
|
+
// in one capture, every "Mua ngay" and "Thêm vào giỏ" among them. A button
|
|
83
|
+
// that really is a form control is unreachable anyway — the FORM branch
|
|
84
|
+
// returns without walking its children — so the only ones this reaches are
|
|
85
|
+
// the ones a reader would press.
|
|
86
|
+
'NAV', 'INPUT', 'SELECT', 'TEXTAREA',
|
|
78
87
|
]);
|
|
79
88
|
const forms = [];
|
|
80
89
|
/** The provider and id behind an embed URL, or null if this platform has no element for it. */
|
|
@@ -136,6 +145,89 @@ function capturePage(limits) {
|
|
|
136
145
|
// a throwaway tab that is closed straight after.
|
|
137
146
|
for (const d of Array.from(document.querySelectorAll('details')))
|
|
138
147
|
d.setAttribute('open', '');
|
|
148
|
+
// A TAB PANEL THAT IS NOT THE OPEN ONE MEASURES AS ZERO, and on a shop that
|
|
149
|
+
// is most of the page.
|
|
150
|
+
//
|
|
151
|
+
// Same principle as the `<details>` pass above and the same sentence decides
|
|
152
|
+
// it: the source's collapsed state is not content. MEASURED on ttgshop.vn —
|
|
153
|
+
// a homepage that lays its catalogue out in tabs (PC GAMING, WORKSTATION,
|
|
154
|
+
// AMD, MINI, …) — 73% of the page's own text was in panels carrying
|
|
155
|
+
// `display:none`, so the import kept one tab's products and silently dropped
|
|
156
|
+
// every other tab: product names, prices, discounts, stock. Nothing reported
|
|
157
|
+
// it, because a hidden element is indistinguishable from an absent one once
|
|
158
|
+
// it has been skipped.
|
|
159
|
+
//
|
|
160
|
+
// REVEALED BY THE SHAPE OF A PANEL SET, never by unhiding what is hidden. A
|
|
161
|
+
// `display:none` element with no visible SIBLING is a modal, a drop-down, an
|
|
162
|
+
// off-canvas menu or a mobile-only copy of a desktop bar — showing those is
|
|
163
|
+
// how an import grows a navigation drawer in the middle of a page. What marks
|
|
164
|
+
// a panel is that it sits beside a peer of the same kind that IS shown: one
|
|
165
|
+
// tab open, the rest waiting. So the reveal needs a visible sibling, real
|
|
166
|
+
// content of its own, and a parent that is not page chrome.
|
|
167
|
+
//
|
|
168
|
+
// The display value is COPIED from that visible sibling rather than forced to
|
|
169
|
+
// `block`: a flex row of cards revealed as a block would stack, and the walk
|
|
170
|
+
// reads `display` to decide what is a row.
|
|
171
|
+
const revealPanels = () => {
|
|
172
|
+
const parents = new Set();
|
|
173
|
+
for (const el of Array.from(document.querySelectorAll('[style*="display"], .hidden, [hidden], [aria-hidden]'))) {
|
|
174
|
+
const p = el.parentElement;
|
|
175
|
+
if (p)
|
|
176
|
+
parents.add(p);
|
|
177
|
+
}
|
|
178
|
+
// Any container can hold a panel set; the attribute scan above only finds
|
|
179
|
+
// the common spellings, so the sweep below is over every parent of more
|
|
180
|
+
// than one element, bounded by the document itself.
|
|
181
|
+
for (const el of Array.from(document.querySelectorAll('*'))) {
|
|
182
|
+
if (el.children.length > 1)
|
|
183
|
+
parents.add(el);
|
|
184
|
+
}
|
|
185
|
+
for (const parent of parents) {
|
|
186
|
+
if (inPageChrome(parent))
|
|
187
|
+
continue;
|
|
188
|
+
const kids = Array.from(parent.children);
|
|
189
|
+
if (kids.length < 2)
|
|
190
|
+
continue;
|
|
191
|
+
// A VISIBLE PEER IS NOT ENOUGH, and the first version of this stopped
|
|
192
|
+
// there — which reveals every hidden thing on the page, because anything
|
|
193
|
+
// in a content flow has visible siblings. A pinned test caught it: a
|
|
194
|
+
// `<p style="display:none">` beside a visible paragraph came back as
|
|
195
|
+
// content, and that `<p>` is hidden because its author hid it.
|
|
196
|
+
//
|
|
197
|
+
// A PANEL IS A CONTAINER THAT MATCHES ITS PEER. Same tag as a sibling
|
|
198
|
+
// that IS shown, and children of its own — which is what a tab body, a
|
|
199
|
+
// carousel track or a filtered grid always is, and what a hidden
|
|
200
|
+
// paragraph, a stray span and an empty slot never are.
|
|
201
|
+
const shownByTag = {};
|
|
202
|
+
const hidden = [];
|
|
203
|
+
for (const k of kids) {
|
|
204
|
+
const cs = getComputedStyle(k);
|
|
205
|
+
const r = k.getBoundingClientRect();
|
|
206
|
+
if (cs.display === 'none')
|
|
207
|
+
hidden.push(k);
|
|
208
|
+
else if (!shownByTag[k.tagName] && r.width > 0 && r.height > 0) {
|
|
209
|
+
shownByTag[k.tagName] = cs.display;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!hidden.length)
|
|
213
|
+
continue;
|
|
214
|
+
for (const k of hidden) {
|
|
215
|
+
// Real content only — an empty slot or a script-filled placeholder is
|
|
216
|
+
// not a panel worth showing, and revealing it costs a skip either way.
|
|
217
|
+
// Low floor on purpose: a panel whose whole content is a price —
|
|
218
|
+
// "1.490.000 VNĐ", thirteen characters — is exactly the panel a shop
|
|
219
|
+
// hides, and a threshold tuned for prose skipped it.
|
|
220
|
+
const shownDisplay = shownByTag[k.tagName];
|
|
221
|
+
if (!shownDisplay || k.children.length === 0)
|
|
222
|
+
continue;
|
|
223
|
+
const holds = clean(k.textContent).length > 2 || k.querySelectorAll('img').length > 0;
|
|
224
|
+
if (!holds)
|
|
225
|
+
continue;
|
|
226
|
+
const was = k.getAttribute('style') ?? '';
|
|
227
|
+
k.setAttribute('style', `${was};display:${shownDisplay} !important`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
139
231
|
/**
|
|
140
232
|
* What the page CALLS this icon, in its own words. A candidate, never a verdict.
|
|
141
233
|
*
|
|
@@ -145,6 +237,64 @@ function capturePage(limits) {
|
|
|
145
237
|
* decides whether this platform has one by that name — nothing in the page can
|
|
146
238
|
* answer that.
|
|
147
239
|
*/
|
|
240
|
+
/**
|
|
241
|
+
* THE REAL FILE, wherever the page put it — and on a shop that is almost never
|
|
242
|
+
* `src`.
|
|
243
|
+
*
|
|
244
|
+
* MEASURED on ttgshop.vn: 100 images, and 84 of them carry `data-src` with no
|
|
245
|
+
* `src` at all. Those 84 are the PRODUCT PHOTOS. Reading `src` alone imported
|
|
246
|
+
* a shop with a sixth of its pictures and reported the rest as
|
|
247
|
+
* `image-without-src`, which reads like the page's fault rather than ours.
|
|
248
|
+
*
|
|
249
|
+
* Lazy loading is not an edge case, it is how the web ships images: every
|
|
250
|
+
* mainstream loader (lazysizes, lozad, and most CMS themes) parks the URL in a
|
|
251
|
+
* data attribute and fills `src` only when the image nears the viewport — and
|
|
252
|
+
* a full-page screenshot does not scroll, which is the same reason `sb_look`
|
|
253
|
+
* walks the page before it fires.
|
|
254
|
+
*
|
|
255
|
+
* `srcset` is read for its LARGEST candidate rather than its first: the
|
|
256
|
+
* platform re-encodes what it is given, so handing it the 350w thumbnail when
|
|
257
|
+
* the page also offers 1400w throws away detail nothing can recover. A
|
|
258
|
+
* `data:` URI is refused wherever it appears — it is the 1x1 placeholder the
|
|
259
|
+
* loader is displaying until the real one arrives, which is precisely the
|
|
260
|
+
* placeholder this import must not ship.
|
|
261
|
+
*/
|
|
262
|
+
const LAZY_SRC = ['data-src', 'data-original', 'data-lazy-src', 'data-lazy', 'data-echo', 'data-url'];
|
|
263
|
+
const widest = (srcset) => {
|
|
264
|
+
let best = '';
|
|
265
|
+
let bestW = -1;
|
|
266
|
+
for (const part of srcset.split(',')) {
|
|
267
|
+
const bits = part.trim().split(/\s+/);
|
|
268
|
+
if (!bits[0])
|
|
269
|
+
continue;
|
|
270
|
+
const w = /^([0-9]+)w$/.exec(bits[1] ?? '');
|
|
271
|
+
const n = w ? Number(w[1]) : 0;
|
|
272
|
+
if (n >= bestW) {
|
|
273
|
+
bestW = n;
|
|
274
|
+
best = bits[0];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return best;
|
|
278
|
+
};
|
|
279
|
+
const realSrc = (el) => {
|
|
280
|
+
const direct = el.getAttribute('src');
|
|
281
|
+
if (direct && !direct.startsWith('data:'))
|
|
282
|
+
return direct;
|
|
283
|
+
for (const a of LAZY_SRC) {
|
|
284
|
+
const v = el.getAttribute(a);
|
|
285
|
+
if (v && !v.startsWith('data:'))
|
|
286
|
+
return v;
|
|
287
|
+
}
|
|
288
|
+
for (const a of ['srcset', 'data-srcset']) {
|
|
289
|
+
const v = el.getAttribute(a);
|
|
290
|
+
if (v) {
|
|
291
|
+
const w = widest(v);
|
|
292
|
+
if (w && !w.startsWith('data:'))
|
|
293
|
+
return w;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return '';
|
|
297
|
+
};
|
|
148
298
|
const iconName = (el) => {
|
|
149
299
|
const use = Array.from(el.querySelectorAll('use'))[0];
|
|
150
300
|
const ref = use ? use.getAttribute('href') ?? use.getAttribute('xlink:href') : null;
|
|
@@ -385,8 +535,8 @@ function capturePage(limits) {
|
|
|
385
535
|
return [{ kind: 'heading', level: Number(tag.slice(1)), text }];
|
|
386
536
|
}
|
|
387
537
|
if (tag === 'IMG') {
|
|
388
|
-
const src = el
|
|
389
|
-
if (!src
|
|
538
|
+
const src = realSrc(el);
|
|
539
|
+
if (!src) {
|
|
390
540
|
skip('image-without-src');
|
|
391
541
|
return [];
|
|
392
542
|
}
|
|
@@ -398,6 +548,31 @@ function capturePage(limits) {
|
|
|
398
548
|
taken.nodes++;
|
|
399
549
|
return [{ kind: 'image', src: abs(src), alt: clean(el.getAttribute('alt')) }];
|
|
400
550
|
}
|
|
551
|
+
if (tag === 'BUTTON') {
|
|
552
|
+
// Read exactly as an `<a>` is, minus the href: painted is a call to
|
|
553
|
+
// action, unpainted is a plain control, and the variant is what stops
|
|
554
|
+
// every one of them arriving as a pink pill — the defect the anchor
|
|
555
|
+
// branch below records from a documentation sidebar.
|
|
556
|
+
const text = clean(el.textContent);
|
|
557
|
+
// A BUTTON WITH MARKUP INSIDE IT IS A CARD, not a label — the same
|
|
558
|
+
// reading the anchor branch below already applies, and taking the text
|
|
559
|
+
// whole instead COST coverage rather than gaining it: a product tile
|
|
560
|
+
// wrapped in a `<button>` came back as one run-on string with the name,
|
|
561
|
+
// the price and the discount glued together, replacing three nodes that
|
|
562
|
+
// had been captured separately. Measured: 89.7% down to 82.7% on
|
|
563
|
+
// ttgshop.vn, recovered by descending first.
|
|
564
|
+
if (el.children.length > 0) {
|
|
565
|
+
const inner = walkChildren(el);
|
|
566
|
+
if (inner.length > 0)
|
|
567
|
+
return inner;
|
|
568
|
+
}
|
|
569
|
+
if (!text) {
|
|
570
|
+
skip('button-without-words');
|
|
571
|
+
return [];
|
|
572
|
+
}
|
|
573
|
+
taken.nodes++;
|
|
574
|
+
return [{ kind: 'button', variant: looksLikeButton(el) ? 'cta' : 'link', text }];
|
|
575
|
+
}
|
|
401
576
|
if (tag === 'A') {
|
|
402
577
|
const text = clean(el.textContent);
|
|
403
578
|
// A LINK THAT IS NOT A BUTTON IS STILL A LINK. It used to contribute
|
|
@@ -526,7 +701,20 @@ function capturePage(limits) {
|
|
|
526
701
|
// wrapping it — so the honest translation is the stack it already reads
|
|
527
702
|
// as. Twelve is above any real row seen here and far below a content
|
|
528
703
|
// grid.
|
|
529
|
-
|
|
704
|
+
// THE CAP IS ABOUT SLIVERS, so it belongs on the containers that make
|
|
705
|
+
// them. A NOWRAP row divides one width by its column count — that is
|
|
706
|
+
// how 279 columns became 279 slivers — but a container that WRAPS never
|
|
707
|
+
// does: it lays out as many as fit and starts a new line, which is the
|
|
708
|
+
// same thing a twenty-card product shelf wants and exactly what the
|
|
709
|
+
// source was already showing.
|
|
710
|
+
//
|
|
711
|
+
// Twelve on a wrapping shelf was a rule coercing the layout rather than
|
|
712
|
+
// reading it: a shop's collection band came back as a vertical stack of
|
|
713
|
+
// cards because it had more than a dozen. The bound that remains is the
|
|
714
|
+
// whole-import node budget, which is the honest place for "this page is
|
|
715
|
+
// enormous".
|
|
716
|
+
const wraps = grid || cs.flexWrap === 'wrap';
|
|
717
|
+
const ROW_MAX = wraps ? 60 : 12;
|
|
530
718
|
if (lays && row && kids.length >= 2 && kids.length <= ROW_MAX) {
|
|
531
719
|
taken.nodes++;
|
|
532
720
|
return [{
|
|
@@ -535,7 +723,7 @@ function capturePage(limits) {
|
|
|
535
723
|
// A GRID ALWAYS WRAPS — that is what a grid IS — and `flexWrap` reads
|
|
536
724
|
// `nowrap` on one because the property does not apply. Carrying that
|
|
537
725
|
// literally gave the columns nowhere to go at any width.
|
|
538
|
-
wrap:
|
|
726
|
+
wrap: wraps,
|
|
539
727
|
children: kids,
|
|
540
728
|
}];
|
|
541
729
|
}
|
|
@@ -561,6 +749,12 @@ function capturePage(limits) {
|
|
|
561
749
|
};
|
|
562
750
|
return walk(root);
|
|
563
751
|
};
|
|
752
|
+
// AFTER `inPageChrome` exists, and that ordering is load-bearing: the reveal
|
|
753
|
+
// asks whether a container is page chrome, and a `const` arrow read before
|
|
754
|
+
// its own definition throws inside `evaluate`, which kills the whole capture.
|
|
755
|
+
// The same class as the closure trap this file already carries — a name that
|
|
756
|
+
// exists but is not yet initialised.
|
|
757
|
+
revealPanels();
|
|
564
758
|
// SECTION CANDIDATES, widest first: a page that marks its bands up
|
|
565
759
|
// semantically is read that way, and one that does not falls back to the
|
|
566
760
|
// top-level children of its main content, which is what a hand-written page
|
|
@@ -614,15 +808,119 @@ function capturePage(limits) {
|
|
|
614
808
|
return acc;
|
|
615
809
|
};
|
|
616
810
|
let sections = build(candidates);
|
|
617
|
-
// THE FALLBACK HAS TO FIRE ON
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
|
|
811
|
+
// THE FALLBACK HAS TO FIRE ON A DERISORY RESULT, not only on an empty one —
|
|
812
|
+
// and this entry has now been widened TWICE, each time by a real page.
|
|
813
|
+
//
|
|
814
|
+
// First it fired only on an empty candidate LIST, so a page offering
|
|
815
|
+
// `<section>`s that hold nothing this platform renders came back empty
|
|
816
|
+
// (tailwindcss.com: 0 of 6,004 characters, one skipped empty section). That
|
|
817
|
+
// was fixed by firing on an empty RESULT.
|
|
818
|
+
//
|
|
819
|
+
// An empty result is still the wrong test, because it treats "we captured
|
|
820
|
+
// something" as "we captured the page". MEASURED on ttgshop.vn, a shop with
|
|
821
|
+
// 2,856 divs, 602 paragraphs, 110 headings and 100 images: the whole page
|
|
822
|
+
// lives in `div.homepage`, and the document's only two `<section>` elements
|
|
823
|
+
// are a BREADCRUMB and one more. The selector privileges `<section>`
|
|
824
|
+
// absolutely, so the breadcrumb WAS the result — one section, one text node,
|
|
825
|
+
// 34 of 4,993 characters — and because that is not empty, nothing fell back.
|
|
826
|
+
// A shop imported as its own breadcrumb, with no error at any step.
|
|
827
|
+
//
|
|
828
|
+
// So the question is coverage, against the denominator this file already
|
|
829
|
+
// records as the honest one: page chrome is skipped ON PURPOSE and must not
|
|
830
|
+
// count against the result, or every correct import of a nav-heavy site would
|
|
831
|
+
// look like a failure. Below half of the non-chrome text, the candidate set
|
|
832
|
+
// was simply the wrong reading of the page, and the body walk is tried and
|
|
833
|
+
// kept only if it does better — so a page where the sections really are the
|
|
834
|
+
// content pays one comparison and keeps its own answer.
|
|
835
|
+
const textOf = (nodes) => {
|
|
836
|
+
let n = 0;
|
|
837
|
+
const walk = (c) => {
|
|
838
|
+
if (typeof c.text === 'string')
|
|
839
|
+
n += c.text.length;
|
|
840
|
+
for (const k of c.children ?? [])
|
|
841
|
+
walk(k);
|
|
842
|
+
};
|
|
843
|
+
for (const c of nodes)
|
|
844
|
+
walk(c);
|
|
845
|
+
return n;
|
|
846
|
+
};
|
|
847
|
+
let chromeChars = 0;
|
|
848
|
+
for (const el of Array.from(document.querySelectorAll('header, nav, footer'))) {
|
|
849
|
+
if (inPageChrome(el))
|
|
850
|
+
chromeChars += (el.innerText ?? '').length;
|
|
851
|
+
}
|
|
852
|
+
const contentChars = Math.max(0, (document.body.innerText ?? '').length - chromeChars);
|
|
853
|
+
// NO SIZE FLOOR ON THE CHECK. An earlier version only asked the question on
|
|
854
|
+
// pages with more than 400 characters, which is the guard you write when you
|
|
855
|
+
// fear a fallback firing too often — but the fallback cannot do harm here: it
|
|
856
|
+
// BUILDS the alternative and keeps it only if it captured more, so the worst
|
|
857
|
+
// case is one wasted walk on a page that was already right. A small page is
|
|
858
|
+
// also exactly where one stray band is the whole import.
|
|
859
|
+
if (sections.length === 0 || (contentChars > 0 && textOf(sections) < contentChars * 0.5)) {
|
|
860
|
+
// THE CONTENT ROOT, not `body` — otherwise the whole page comes back as ONE
|
|
861
|
+
// band and every arrangement the source had is gone. Measured on
|
|
862
|
+
// ttgshop.vn: `body` yields `div.homepage`, one candidate holding 9,829px of
|
|
863
|
+
// shop, so the import produced a single section with 467 text nodes in it.
|
|
864
|
+
//
|
|
865
|
+
// A real page wraps its content two or three deep before the bands start
|
|
866
|
+
// (`body > div.homepage > div.container > div.section-collection…`), so the
|
|
867
|
+
// root is found by DESCENDING while one child still holds nearly all the
|
|
868
|
+
// text. That child is a wrapper by definition — it has siblings that are
|
|
869
|
+
// scripts, chrome and empty slots — and its children are the bands.
|
|
870
|
+
// Bounded, and it stops the moment the text spreads out, which is exactly
|
|
871
|
+
// when the bands have been reached.
|
|
872
|
+
const textLen = (el) => (el.innerText ?? el.textContent ?? '').length;
|
|
873
|
+
let root = document.querySelectorAll('main')[0] ?? document.body;
|
|
874
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
875
|
+
const kids = Array.from(root.children).filter((k) => !inPageChrome(k));
|
|
876
|
+
if (kids.length === 0)
|
|
877
|
+
break;
|
|
878
|
+
const total = textLen(root);
|
|
879
|
+
let biggest = kids[0];
|
|
880
|
+
for (const k of kids)
|
|
881
|
+
if (textLen(k) > textLen(biggest))
|
|
882
|
+
biggest = k;
|
|
883
|
+
if (total < 200 || textLen(biggest) < total * 0.7)
|
|
884
|
+
break;
|
|
885
|
+
root = biggest;
|
|
886
|
+
}
|
|
887
|
+
// BOTH READINGS, AND THE ONE THAT COVERS MORE WINS. Descending finds the
|
|
888
|
+
// band split on a page that wraps its content; it LOSES material on a page
|
|
889
|
+
// whose content is spread across two wrappers, because everything outside
|
|
890
|
+
// the biggest one is left behind — measured here as 89.7% falling to 82.7%.
|
|
891
|
+
// Neither rule is right for every page and the comparison costs one walk,
|
|
892
|
+
// so the page decides rather than the heuristic.
|
|
893
|
+
// A SPECULATIVE BUILD MUST LEAVE NO TRACE, and the first version of this
|
|
894
|
+
// left three. `build` is not pure — it pushes every `<form>` it meets onto
|
|
895
|
+
// the shared list, counts every skip, and spends the image and node budget
|
|
896
|
+
// — so trying a second reading REPORTED a one-form page as having three and
|
|
897
|
+
// trebled every skip count. Caught by two tests that were already there,
|
|
898
|
+
// which is the argument for asserting side effects and not only results.
|
|
899
|
+
const snap = () => ({
|
|
900
|
+
forms: forms.length,
|
|
901
|
+
skipped: { ...skipped },
|
|
902
|
+
taken: { ...taken },
|
|
903
|
+
});
|
|
904
|
+
const restore = (was) => {
|
|
905
|
+
forms.length = was.forms;
|
|
906
|
+
for (const k of Object.keys(skipped))
|
|
907
|
+
delete skipped[k];
|
|
908
|
+
Object.assign(skipped, was.skipped);
|
|
909
|
+
taken.images = was.taken.images;
|
|
910
|
+
taken.nodes = was.taken.nodes;
|
|
911
|
+
};
|
|
912
|
+
const before = snap();
|
|
913
|
+
let bestState = snap();
|
|
624
914
|
const main = document.querySelectorAll('main')[0] ?? document.body;
|
|
625
|
-
|
|
915
|
+
for (const from of [root, main]) {
|
|
916
|
+
restore(before);
|
|
917
|
+
const wider = build(Array.from(from.children));
|
|
918
|
+
if (textOf(wider) > textOf(sections)) {
|
|
919
|
+
sections = wider;
|
|
920
|
+
bestState = snap();
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
restore(bestState);
|
|
626
924
|
}
|
|
627
925
|
const link = Array.from(document.querySelectorAll('link[rel="canonical"]'))[0];
|
|
628
926
|
const canonical = link ? (link.getAttribute('href') ?? '') : '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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",
|