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.4",
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
- 1. **Outer shell.** `<footer class="es-footer">` with reset + `box-sizing: border-box` + the page background-color taken from `inspection.tokens.colors.background` (or fallback `#fafafa`).
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
- **Header recipe** (when `hydratedHeader` present):
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
- 1. **Outer shell.** `<header class="es-header">` with sticky position if the page tokens indicate so.
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
- Use `!important` ONLY on these four properties when they appear on critical text. Do NOT spray `!important` everywhere overuse turns debugging into a nightmare. Specifically: do NOT `!important` margins, paddings, widths, heights, backgrounds, transforms, transitions.
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 headings = Array.from(root.querySelectorAll('h1, h2, h3, h4, h5, h6, p.bold, .footer__block-title, [class*="heading"]'))
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