sbuilder-mcp 0.32.0 → 0.32.2
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 +15 -0
- package/CHANGELOG.vi.md +15 -0
- package/dist/catalog/elements.generated.js +13 -0
- package/dist/domains/site/ids.js +28 -1
- package/dist/domains/site/importmap.js +106 -2
- package/dist/domains/site/patterns.js +65 -10
- package/dist/vision/capture.js +30 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,21 @@ 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.32.2] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- A centred section built by sb_import, sb_import_site, or the built-in layout patterns applied by sb_template_use no longer renders half left-aligned: the theme's heading preset overrides inherited text alignment, so headings and paragraphs now carry `textAlign` directly on the node when the source (or pattern) centers or right-aligns them.
|
|
13
|
+
- The gallery-wall layout pattern applied by sb_template_use now frames every photo to a shared aspect ratio measured as the median of the pictures actually picked, instead of leaving each tile its own shape, so a wall of portraits or landscapes no longer renders with mismatched row heights.
|
|
14
|
+
|
|
15
|
+
## [0.32.1] - 2026-09-11
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- sb_import, sb_import_site, and the built-in layout patterns applied by sb_template_use now cap every section's width at 1200px instead of leaving it unbounded, so an imported or generated band no longer sets prose on a 1392px line.
|
|
19
|
+
- A wrapping row of two or more columns (a photo gallery, a card shelf) is now built as a CSS grid instead of a flex row, so every cell comes out the same size instead of the last, shorter line stretching to fill the row.
|
|
20
|
+
- Headings built by sb_import, sb_import_site, and the built-in layout patterns now wear the site's own theme text style (heading-1 through heading-6, by reference) instead of the flat 48px default, so an h2 and an h3 no longer render at an identical size.
|
|
21
|
+
- A row of two unequal-weight columns (a hero's text beside its photograph) now keeps the source page's own cross-axis alignment, such as centering, instead of always aligning to the top and leaving dead space under the shorter column.
|
|
22
|
+
- Node ids generated during a build (sb_add, sb_import, sb_import_site) can no longer collide: each process now remembers every id it has issued and redraws on a repeat instead of risking one node silently overwriting another.
|
|
23
|
+
|
|
9
24
|
## [0.32.0] - 2026-09-11
|
|
10
25
|
|
|
11
26
|
### Added
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,21 @@ 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.32.2] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Một section được căn giữa do sb_import, sb_import_site, hoặc các layout pattern có sẵn được sb_template_use áp dụng giờ không còn hiển thị nửa căn trái nữa: preset heading của theme ghi đè cách căn chữ được kế thừa, nên heading và đoạn văn giờ mang `textAlign` trực tiếp trên node khi trang nguồn (hoặc pattern) căn giữa hay căn phải chúng.
|
|
13
|
+
- Layout pattern dạng tường ảnh do sb_template_use áp dụng giờ đóng khung mọi ảnh theo một tỉ lệ khung hình chung, tính bằng trung vị của các ảnh thực sự được chọn, thay vì để mỗi ô giữ nguyên hình dạng riêng, nên một tường ảnh chân dung hay ảnh ngang không còn hiển thị với chiều cao các hàng lệch nhau.
|
|
14
|
+
|
|
15
|
+
## [0.32.1] - 2026-09-11
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- sb_import, sb_import_site, và các layout pattern có sẵn được sb_template_use áp dụng giờ giới hạn chiều rộng mỗi section ở 1200px thay vì để không giới hạn, nên một band được nhập hoặc dựng sẵn không còn đặt đoạn văn trên một dòng dài 1392px.
|
|
19
|
+
- Một hàng bị wrap gồm hai cột trở lên (một dải ảnh, một kệ thẻ sản phẩm) giờ được dựng bằng CSS grid thay vì hàng flex, nên mọi ô đều có cùng kích thước thay vì dòng cuối ngắn hơn bị kéo giãn để lấp đầy hàng.
|
|
20
|
+
- Các heading do sb_import, sb_import_site, và các layout pattern có sẵn dựng ra giờ mang text style của theme site (heading-1 đến heading-6, theo tham chiếu) thay vì cỡ 48px cố định, nên một h2 và một h3 không còn hiển thị cùng một cỡ chữ.
|
|
21
|
+
- Một hàng gồm hai cột có trọng số khác nhau (phần chữ của hero cạnh ảnh của nó) giờ giữ nguyên cách căn theo trục ngang của trang nguồn, ví dụ căn giữa, thay vì luôn căn lên trên và để lại khoảng trống bên dưới cột ngắn hơn.
|
|
22
|
+
- Id của node được tạo trong lúc dựng trang (sb_add, sb_import, sb_import_site) giờ không thể trùng nhau nữa: mỗi tiến trình ghi nhớ mọi id đã cấp và tạo lại nếu bị trùng thay vì để một node âm thầm ghi đè lên node khác.
|
|
23
|
+
|
|
9
24
|
## [0.32.0] - 2026-09-11
|
|
10
25
|
|
|
11
26
|
### Added
|
|
@@ -36215,6 +36215,19 @@ export const BASE_ONLY_EXCEPTIONS = [
|
|
|
36215
36215
|
* It is also BASE-ONLY (see BASE_ONLY_CONFIG) — render/css.go emits it into the
|
|
36216
36216
|
* base lane because the config object is read with no responsive merge.
|
|
36217
36217
|
*/
|
|
36218
|
+
/**
|
|
36219
|
+
* The eight node-style keys a THEME TEXT STYLE controls, and the var prop each
|
|
36220
|
+
* compiles to: a node wears a style by setting these to
|
|
36221
|
+
* var(--wb-ts-<slug>-<prop>), never by writing the value.
|
|
36222
|
+
*
|
|
36223
|
+
* The theme ships heading-1 (48px) through heading-6, and text-1..3 — a real
|
|
36224
|
+
* type scale that every page built by these tools was ignoring, because the
|
|
36225
|
+
* mapper wrote only htmlTag and the heading-default preset pins 48px flat.
|
|
36226
|
+
*
|
|
36227
|
+
* BY REFERENCE, NEVER BY LITERAL. A literal outranks the preset beneath it
|
|
36228
|
+
* permanently and stops the node following the theme.
|
|
36229
|
+
*/
|
|
36230
|
+
export const TEXT_STYLE_KEYS = [["fontFamily", "family"], ["fontSize", "size"], ["fontWeight", "weight"], ["fontStyle", "style"], ["lineHeight", "line-height"], ["letterSpacing", "letter-spacing"], ["textTransform", "transform"], ["color", "color"]];
|
|
36218
36231
|
export const ANIMATION = {
|
|
36219
36232
|
"types": [
|
|
36220
36233
|
"fade_in",
|
package/dist/domains/site/ids.js
CHANGED
|
@@ -21,7 +21,34 @@ const PREFIXES = {
|
|
|
21
21
|
'tab-content': 'tc',
|
|
22
22
|
'tab-item': 'ti',
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Every id this process has issued, so it can never issue one twice.
|
|
26
|
+
*
|
|
27
|
+
* FOUR RANDOM BYTES IS 32 BITS, AND THAT IS THIN FOR THE JOB. The birthday
|
|
28
|
+
* bound puts a collision at roughly 1 in 34,000 across 500 draws — which
|
|
29
|
+
* sounds like never until you notice that one `sb_import_site` run mints
|
|
30
|
+
* thousands, and that the suite's own uniqueness test HIT IT: 499 unique out of
|
|
31
|
+
* 500, once, in an ordinary run.
|
|
32
|
+
*
|
|
33
|
+
* The consequence is not a warning. Two nodes sharing an id means one
|
|
34
|
+
* overwrites the other in `doc.nodes` — content silently gone, a parent's child
|
|
35
|
+
* list pointing at the survivor, and a document that validates, saves and
|
|
36
|
+
* publishes. It is the same family as every other entry in this repo's file:
|
|
37
|
+
* the failure has no error attached to it.
|
|
38
|
+
*
|
|
39
|
+
* The width stays FOUR BYTES because the platform's own ids are eight hex
|
|
40
|
+
* characters and a document this server builds should be indistinguishable
|
|
41
|
+
* from one a human built. Uniqueness comes from remembering instead — which is
|
|
42
|
+
* the scope that actually matters, since one process builds one document.
|
|
43
|
+
*/
|
|
44
|
+
const issued = new Set();
|
|
24
45
|
export function genId(type) {
|
|
25
46
|
const prefix = PREFIXES[type] ?? (type.replace(/[^a-z]/g, '').slice(0, 2) || 'nd');
|
|
26
|
-
|
|
47
|
+
for (;;) {
|
|
48
|
+
const id = `${prefix}_${randomBytes(4).toString('hex')}`;
|
|
49
|
+
if (issued.has(id))
|
|
50
|
+
continue;
|
|
51
|
+
issued.add(id);
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
27
54
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { walk } from '../../core/tree.js';
|
|
2
2
|
import { stickySeeds } from './sticky.js';
|
|
3
|
+
import { TEXT_STYLE_KEYS } from '../../catalog/elements.generated.js';
|
|
3
4
|
import { normalizeUrl } from './discover.js';
|
|
4
5
|
import { ICON_NAMES } from '../../catalog/icons.generated.js';
|
|
5
6
|
/** Style keys read off a node, ignoring anything unset. */
|
|
@@ -101,17 +102,63 @@ function iconFor(raw) {
|
|
|
101
102
|
* empty node is the difference between an imported page and an imported page
|
|
102
103
|
* plus twenty findings from `sb_review`.
|
|
103
104
|
*/
|
|
105
|
+
/**
|
|
106
|
+
* THE SITE'S OWN TYPE SCALE, worn by reference.
|
|
107
|
+
*
|
|
108
|
+
* The theme ships `heading-1` (48px) through `heading-6` (16px) and `text-1`..
|
|
109
|
+
* `text-3`, and every page these tools built ignored all of it: the mapper
|
|
110
|
+
* wrote `htmlTag` and nothing else, so a heading took the `heading-default`
|
|
111
|
+
* preset, which pins `fontSize: 48px` FLAT. MEASURED on a real build — a
|
|
112
|
+
* section title and the three item titles beneath it came out identical, on a
|
|
113
|
+
* page whose document correctly said h2 and h3. "About four type sizes" is the
|
|
114
|
+
* checklist item; the page had one.
|
|
115
|
+
*
|
|
116
|
+
* BY REFERENCE, NEVER BY LITERAL — `var(--wb-ts-<slug>-<prop>)` is what the
|
|
117
|
+
* editor's own picker stamps. A literal `36px` would outrank the preset beneath
|
|
118
|
+
* it permanently and stop the node following the theme, which is the detachment
|
|
119
|
+
* this repo already records for imported icons.
|
|
120
|
+
*
|
|
121
|
+
* SIZE AND LINE HEIGHT ONLY, of the eight keys a style controls. Colour and
|
|
122
|
+
* weight are already answered by the tokens read off the target page (rule 0),
|
|
123
|
+
* and overwriting those here would make an imported band stop matching the page
|
|
124
|
+
* it landed on — which is the one thing the token pass exists to prevent. The
|
|
125
|
+
* editor supports exactly this partial state: the pick is recorded so the
|
|
126
|
+
* picker shows it, and it can also show that the style has drifted.
|
|
127
|
+
*
|
|
128
|
+
* A theme with no such style leaves the var undefined, so every ref carries the
|
|
129
|
+
* element's own former answer as its CSS fallback: a site on a slimmer theme
|
|
130
|
+
* renders exactly as it did before this existed.
|
|
131
|
+
*/
|
|
132
|
+
function textScale(slug, fallback) {
|
|
133
|
+
const style = {};
|
|
134
|
+
for (const [key, prop] of TEXT_STYLE_KEYS) {
|
|
135
|
+
if (key !== 'fontSize' && key !== 'lineHeight')
|
|
136
|
+
continue;
|
|
137
|
+
const fb = fallback[key];
|
|
138
|
+
style[key] = `var(--wb-ts-${slug}-${prop}${fb ? `, ${fb}` : ''})`;
|
|
139
|
+
}
|
|
140
|
+
return { style, config: { textGlobalStyle: slug } };
|
|
141
|
+
}
|
|
104
142
|
function one(c, t) {
|
|
105
143
|
switch (c.kind) {
|
|
106
144
|
case 'heading': {
|
|
107
145
|
const text = c.text?.trim();
|
|
108
146
|
if (!text)
|
|
109
147
|
return null;
|
|
148
|
+
// The level is already decided and already correct in the document; it
|
|
149
|
+
// simply reached no CSS. heading-1..6 are the theme's own slugs.
|
|
150
|
+
const scale = textScale(`heading-${Math.min(6, Math.max(1, c.level ?? 2))}`, {});
|
|
110
151
|
return {
|
|
111
152
|
type: 'heading',
|
|
112
153
|
specials: { htmlTag: headingTag(c.level), text },
|
|
154
|
+
config: scale.config,
|
|
113
155
|
style: {
|
|
114
156
|
margin: '0',
|
|
157
|
+
...scale.style,
|
|
158
|
+
// ON THE NODE, never left to inheritance: the theme's heading preset
|
|
159
|
+
// declares its own textAlign, and a class rule beats an inherited
|
|
160
|
+
// value.
|
|
161
|
+
...(c.textAlign ? { textAlign: c.textAlign } : {}),
|
|
115
162
|
...(t.headingColor ? { color: t.headingColor } : {}),
|
|
116
163
|
...(t.headingWeight ? { fontWeight: t.headingWeight } : {}),
|
|
117
164
|
},
|
|
@@ -126,6 +173,7 @@ function one(c, t) {
|
|
|
126
173
|
specials: { htmlTag: 'p', text },
|
|
127
174
|
style: {
|
|
128
175
|
lineHeight: '1.7',
|
|
176
|
+
...(c.textAlign ? { textAlign: c.textAlign } : {}),
|
|
129
177
|
...(t.textColor ? { color: t.textColor } : {}),
|
|
130
178
|
...(t.textSize ? { fontSize: t.textSize } : {}),
|
|
131
179
|
},
|
|
@@ -245,6 +293,27 @@ function one(c, t) {
|
|
|
245
293
|
// a width/height ATTRIBUTE is a used height, and without this the cap below
|
|
246
294
|
// would be the thing ignored. `contain` because the source's crop is not
|
|
247
295
|
// ours to guess.
|
|
296
|
+
// A FRAME, WHEN THE CALLER MEASURED ONE. `height: auto` is what makes
|
|
297
|
+
// `aspect-ratio` work here at all — the platform writes the intrinsic
|
|
298
|
+
// width/height ATTRIBUTES onto every <img>, and a presentational height is
|
|
299
|
+
// a USED height, so the ratio would otherwise be ignored. That is the same
|
|
300
|
+
// fix the platform applied to its own media CSS.
|
|
301
|
+
//
|
|
302
|
+
// `cover` only inside a frame: filling one means cropping, which is the
|
|
303
|
+
// trade a wall of tiles makes on purpose. With no frame the answer stays
|
|
304
|
+
// `contain` — the source's crop is not ours to guess.
|
|
305
|
+
if (c.ratio) {
|
|
306
|
+
return {
|
|
307
|
+
type: 'image',
|
|
308
|
+
specials: { src: c.src, alt: c.alt ?? '' },
|
|
309
|
+
style: {
|
|
310
|
+
width: '100%',
|
|
311
|
+
height: 'auto',
|
|
312
|
+
aspectRatio: c.ratio,
|
|
313
|
+
objectFit: 'cover',
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
248
317
|
return {
|
|
249
318
|
type: 'image',
|
|
250
319
|
specials: { src: c.src, alt: c.alt ?? '' },
|
|
@@ -336,6 +405,41 @@ function one(c, t) {
|
|
|
336
405
|
}
|
|
337
406
|
if (kids.length === 1)
|
|
338
407
|
return kids[0];
|
|
408
|
+
// A WRAPPING ROW IS A GRID, because flex cannot wrap into EQUAL CELLS.
|
|
409
|
+
//
|
|
410
|
+
// `flex: 1 1 <basis>` lets every item absorb the free space on ITS OWN
|
|
411
|
+
// LINE, so a short last line is a disaster: MEASURED on a page built by
|
|
412
|
+
// these tools, a gallery of five photographs came out as four cells of
|
|
413
|
+
// 330x220 and a fifth of 1392x420 — the same picture, four times the size,
|
|
414
|
+
// under the others. Nothing reported it, because no box overflowed and no
|
|
415
|
+
// two boxes overlapped; `measure` cannot see a cell that is merely wrong.
|
|
416
|
+
//
|
|
417
|
+
// Dropping the grow factor instead (`0 1 280px`) fixes the blow-up and
|
|
418
|
+
// buys a ragged right edge on every full line. `repeat(auto-fill,
|
|
419
|
+
// minmax(280px, 1fr))` is the thing actually wanted and the platform
|
|
420
|
+
// renders it — verified against a live server before this was written:
|
|
421
|
+
// as many cells as fit, all equal, the last line cell-sized like the rest.
|
|
422
|
+
//
|
|
423
|
+
// Mobile gets one column by name. The flex path's `flexDirection: column`
|
|
424
|
+
// says nothing to a grid, and a mobile answer that silently does nothing
|
|
425
|
+
// is rule 3 failing with a value in the document to prove it tried.
|
|
426
|
+
if (c.wrap) {
|
|
427
|
+
return {
|
|
428
|
+
type: 'flex-block',
|
|
429
|
+
style: {
|
|
430
|
+
width: '100%',
|
|
431
|
+
display: 'grid',
|
|
432
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
433
|
+
gap: '24px',
|
|
434
|
+
},
|
|
435
|
+
responsive: { mobile: { style: { gridTemplateColumns: '1fr', gap: '16px' } } },
|
|
436
|
+
children: kids.map((k) => ({
|
|
437
|
+
type: 'flex-block',
|
|
438
|
+
style: { width: '100%', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '12px' },
|
|
439
|
+
children: [k],
|
|
440
|
+
})),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
339
443
|
// A ROW OF TWO OR MORE COLUMNS NEEDS AN EXPLICIT STACK BREAKPOINT, and
|
|
340
444
|
// nothing catches it for you: the columns SHRINK to fit, so no box
|
|
341
445
|
// overflows and `measure` stays silent while a photo becomes a sliver and
|
|
@@ -352,8 +456,8 @@ function one(c, t) {
|
|
|
352
456
|
width: '100%',
|
|
353
457
|
display: 'flex',
|
|
354
458
|
flexDirection: 'row',
|
|
355
|
-
flexWrap:
|
|
356
|
-
alignItems: 'flex-start',
|
|
459
|
+
flexWrap: 'nowrap',
|
|
460
|
+
alignItems: c.align && c.align !== 'start' ? c.align : 'flex-start',
|
|
357
461
|
gap: '24px',
|
|
358
462
|
},
|
|
359
463
|
responsive: { mobile: { style: { flexDirection: 'column', gap: '16px' } } },
|
|
@@ -43,6 +43,19 @@ export const THEME_TOKENS = {
|
|
|
43
43
|
textColor: 'var(--wb-color-text)',
|
|
44
44
|
buttonBg: 'var(--wb-color-primary)',
|
|
45
45
|
buttonColor: '#ffffff',
|
|
46
|
+
// A PAGE WITH NO MEASURE IS NOT AN UNSTYLED PAGE, IT IS A WRONGLY STYLED ONE.
|
|
47
|
+
//
|
|
48
|
+
// `sectionMaxWidth` is read off the TARGET page so an imported band matches
|
|
49
|
+
// what is already there — and a blank page offers nothing to read, so it came
|
|
50
|
+
// out unbounded. MEASURED at 1440 on a page built entirely by these tools:
|
|
51
|
+
// every block 1392px wide, every heading and paragraph set on a 1392px line.
|
|
52
|
+
// That is roughly 200 characters where prose is readable at 60-75, and it is
|
|
53
|
+
// the single loudest way a generated page announces itself.
|
|
54
|
+
//
|
|
55
|
+
// Unbounded was a decision too, and the worse one. This is the same class of
|
|
56
|
+
// answer as the `64px 24px` padding and the `16px` gap the mapper already
|
|
57
|
+
// commits to for a page that cannot answer for itself.
|
|
58
|
+
sectionMaxWidth: '1200px',
|
|
46
59
|
};
|
|
47
60
|
/** One section, through the same mapper an import goes through. */
|
|
48
61
|
function section(children, t, style, innerStyle) {
|
|
@@ -63,14 +76,24 @@ function section(children, t, style, innerStyle) {
|
|
|
63
76
|
children: [{ ...inner, style: { ...inner.style, ...innerStyle } }, ...rest],
|
|
64
77
|
};
|
|
65
78
|
}
|
|
66
|
-
const row = (children, wrap = false) => ({
|
|
79
|
+
const row = (children, wrap = false, align) => ({
|
|
67
80
|
kind: 'group',
|
|
68
81
|
direction: 'row',
|
|
69
82
|
wrap,
|
|
83
|
+
...(align ? { align } : {}),
|
|
70
84
|
children,
|
|
71
85
|
});
|
|
72
|
-
const h = (text, level = 2) => ({
|
|
73
|
-
|
|
86
|
+
const h = (text, level = 2, textAlign) => ({
|
|
87
|
+
kind: 'heading',
|
|
88
|
+
level,
|
|
89
|
+
text,
|
|
90
|
+
...(textAlign ? { textAlign } : {}),
|
|
91
|
+
});
|
|
92
|
+
const p = (text, textAlign) => ({
|
|
93
|
+
kind: 'text',
|
|
94
|
+
text,
|
|
95
|
+
...(textAlign ? { textAlign } : {}),
|
|
96
|
+
});
|
|
74
97
|
const cta = (text, href = '#') => ({ kind: 'button', variant: 'cta', text, href });
|
|
75
98
|
/**
|
|
76
99
|
* The picture slot: a real image when the site has one, and WORDS when it does
|
|
@@ -97,6 +120,10 @@ export const LAYOUT_PATTERNS = [
|
|
|
97
120
|
build: (t, pool) => {
|
|
98
121
|
const used = new Set();
|
|
99
122
|
return section([
|
|
123
|
+
// CENTRED, because a hero's two columns are unequal BY DESIGN: a few
|
|
124
|
+
// words on one side, a photograph on the other. Top-aligned they
|
|
125
|
+
// measured 154px beside 420px, and the band read as a caption that
|
|
126
|
+
// had slipped off the picture.
|
|
100
127
|
row([
|
|
101
128
|
{ kind: 'group', direction: 'column', children: [h('Tiêu đề chính', 1), p('Một câu nói rõ bạn bán gì và cho ai.'), cta('Mua ngay')] },
|
|
102
129
|
{
|
|
@@ -106,7 +133,7 @@ export const LAYOUT_PATTERNS = [
|
|
|
106
133
|
img(pick(pool, used, 'landscape'), 'Ảnh mở đầu', 'Chưa có ảnh nào trong thư viện — sb_media_upload một URL, rồi sb_set src lên khối này.'),
|
|
107
134
|
],
|
|
108
135
|
},
|
|
109
|
-
]),
|
|
136
|
+
], false, 'center'),
|
|
110
137
|
], t);
|
|
111
138
|
},
|
|
112
139
|
},
|
|
@@ -114,7 +141,10 @@ export const LAYOUT_PATTERNS = [
|
|
|
114
141
|
id: 'sb_hero_centered',
|
|
115
142
|
name: 'Hero — canh giữa',
|
|
116
143
|
use: 'Mở đầu trang khi chưa có ảnh: tiêu đề lớn, một câu, một nút',
|
|
117
|
-
build: (t) => section(
|
|
144
|
+
build: (t) => section(
|
|
145
|
+
// Centred on the NODES, not left to the section's inherited textAlign —
|
|
146
|
+
// the theme's heading preset declares its own and wins.
|
|
147
|
+
[h('Tiêu đề chính', 1, 'center'), p('Một câu nói rõ bạn bán gì và cho ai.', 'center'), cta('Mua ngay')], t, { alignItems: 'center', textAlign: 'center' }, { alignItems: 'center' }),
|
|
118
148
|
},
|
|
119
149
|
{
|
|
120
150
|
id: 'sb_feature_trio',
|
|
@@ -135,9 +165,9 @@ export const LAYOUT_PATTERNS = [
|
|
|
135
165
|
use: 'Ba đến bốn con số lớn với nhãn bên dưới — bằng chứng, không phải lời hứa',
|
|
136
166
|
build: (t) => section([
|
|
137
167
|
row([
|
|
138
|
-
{ kind: 'group', direction: 'column', children: [h('1.000+', 2), p('Khách hàng')] },
|
|
139
|
-
{ kind: 'group', direction: 'column', children: [h('4,9/5', 2), p('Đánh giá trung bình')] },
|
|
140
|
-
{ kind: 'group', direction: 'column', children: [h('24h', 2), p('Giao trong nội thành')] },
|
|
168
|
+
{ kind: 'group', direction: 'column', children: [h('1.000+', 2, 'center'), p('Khách hàng', 'center')] },
|
|
169
|
+
{ kind: 'group', direction: 'column', children: [h('4,9/5', 2, 'center'), p('Đánh giá trung bình', 'center')] },
|
|
170
|
+
{ kind: 'group', direction: 'column', children: [h('24h', 2, 'center'), p('Giao trong nội thành', 'center')] },
|
|
141
171
|
]),
|
|
142
172
|
], t, { alignItems: 'center', textAlign: 'center' }, { alignItems: 'center' }),
|
|
143
173
|
},
|
|
@@ -145,7 +175,11 @@ export const LAYOUT_PATTERNS = [
|
|
|
145
175
|
id: 'sb_cta_band',
|
|
146
176
|
name: 'Dải kêu gọi hành động',
|
|
147
177
|
use: 'Một câu và một nút, đặt cuối trang hoặc giữa hai band nội dung',
|
|
148
|
-
build: (t) => section(
|
|
178
|
+
build: (t) => section(
|
|
179
|
+
// CENTRED ON THE NODES. The section's own `textAlign` is inherited, and
|
|
180
|
+
// the theme's heading preset declares its own — so the band came out
|
|
181
|
+
// with its words hard left and only the button in the middle.
|
|
182
|
+
[h('Sẵn sàng bắt đầu?', 2, 'center'), p('Một câu nhắc lại lời hứa chính.', 'center'), cta('Mua ngay')], t, { alignItems: 'center', textAlign: 'center' }, { alignItems: 'center' }),
|
|
149
183
|
},
|
|
150
184
|
{
|
|
151
185
|
id: 'sb_gallery',
|
|
@@ -158,11 +192,32 @@ export const LAYOUT_PATTERNS = [
|
|
|
158
192
|
// SIX IS A WALL, THREE IS A ROW. Bounded because a gallery is a design
|
|
159
193
|
// decision and not a dump of the library — a merchant with 164 assets does
|
|
160
194
|
// not want 164 of them in one band.
|
|
195
|
+
const picked = [];
|
|
161
196
|
for (let i = 0; i < 6; i += 1) {
|
|
162
197
|
const m = pick(pool, used, 'any');
|
|
163
198
|
if (!m)
|
|
164
199
|
break;
|
|
165
|
-
|
|
200
|
+
picked.push(m);
|
|
201
|
+
}
|
|
202
|
+
// ONE FRAME FOR THE WALL, MEASURED RATHER THAN INVENTED.
|
|
203
|
+
//
|
|
204
|
+
// Rule 6 says match a frame's ratio to the ASSET, and a wall of
|
|
205
|
+
// photographs is the case it does not cover: there is no single asset.
|
|
206
|
+
// Left alone, every tile keeps its own shape and the grid's rows come out
|
|
207
|
+
// different heights — measured on a real build, four tiles at 330x220 and
|
|
208
|
+
// a fifth noticeably taller, which reads as unfinished.
|
|
209
|
+
//
|
|
210
|
+
// The MEDIAN of the pictures actually being shown is the honest frame:
|
|
211
|
+
// most crop by nothing, the outliers crop least, and a library of
|
|
212
|
+
// portraits gets a portrait wall rather than a landscape one imposed on
|
|
213
|
+
// it. A picture whose size the library did not report votes for nothing.
|
|
214
|
+
const ratios = picked
|
|
215
|
+
.map((m) => (m.width && m.height ? m.width / m.height : 0))
|
|
216
|
+
.filter((r) => r > 0)
|
|
217
|
+
.sort((a, b) => a - b);
|
|
218
|
+
const frame = ratios.length ? ratios[Math.floor(ratios.length / 2)].toFixed(2) : undefined;
|
|
219
|
+
for (const m of picked) {
|
|
220
|
+
shots.push({ kind: 'image', src: m.url, alt: m.name ?? 'Ảnh', ...(frame ? { ratio: frame } : {}) });
|
|
166
221
|
}
|
|
167
222
|
if (shots.length === 0) {
|
|
168
223
|
return section([
|
package/dist/vision/capture.js
CHANGED
|
@@ -274,6 +274,23 @@ function capturePage(limits) {
|
|
|
274
274
|
* loader is displaying until the real one arrives, which is precisely the
|
|
275
275
|
* placeholder this import must not ship.
|
|
276
276
|
*/
|
|
277
|
+
/**
|
|
278
|
+
* The SOURCE'S OWN text alignment, and only when it is not the default.
|
|
279
|
+
*
|
|
280
|
+
* A hero that centres its copy is making a decision; a copy that left-aligns
|
|
281
|
+
* it is not the same band. `start`/`left` is the browser's answer and saying
|
|
282
|
+
* it out loud would put a value on every imported paragraph for nothing —
|
|
283
|
+
* and worse, would override the target page's own centred preset with a
|
|
284
|
+
* literal.
|
|
285
|
+
*/
|
|
286
|
+
const alignOf = (el) => {
|
|
287
|
+
const a = getComputedStyle(el).textAlign;
|
|
288
|
+
if (a === 'center')
|
|
289
|
+
return { textAlign: 'center' };
|
|
290
|
+
if (a === 'right' || a === 'end')
|
|
291
|
+
return { textAlign: 'right' };
|
|
292
|
+
return {};
|
|
293
|
+
};
|
|
277
294
|
const LAZY_SRC = ['data-src', 'data-original', 'data-lazy-src', 'data-lazy', 'data-echo', 'data-url'];
|
|
278
295
|
const widest = (srcset) => {
|
|
279
296
|
let best = '';
|
|
@@ -657,7 +674,7 @@ function capturePage(limits) {
|
|
|
657
674
|
if (!text)
|
|
658
675
|
return [];
|
|
659
676
|
taken.nodes++;
|
|
660
|
-
return [{ kind: 'heading', level: Number(tag.slice(1)), text }];
|
|
677
|
+
return [{ kind: 'heading', level: Number(tag.slice(1)), text, ...alignOf(el) }];
|
|
661
678
|
}
|
|
662
679
|
if (tag === 'IMG') {
|
|
663
680
|
const src = realSrc(el);
|
|
@@ -801,7 +818,7 @@ function capturePage(limits) {
|
|
|
801
818
|
if (!text)
|
|
802
819
|
return [];
|
|
803
820
|
taken.nodes++;
|
|
804
|
-
return [{ kind: 'text', text }];
|
|
821
|
+
return [{ kind: 'text', text, ...alignOf(el) }];
|
|
805
822
|
}
|
|
806
823
|
const kids = walkChildren(el);
|
|
807
824
|
if (kids.length > 0) {
|
|
@@ -838,6 +855,16 @@ function capturePage(limits) {
|
|
|
838
855
|
// cards because it had more than a dozen. The bound that remains is the
|
|
839
856
|
// whole-import node budget, which is the honest place for "this page is
|
|
840
857
|
// enormous".
|
|
858
|
+
// THE SOURCE'S OWN CROSS-AXIS ANSWER. A hero that centres its words
|
|
859
|
+
// against a tall photograph is making a layout decision, and a copy
|
|
860
|
+
// that top-aligns them is not the same band — the same reasoning the
|
|
861
|
+
// `pinned` read already carries. Only the three values that mean
|
|
862
|
+
// something here; `stretch` is the browser's default and saying it
|
|
863
|
+
// would put a value in every imported row for nothing.
|
|
864
|
+
const av = cs.alignItems;
|
|
865
|
+
const align = av === 'center' ? 'center'
|
|
866
|
+
: av === 'flex-end' || av === 'end' ? 'end'
|
|
867
|
+
: undefined;
|
|
841
868
|
const wraps = grid || cs.flexWrap === 'wrap';
|
|
842
869
|
const ROW_MAX = wraps ? 60 : 12;
|
|
843
870
|
if (lays && row && kids.length >= 2 && kids.length <= ROW_MAX) {
|
|
@@ -849,6 +876,7 @@ function capturePage(limits) {
|
|
|
849
876
|
// `nowrap` on one because the property does not apply. Carrying that
|
|
850
877
|
// literally gave the columns nowhere to go at any width.
|
|
851
878
|
wrap: wraps,
|
|
879
|
+
...(align ? { align } : {}),
|
|
852
880
|
children: kids,
|
|
853
881
|
}];
|
|
854
882
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.2",
|
|
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",
|