similarbuild 0.3.4 → 0.3.5
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "similarbuild",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Visual migration framework for Claude Code — clone a live page, get a paste-ready WordPress/Elementor or Shopify section file, validated and auto-corrected.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -85,6 +85,13 @@ A single `.html` file written to `outputPath`. The file is a fragment — no `<h
|
|
|
85
85
|
|
|
86
86
|
Recipe: emit STRUCTURAL placeholder (heading from DOM if available + `<div class="placeholder-grid">` with N visual placeholder-cards) + `<!-- TODO: <widget-name or section-description> — content not visible in crop, integrate at deploy -->`. Mount-div alone (empty `<div>`) is FORBIDDEN — placeholder must have visible structure so the deployed page is not visually broken.
|
|
87
87
|
|
|
88
|
+
2.5. **CARD-COUNT cross-validation (V03-5)**. When the section is a grid/carousel of repeating cards (products, reviews, trust badges, gallery thumbs, testimonial photos), the crop may only show the first row OR the first few items of a scroll-x carousel. Composer MUST count cards from `inspection.domLive` (or `inspection.dom`) recursively, NOT just from the crop. Recipe:
|
|
89
|
+
- Find the section's root node in domLive (by bbox.y match or sectionType).
|
|
90
|
+
- Walk children, count direct repeat-pattern items: `.product-card`, `.review-card`, `.feature-card`, `<li>` inside `<ul>` carousel, `[role="listitem"]`, etc.
|
|
91
|
+
- If DOM count > crop visible count → emit DOM count items, with `<img>` placeholder + literal text (cross-validated) for the items visible in crop, and `<img>` placeholder + `<!-- TODO: card N text below the fold -->` for items beyond crop.
|
|
92
|
+
- This fixes bugs 3.4 (home only 3 cards), 3.6 (trust badges only 2 of 4), 4.1 (PDP gallery only 1 thumb of 6), 6.1 (collection only 1 of 3 products).
|
|
93
|
+
- **NEVER emit fewer items than DOM has.** Crop visibility is sample, DOM is authority for structure.
|
|
94
|
+
|
|
88
95
|
3. **Hybridize sources by field type** (applies to B, C, D):
|
|
89
96
|
- **Texts/structure/counts** → crop, cross-validated against DOM.
|
|
90
97
|
- **Hrefs** → `inspection.domLive` / `hydratedHeader` / `hydratedFooter` / `imgUrls` by matching visible link text. No match → `href="#"` + TODO.
|
|
@@ -167,24 +174,118 @@ A single `.html` file written to `outputPath`. The file is a fragment — no `<h
|
|
|
167
174
|
|
|
168
175
|
When `--target-section=header` or `--target-section=footer` is passed AND the corresponding `inspection.hydratedHeader` / `inspection.hydratedFooter` is non-null, follow this composition recipe instead of the generic A-H pattern lookup:
|
|
169
176
|
|
|
170
|
-
**Footer recipe** (when `hydratedFooter` present):
|
|
177
|
+
**Footer recipe** (when `hydratedFooter` present) — v0.3.5 rewrite:
|
|
178
|
+
|
|
179
|
+
**Composition order = DOM order of `hydratedFooter.html`.** Walk the raw HTML once to capture the visual sequence (e.g. brand → newsletter → menu columns → Need Help → social → payments → copyright). DO NOT invent an order that differs from the source — the live site's order is the canonical reference.
|
|
180
|
+
|
|
181
|
+
Use `hydratedFooter.blocks[]` (new in v0.3.5) as primary structured data — each block has `{heading, paragraphs[], links[], mailtos[]}` already grouped per source heading.
|
|
182
|
+
|
|
183
|
+
1. **Outer shell.** `<footer class="es-footer">` with reset + `box-sizing: border-box` + background from `inspection.tokens.colors.background` (fallback `#fafafa`).
|
|
184
|
+
|
|
185
|
+
2. **Brand row (top).** When `hydratedFooter.images[]` includes a logo-looking image (alt matching brand name, or first image of the footer), emit `<a class="es-footer__brand" href="/"><img src="{assetsMap.localPath}"></a>`. Immediately below, if `hydratedFooter.paragraphs[0]` exists AND is long enough to be the brand description (typically >50 chars, sits ABOVE the column area in the live source), emit `<p class="es-footer__brand-desc">{paragraphs[0] verbatim}</p>`. This is the "Socks can be more powerful..." copy in everstride — DO NOT skip.
|
|
186
|
+
|
|
187
|
+
3. **Newsletter (after brand description).** If `hydratedFooter.forms[]` non-empty AND `inputs[]` contains an `email`-typed input, emit `<form action="{form.action}" method="{form.method}">` with email input (preserve `name`, `placeholder`, `required`, `aria-label`) + all hidden inputs verbatim + `<button type="submit">`. **Position: directly below brand desc, ABOVE the menu columns.** Newsletter is high-conversion CTA — keep it where the live placed it.
|
|
188
|
+
|
|
189
|
+
4. **Menu columns side-by-side (NOT stacked).** Take `hydratedFooter.blocks[]` filtered to those with `links.length >= 2` (menu-style blocks). Emit them as a grid:
|
|
190
|
+
```css
|
|
191
|
+
.es-footer__cols.es-footer__cols {
|
|
192
|
+
display: grid;
|
|
193
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
194
|
+
gap: 24px;
|
|
195
|
+
}
|
|
196
|
+
@media (min-width: 750px) {
|
|
197
|
+
.es-footer__cols.es-footer__cols { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
For each menu block: emit column with `<p class="es-footer__col-title">{block.heading.text verbatim}</p>` + `<ul>` of `<li><a href="{link.href}">{link.text verbatim}</a></li>` from `block.links`. **NEVER stack vertically on mobile** — at least 2 columns side-by-side is the canonical layout, otherwise footer becomes an absurd long vertical strip (bug 2.4).
|
|
201
|
+
|
|
202
|
+
5. **Info blocks (Need Help? and similar).** Blocks with `paragraphs.length >= 1` AND `links.length < 2` are info-text blocks. For each: emit column with heading + `block.paragraphs[]` as `<p>` verbatim + `mailto:` links from `block.mailtos[]` as `<a href="mailto:{email}">{email}</a>`. This captures "Need Help? + Have a question? Email us at info@... + Our friendly support team is available 24/7..." (bug 2.5b — was emitting only heading without paragraphs).
|
|
203
|
+
|
|
204
|
+
6. **Social icons.** Detect from `hydratedFooter.links` filtered by `href` matching `/facebook|instagram|tiktok|youtube|x\.com|twitter|pinterest/i`. Emit as `<ul class="es-footer__social">` with inline SVG icons (use `hydratedFooter.inlineSvgs[]` matching parent class containing `social`; fall back to bundled FB/IG inline SVG patterns). Position: between menu columns and payment icons (or wherever the source places them).
|
|
205
|
+
|
|
206
|
+
7. **Payment icons.** Detect by EITHER:
|
|
207
|
+
- `hydratedFooter.images[]` with `alt` matching `/amazon|visa|mastercard|amex|apple pay|google pay|discover|diners|shop pay|paypal/i`, OR
|
|
208
|
+
- `hydratedFooter.inlineSvgs[]` with `ariaLabel`, `title`, or `parentClass` matching the same patterns, OR
|
|
209
|
+
- `hydratedFooter.inlineSvgs[]` where `parentClass` contains `payment-icons` / `footer__payment`.
|
|
210
|
+
Emit as `<ul class="es-footer__payments">` with `<img>` (resolved via assetsMap) OR inline SVG verbatim. If none of these match BUT you can see payment icons in the section crop, emit `<!-- TODO: payment icons visible in crop but not capturable from DOM -->`. NEVER skip the payment row when source had it (bug 2.1).
|
|
211
|
+
|
|
212
|
+
8. **Copyright.** Capture `© YEAR, Brand.` verbatim from `hydratedFooter.html` (typically last `<p>` in the footer matching `/^(©|copyright)/i`). Emit `<p class="es-footer__copyright">© 2026, Everstride.</p>` — **verbatim only, NEVER add "All rights reserved" or any other plausible-sounding suffix** (bug 2.5a fabrication regression).
|
|
171
213
|
|
|
172
|
-
|
|
173
|
-
2. **Grouping.** Split `hydratedFooter.links` into clusters by their adjacent heading in `hydratedFooter.headings`. Order of headings reflects the live DOM order. For each heading: emit a column with `<p class="footer__col-title">{heading.text}</p>` followed by `<ul>` of `<li><a href>` from the links bucketed under that heading.
|
|
174
|
-
- Heuristic for bucketing: walk `hydratedFooter.html` once (in your head, you have the raw outerHTML in `hydratedFooter.html` if needed) — links that appear AFTER a heading and BEFORE the next heading belong to that heading. If you can't disambiguate, fall back to grouping by `href` prefix patterns (`/policies/` → "More Information", `/products/` → "Collections", `/pages/` → split by name).
|
|
175
|
-
3. **Newsletter.** If `hydratedFooter.forms[]` is non-empty AND `hydratedFooter.inputs[]` contains an `email`-typed input, emit a real `<form action="{form.action}" method="{form.method}">` with the email input, preserving `name`, `placeholder`, `required`, `aria-label`. Hidden inputs (`type=hidden`) are preserved verbatim — they're typically Shopify form-type tokens. Add a submit `<button type="submit">` even if not in the source.
|
|
176
|
-
4. **Social media.** Detect social links by `href` matching `/facebook|instagram|tiktok|youtube|x\.com|twitter|pinterest/`. Group in a separate `<ul class="footer__social">` with inline SVG icons (you can ship the standard FB/IG icons from the bundled patterns). Skip if no matches.
|
|
177
|
-
5. **Payment icons.** Detect by `hydratedFooter.images[]` with `alt` matching `/amazon|visa|mastercard|amex|apple pay|google pay|discover|diners|shop pay|paypal/i` — emit those as a `<ul class="footer__payments">` with `<img>` referencing the `assetsMap` (resolve src via the standard pipeline). If `images[]` is empty but you'd expect them, fall back to text labels.
|
|
178
|
-
6. **Copyright.** If any link or heading text matches `© YEAR, Brand.`, preserve verbatim at the bottom.
|
|
179
|
-
7. **NO image-slice fallback.** When hydrated data is present, never compose a single `<img>` of the footer. The hydrated payload gives everything needed for real markup.
|
|
214
|
+
9. **Cross-validate every literal** before emit: every block heading, every link text, every paragraph, every copyright text MUST appear verbatim in `hydratedFooter.html`. Validator (`build-wp.mjs validate --inspection-path`) will catch fabrications; pre-validate yourself to avoid rework.
|
|
180
215
|
|
|
181
|
-
|
|
216
|
+
10. **NO image-slice fallback.** When `hydratedFooter` is present, never compose a single `<img>` of the footer. Hydrated payload always provides enough for real markup.
|
|
217
|
+
|
|
218
|
+
**Header recipe** (when `hydratedHeader` present) — v0.3.5 rewrite:
|
|
219
|
+
|
|
220
|
+
1. **Announcement bar FIRST (when `inspection.hydratedAnnouncementBar` is non-null).** This is captured separately in v0.3.5 — typically a `<aside>` sibling above the `<header>` carrying promotional text ("Mother's Day Sale", "Free Shipping over $X", etc.). Emit as the very first element of `clean/global/header.html`:
|
|
221
|
+
```html
|
|
222
|
+
<div class="es-announcement-bar.es-announcement-bar">
|
|
223
|
+
{hydratedAnnouncementBar.text verbatim}
|
|
224
|
+
</div>
|
|
225
|
+
```
|
|
226
|
+
With CSS giving it the pink/promo background read from the crop. **NEVER skip the announcement bar** when it was captured — bug 1.1 (banner not in header) AND bug 3.1 (banner ended up inline in body) both trace to this being omitted. Also remove from per-page body via stripper.
|
|
227
|
+
|
|
228
|
+
2. **Outer shell.** `<header class="es-header">` below the announcement bar.
|
|
229
|
+
|
|
230
|
+
3. **Layout: grid with 3 zones (centered logo).** Default mobile + desktop:
|
|
231
|
+
```css
|
|
232
|
+
.es-header.es-header {
|
|
233
|
+
display: grid;
|
|
234
|
+
grid-template-columns: auto 1fr auto;
|
|
235
|
+
align-items: center;
|
|
236
|
+
padding: 12px 16px;
|
|
237
|
+
}
|
|
238
|
+
.es-header.es-header > .es-header__left { justify-self: start; }
|
|
239
|
+
.es-header.es-header > .es-header__brand { justify-self: center; }
|
|
240
|
+
.es-header.es-header > .es-header__right { justify-self: end; }
|
|
241
|
+
```
|
|
242
|
+
This guarantees the brand stays VISUALLY centered regardless of how many icons are in left/right clusters — fixes bug 1.3 (logo descentralizada).
|
|
243
|
+
|
|
244
|
+
4. **Brand (center).** If `hydratedHeader.images[]` includes a logo-looking image (alt matching brand name OR class containing `logo`/`brand`), emit `<a class="es-header__brand" href="/"><img src="{assetsMap.localPath}" alt="{brand}"></a>`. If brand is text-only (no img), emit `<span class="es-header__brand">{brand-name}</span>`.
|
|
245
|
+
|
|
246
|
+
5. **Left cluster: hamburger + search.** On mobile (`@media (max-width: 749px)`), emit `<div class="es-header__left">` with a `<button>` hamburger trigger (aria-controls a drawer) + a `<button>` search trigger. On desktop, replace hamburger with horizontal nav links (rule 7).
|
|
247
|
+
|
|
248
|
+
6. **Right cluster: utility icons.** Links/buttons matching `/account|cart|search/` go into `<div class="es-header__right">` with proper SVG icons (use `hydratedHeader.inlineSvgs[]` matching parent class containing `account|cart|search`, OR fall back to bundled icon set).
|
|
249
|
+
|
|
250
|
+
7. **Desktop nav (visible only ≥750px).** Take category links from `hydratedHeader.links` filtered by hrefs matching `/products|collections|categories|shop/` (top-level shop nav). Emit as `<nav class="es-header__nav-desktop"><ul>{links}</ul></nav>` between brand and right cluster, with `display: none` on mobile.
|
|
251
|
+
|
|
252
|
+
8. **Mobile drawer (V03-5 corrected UX).** Bug 1.2 — previous output had logo inside drawer + no overlay. Correct UX:
|
|
253
|
+
```html
|
|
254
|
+
<div class="es-drawer-backdrop" data-drawer-backdrop hidden>
|
|
255
|
+
<div class="es-drawer" data-drawer role="dialog" aria-modal="true">
|
|
256
|
+
<button class="es-drawer__close" aria-label="Close menu" data-drawer-close>✕</button>
|
|
257
|
+
<nav>
|
|
258
|
+
<ul class="es-drawer__nav">
|
|
259
|
+
<!-- category links from hydratedHeader.links, NO LOGO inside -->
|
|
260
|
+
</ul>
|
|
261
|
+
</nav>
|
|
262
|
+
<!-- Optional: account link / login at footer of drawer if source has it -->
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
```
|
|
266
|
+
CSS:
|
|
267
|
+
```css
|
|
268
|
+
.es-drawer-backdrop.es-drawer-backdrop[hidden] { display: none; }
|
|
269
|
+
.es-drawer-backdrop.es-drawer-backdrop {
|
|
270
|
+
position: fixed; inset: 0;
|
|
271
|
+
background: rgba(0,0,0,.5);
|
|
272
|
+
z-index: 9999;
|
|
273
|
+
}
|
|
274
|
+
.es-drawer.es-drawer {
|
|
275
|
+
position: absolute; top: 0; left: 0; bottom: 0;
|
|
276
|
+
width: min(85vw, 360px);
|
|
277
|
+
background: #fff;
|
|
278
|
+
padding: 24px 20px;
|
|
279
|
+
overflow-y: auto;
|
|
280
|
+
}
|
|
281
|
+
.es-drawer__close.es-drawer__close {
|
|
282
|
+
position: absolute; top: 12px; right: 12px;
|
|
283
|
+
background: none; border: 0; font-size: 24px;
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
JS: vanilla toggle on hamburger click. **NO LOGO inside drawer** — logo is in the header shell, not duplicated. **Backdrop overlay is mandatory** — drawer with no backdrop is the v0.3.x bug.
|
|
182
287
|
|
|
183
|
-
|
|
184
|
-
2. **Promo bar.** If any link's text matches a promotional pattern (`/sale|discount|free|% off/i`) OR `hydratedHeader.headings[]` contains a short standalone heading at the top, emit `<div class="es-header__promo">{text}</div>`.
|
|
185
|
-
3. **Brand.** If `hydratedHeader.images[]` includes one with `alt` matching the brand name (derived from URL or generic `logo`), emit `<a class="es-header__brand" href="/">` with that `<img>`.
|
|
186
|
-
4. **Nav.** Take `hydratedHeader.links` filtered to category-looking hrefs (`/products/...`, `/collections/...`, top-level pages). Emit as `<nav><ul>{links}</ul></nav>`. On mobile (`@media (max-width: 749px)`), collapse into a `<details><summary aria-label="Menu">` drawer — keep all category links inside.
|
|
187
|
-
5. **Utility icons.** Links with text "Open search", "Account", "Cart" (or matching hrefs `/search`, `/account`, `/cart`) get rendered as a right-side cluster of icon buttons.
|
|
288
|
+
9. **Cross-validate every literal** (brand text, link texts, announcement bar text) against `hydratedHeader.html` / `hydratedAnnouncementBar.html` before emit. Validator catches fabrications.
|
|
188
289
|
|
|
189
290
|
**Output contract for header/footer:**
|
|
190
291
|
- Markup includes a top comment `<!-- sb-build-wp: composed from hydrated snapshot (V03-0a) -->`.
|
|
@@ -51,7 +51,17 @@ The Elementor + active theme stack frequently injects `!important` on these prop
|
|
|
51
51
|
- `font-weight` — set on `h1..h6`, `body`
|
|
52
52
|
- `color` — set on links via `.elementor a`, on body via theme
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
### v0.3.5 — Expanded property list (after real-world "tudo liso no WordPress" bug 3.4):
|
|
55
|
+
|
|
56
|
+
The above 4-property list was insufficient — when output was published in WordPress, the products grid and other card-based layouts came out "tudo liso" (no border-radius, no shadow, no gaps). The Elementor theme stack also normalizes/overrides these visual properties on `*` selectors. Add `!important` ALSO to:
|
|
57
|
+
|
|
58
|
+
- `border-radius` — on cards, buttons, image containers (themes flatten cards otherwise)
|
|
59
|
+
- `box-shadow` — on cards and elevated elements
|
|
60
|
+
- `background-color` — on color-bearing blocks (`.es-card`, `.es-cta`, `.es-banner`) when the visual identity depends on it
|
|
61
|
+
- `gap` (and `column-gap`, `row-gap`) — on grid/flex containers (themes that reset child margins also reset gaps)
|
|
62
|
+
- `padding` — on card-like containers and section blocks where the live padding is visually critical
|
|
63
|
+
|
|
64
|
+
Apply via the same chained-scope pattern (`.es-card.es-card { border-radius: 12px !important; }`). Still DO NOT spray `!important` on margin, width, height, transforms, transitions — those rarely conflict with theme.
|
|
55
65
|
|
|
56
66
|
## Reset universal
|
|
57
67
|
|
|
@@ -259,8 +259,32 @@ async function main() {
|
|
|
259
259
|
}
|
|
260
260
|
return best?.el || null
|
|
261
261
|
}
|
|
262
|
+
// §V03-5 — announcement bar (promo bar on top of the page,
|
|
263
|
+
// typically <aside> sibling above the <header>). Common in e-commerce
|
|
264
|
+
// ("Mother's Day Sale", "Free Shipping over $X", etc.). Captured as
|
|
265
|
+
// separate snapshot so the composer can compose it as part of the
|
|
266
|
+
// global chrome (above the header) — not as inline body content of
|
|
267
|
+
// the home page.
|
|
268
|
+
function pickAnnouncementBar() {
|
|
269
|
+
const candidates = document.querySelectorAll(
|
|
270
|
+
'aside[class*="announcement"], [class*="announcement-bar"], [class*="promo-bar"], [class*="shopify-section-group-header-group"][class*="announcement"], aside[class*="shopify-section-group-header"]',
|
|
271
|
+
)
|
|
272
|
+
let best = null
|
|
273
|
+
for (const el of candidates) {
|
|
274
|
+
const r = el.getBoundingClientRect()
|
|
275
|
+
if (r.height < 20 || r.height > 200) continue
|
|
276
|
+
// must be near top of page
|
|
277
|
+
if (Math.abs(r.y + window.scrollY) > 200) continue
|
|
278
|
+
const txt = (el.textContent || '').trim()
|
|
279
|
+
if (!txt) continue
|
|
280
|
+
const score = txt.length / 10 + (r.width >= 320 ? 5 : 0)
|
|
281
|
+
if (!best || score > best.score) best = { el, score }
|
|
282
|
+
}
|
|
283
|
+
return best?.el || null
|
|
284
|
+
}
|
|
262
285
|
const footer = pickFooter()
|
|
263
286
|
const header = pickHeader()
|
|
287
|
+
const announcement = pickAnnouncementBar()
|
|
264
288
|
window.__sbHydratedFooter = footer
|
|
265
289
|
? {
|
|
266
290
|
html: footer.outerHTML,
|
|
@@ -289,6 +313,29 @@ async function main() {
|
|
|
289
313
|
})(),
|
|
290
314
|
}
|
|
291
315
|
: null
|
|
316
|
+
window.__sbHydratedAnnouncementBar = announcement
|
|
317
|
+
? {
|
|
318
|
+
html: announcement.outerHTML,
|
|
319
|
+
text: (() => {
|
|
320
|
+
// Strip inline <script>/<style> tags before reading text —
|
|
321
|
+
// Shopify themes inline CSS variables and JS at the top of
|
|
322
|
+
// the announcement-bar section, which would otherwise leak
|
|
323
|
+
// into the "text" field as garbage.
|
|
324
|
+
const clone = announcement.cloneNode(true)
|
|
325
|
+
clone.querySelectorAll('script,style').forEach((n) => n.remove())
|
|
326
|
+
return (clone.textContent || '').replace(/\s+/g, ' ').trim()
|
|
327
|
+
})(),
|
|
328
|
+
bbox: (() => {
|
|
329
|
+
const r = announcement.getBoundingClientRect()
|
|
330
|
+
return {
|
|
331
|
+
x: Math.round(r.x + window.scrollX),
|
|
332
|
+
y: Math.round(r.y + window.scrollY),
|
|
333
|
+
w: Math.round(r.width),
|
|
334
|
+
h: Math.round(r.height),
|
|
335
|
+
}
|
|
336
|
+
})(),
|
|
337
|
+
}
|
|
338
|
+
: null
|
|
292
339
|
})
|
|
293
340
|
|
|
294
341
|
// Return to the top before layout reads. content-visibility:auto on
|
|
@@ -1882,7 +1929,7 @@ function extractInPage({ selector, maxDepth, maxChildren, maxText }) {
|
|
|
1882
1929
|
try {
|
|
1883
1930
|
parsed = new DOMParser().parseFromString(snapshot.html, 'text/html')
|
|
1884
1931
|
} catch (_) {
|
|
1885
|
-
return { ...snapshot, links: [], headings: [], inputs: [], forms: [], images: [] }
|
|
1932
|
+
return { ...snapshot, links: [], headings: [], inputs: [], forms: [], images: [], blocks: [], paragraphs: [], inlineSvgs: [] }
|
|
1886
1933
|
}
|
|
1887
1934
|
const root = parsed.body.firstElementChild || parsed.body
|
|
1888
1935
|
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim()
|
|
@@ -1893,7 +1940,8 @@ function extractInPage({ selector, maxDepth, maxChildren, maxText }) {
|
|
|
1893
1940
|
label: a.getAttribute('aria-label') || null,
|
|
1894
1941
|
}))
|
|
1895
1942
|
.filter((l) => l.href && (l.text || l.label))
|
|
1896
|
-
const
|
|
1943
|
+
const HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6, p.bold, .footer__block-title, [class*="heading"]'
|
|
1944
|
+
const headings = Array.from(root.querySelectorAll(HEADING_SELECTOR))
|
|
1897
1945
|
.map((h) => ({ tag: h.tagName.toLowerCase(), text: norm(h.textContent) }))
|
|
1898
1946
|
.filter((h) => h.text)
|
|
1899
1947
|
const inputs = Array.from(root.querySelectorAll('input')).map((i) => ({
|
|
@@ -1914,8 +1962,96 @@ function extractInPage({ selector, maxDepth, maxChildren, maxText }) {
|
|
|
1914
1962
|
alt: img.getAttribute('alt') || '',
|
|
1915
1963
|
width: img.getAttribute('width') || null,
|
|
1916
1964
|
height: img.getAttribute('height') || null,
|
|
1965
|
+
classList: Array.from(img.classList || []),
|
|
1966
|
+
parentClass: img.parentElement ? Array.from(img.parentElement.classList || []) : [],
|
|
1917
1967
|
}))
|
|
1918
1968
|
.filter((img) => img.src)
|
|
1969
|
+
// §V03-5 — inline SVG capture. Composer needs the raw SVG markup to
|
|
1970
|
+
// preserve ornamental decorators (Clinicians' Choice flanking flora,
|
|
1971
|
+
// social icons, payment cards when those are inline rather than <img>).
|
|
1972
|
+
// Skip extremely small (single-path icon < 16x16) and absurdly large
|
|
1973
|
+
// svgs that are likely page-level decorative blobs.
|
|
1974
|
+
const inlineSvgs = Array.from(root.querySelectorAll('svg'))
|
|
1975
|
+
.map((svg, i) => {
|
|
1976
|
+
const r = svg.getBoundingClientRect ? svg.getBoundingClientRect() : { width: 0, height: 0 }
|
|
1977
|
+
const w = svg.getAttribute('width') || r.width || null
|
|
1978
|
+
const h = svg.getAttribute('height') || r.height || null
|
|
1979
|
+
const viewBox = svg.getAttribute('viewBox') || null
|
|
1980
|
+
const ariaLabel = svg.getAttribute('aria-label') || null
|
|
1981
|
+
const title = svg.querySelector('title')?.textContent || null
|
|
1982
|
+
const classes = Array.from(svg.classList || [])
|
|
1983
|
+
return {
|
|
1984
|
+
idx: i,
|
|
1985
|
+
outerHTML: svg.outerHTML,
|
|
1986
|
+
width: w,
|
|
1987
|
+
height: h,
|
|
1988
|
+
viewBox,
|
|
1989
|
+
ariaLabel,
|
|
1990
|
+
title,
|
|
1991
|
+
classes,
|
|
1992
|
+
parentClass: svg.parentElement ? Array.from(svg.parentElement.classList || []) : [],
|
|
1993
|
+
}
|
|
1994
|
+
})
|
|
1995
|
+
// §V03-5 — blocks: heading-anchored sub-sections. Walks DOM in source
|
|
1996
|
+
// order and groups text content under each heading (e.g. "Need Help?"
|
|
1997
|
+
// heading + its paragraphs + its mailto link). This lets the composer
|
|
1998
|
+
// emit semantically grouped output where the live source intended.
|
|
1999
|
+
const blocks = []
|
|
2000
|
+
let currentBlock = null
|
|
2001
|
+
function nodeHeadingText(el) {
|
|
2002
|
+
if (!el) return null
|
|
2003
|
+
if (el.matches && el.matches(HEADING_SELECTOR)) return norm(el.textContent)
|
|
2004
|
+
return null
|
|
2005
|
+
}
|
|
2006
|
+
function walkInOrder(el) {
|
|
2007
|
+
if (!el || el.nodeType !== 1) return
|
|
2008
|
+
const headingText = nodeHeadingText(el)
|
|
2009
|
+
if (headingText) {
|
|
2010
|
+
if (currentBlock) blocks.push(currentBlock)
|
|
2011
|
+
currentBlock = {
|
|
2012
|
+
heading: { tag: el.tagName.toLowerCase(), text: headingText },
|
|
2013
|
+
paragraphs: [],
|
|
2014
|
+
links: [],
|
|
2015
|
+
mailtos: [],
|
|
2016
|
+
}
|
|
2017
|
+
return // don't descend; siblings of the heading carry the content
|
|
2018
|
+
}
|
|
2019
|
+
// collect paragraphs / inline content for the current block
|
|
2020
|
+
if (currentBlock) {
|
|
2021
|
+
if (el.tagName === 'P') {
|
|
2022
|
+
// <p> can contain inline children (<a>, <strong>, etc.) — what
|
|
2023
|
+
// matters is that it has no block-level nested elements.
|
|
2024
|
+
const hasBlockChild = Array.from(el.children).some((c) =>
|
|
2025
|
+
/^(DIV|UL|OL|SECTION|ARTICLE|FORM|HEADER|FOOTER|NAV|FIGURE)$/.test(c.tagName),
|
|
2026
|
+
)
|
|
2027
|
+
const txt = norm(el.textContent)
|
|
2028
|
+
// Copyright / "All rights reserved" lines are end-of-section
|
|
2029
|
+
// markers — close the current block before they get attached
|
|
2030
|
+
// to an unrelated heading like "Need Help?".
|
|
2031
|
+
if (/^(©|copyright)/i.test(txt) || /all rights reserved/i.test(txt)) {
|
|
2032
|
+
if (currentBlock) {
|
|
2033
|
+
blocks.push(currentBlock)
|
|
2034
|
+
currentBlock = null
|
|
2035
|
+
}
|
|
2036
|
+
} else if (txt && txt.length >= 8 && !hasBlockChild) {
|
|
2037
|
+
currentBlock.paragraphs.push(txt)
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
if (el.tagName === 'A') {
|
|
2041
|
+
const href = el.getAttribute('href') || ''
|
|
2042
|
+
if (href.startsWith('mailto:')) currentBlock.mailtos.push(href.replace(/^mailto:/, ''))
|
|
2043
|
+
else if (href) currentBlock.links.push({ href, text: norm(el.textContent) })
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
for (const c of el.children) walkInOrder(c)
|
|
2047
|
+
}
|
|
2048
|
+
walkInOrder(root)
|
|
2049
|
+
if (currentBlock) blocks.push(currentBlock)
|
|
2050
|
+
// §V03-5 — paragraphs: all <p> in order, for sections without
|
|
2051
|
+
// heading anchors (e.g. footer brand description sitting alone).
|
|
2052
|
+
const paragraphs = Array.from(root.querySelectorAll('p'))
|
|
2053
|
+
.map((p) => norm(p.textContent))
|
|
2054
|
+
.filter((t) => t && t.length >= 8 && t.length < 400)
|
|
1919
2055
|
return {
|
|
1920
2056
|
html: snapshot.html,
|
|
1921
2057
|
bbox: snapshot.bbox,
|
|
@@ -1924,10 +2060,18 @@ function extractInPage({ selector, maxDepth, maxChildren, maxText }) {
|
|
|
1924
2060
|
inputs,
|
|
1925
2061
|
forms,
|
|
1926
2062
|
images,
|
|
2063
|
+
inlineSvgs,
|
|
2064
|
+
blocks,
|
|
2065
|
+
paragraphs,
|
|
1927
2066
|
}
|
|
1928
2067
|
}
|
|
1929
2068
|
const hydratedHeader = extractChrome(window.__sbHydratedHeader)
|
|
1930
2069
|
const hydratedFooter = extractChrome(window.__sbHydratedFooter)
|
|
2070
|
+
// §V03-5 announcement bar — captured separately to allow the composer
|
|
2071
|
+
// to emit it as part of the global chrome (clean/global/header.html
|
|
2072
|
+
// composed with the bar prepended) instead of leaking into per-page
|
|
2073
|
+
// body content.
|
|
2074
|
+
const hydratedAnnouncementBar = window.__sbHydratedAnnouncementBar || null
|
|
1931
2075
|
|
|
1932
2076
|
return {
|
|
1933
2077
|
sectionType,
|
|
@@ -1944,6 +2088,7 @@ function extractInPage({ selector, maxDepth, maxChildren, maxText }) {
|
|
|
1944
2088
|
externalIframes,
|
|
1945
2089
|
hydratedHeader,
|
|
1946
2090
|
hydratedFooter,
|
|
2091
|
+
hydratedAnnouncementBar,
|
|
1947
2092
|
}
|
|
1948
2093
|
}
|
|
1949
2094
|
|