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 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",
@@ -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
- return `${prefix}_${randomBytes(4).toString('hex')}`;
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: c.wrap ? 'wrap' : 'nowrap',
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) => ({ kind: 'heading', level, text });
73
- const p = (text) => ({ kind: 'text', text });
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([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')], t, { alignItems: 'center', textAlign: 'center' }, { alignItems: 'center' }),
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([h('Sẵn sàng bắt đầu?'), p('Một câu nhắc lại lời hứa chính.'), cta('Mua ngay')], t, { alignItems: 'center', textAlign: 'center' }, { alignItems: 'center' }),
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
- shots.push({ kind: 'image', src: m.url, alt: m.name ?? 'Ảnh' });
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([
@@ -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.0",
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",