diff --git a/src/removals/selectors.ts b/src/removals/selectors.ts index 31b466810..d226ee259 100644 --- a/src/removals/selectors.ts +++ b/src/removals/selectors.ts @@ -11,6 +11,70 @@ import { DebugRemoval } from '../types'; import { textPreview, logDebug } from '../utils'; import { getClassName, hasResponsiveShowClass } from '../utils/dom'; +const HERO_PARTIAL_SELECTOR = 'hero[_\\-a-z]'; +const HERO_PARTIAL_SELECTOR_REGEX = new RegExp(HERO_PARTIAL_SELECTOR, 'i'); +const NON_HERO_PARTIAL_SELECTOR_REGEXES = PARTIAL_SELECTORS + .filter(pattern => pattern !== HERO_PARTIAL_SELECTOR) + .map(pattern => new RegExp(pattern, 'i')); +const CAPTION_CLASS_REGEX = /caption|credit/i; + +function getSelectorAttrs(el: Element): string { + const tag = el.tagName; + const isHeading = /^H[1-6]$/.test(tag); + return (isHeading + ? getClassName(el) + : getClassName(el) + ' ' + + (el.id || '') + ' ' + + (el.getAttribute('data-component') || '') + ' ' + + (el.getAttribute('data-test') || '') + ' ' + + (el.getAttribute('data-testid') || '') + ' ' + + (el.getAttribute('data-test-id') || '') + ' ' + + (el.getAttribute('data-qa') || '') + ' ' + + (el.getAttribute('data-cy') || '') + ).toLowerCase(); +} + +function hasCaptionClass(el: Element): boolean { + return CAPTION_CLASS_REGEX.test(getClassName(el)); +} + +function hasImageWithCaptionClass(el: Element): boolean { + if (!el.querySelector('img')) { + return false; + } + return Array.from(el.querySelectorAll('[class]')).some(hasCaptionClass); +} + +function hasCaptionedFigure(el: Element): boolean { + const figure = el.tagName === 'FIGURE' ? el : el.closest('figure'); + if (figure?.querySelector('figcaption')) { + return true; + } + return Array.from(el.querySelectorAll('figure')).some(figureEl => !!figureEl.querySelector('figcaption')); +} + +function hasHeroMediaWithCaption(el: Element): boolean { + if (hasCaptionedFigure(el) || hasImageWithCaptionClass(el)) { + return true; + } + + let ancestor = el.parentElement; + while (ancestor) { + if ( + HERO_PARTIAL_SELECTOR_REGEX.test(getSelectorAttrs(ancestor)) && + (hasCaptionedFigure(ancestor) || hasImageWithCaptionClass(ancestor)) + ) { + return true; + } + ancestor = ancestor.parentElement; + } + return false; +} + +function hasNonHeroPartialSelector(attrs: string): boolean { + return NON_HERO_PARTIAL_SELECTOR_REGEXES.some(regex => regex.test(attrs)); +} + export function removeBySelector(doc: Document, debug: boolean, removeExact: boolean = true, removePartial: boolean = true, mainContent?: Element | null, debugRemovals?: DebugRemoval[], skipHiddenExactSelectors: boolean = false) { const startTime = Date.now(); let exactSelectorCount = 0; @@ -83,18 +147,7 @@ export function removeBySelector(doc: Document, debug: boolean, removeExact: boo // (Hardcoded to match TEST_ATTRIBUTES in constants.ts — avoids array allocation per element) // For headings, only check class — IDs are auto-slugs and data-testid // values (e.g. "article-header") cause false positives. - const isHeading = /^H[1-6]$/.test(tag); - const attrs = (isHeading - ? getClassName(el) - : getClassName(el) + ' ' + - (el.id || '') + ' ' + - (el.getAttribute('data-component') || '') + ' ' + - (el.getAttribute('data-test') || '') + ' ' + - (el.getAttribute('data-testid') || '') + ' ' + - (el.getAttribute('data-test-id') || '') + ' ' + - (el.getAttribute('data-qa') || '') + ' ' + - (el.getAttribute('data-cy') || '') - ).toLowerCase(); + const attrs = getSelectorAttrs(el); // Skip if no attributes to check if (!attrs.trim()) { @@ -106,6 +159,13 @@ export function removeBySelector(doc: Document, debug: boolean, removeExact: boo const matchedPattern = individualRegexes ? individualRegexes.find(r => r.regex.test(attrs))?.pattern : undefined; + if ( + HERO_PARTIAL_SELECTOR_REGEX.test(attrs) && + hasHeroMediaWithCaption(el) && + !hasNonHeroPartialSelector(attrs) + ) { + return; + } elementsToRemove.set(el, { type: 'partial', selector: matchedPattern }); partialSelectorCount++; } diff --git a/tests/expected/content-patterns--webflow-hero-image-with-caption.md b/tests/expected/content-patterns--webflow-hero-image-with-caption.md new file mode 100644 index 000000000..aefd1c038 --- /dev/null +++ b/tests/expected/content-patterns--webflow-hero-image-with-caption.md @@ -0,0 +1,16 @@ +```json +{ + "title": "Artificial Life in Practice", + "author": "", + "site": "", + "published": "" +} +``` + +![Artificial organisms moving through a simulated environment](https://cdn.example.com/images/artificial-life-hero.jpg) + +Artificial organisms move through a simulated environment. Credit: Example Lab. + +Artificial life research studies simple systems that produce complex behavior over time. + +Researchers use these models to compare how local rules can create lifelike global patterns. diff --git a/tests/fixtures/content-patterns--webflow-hero-image-with-caption.html b/tests/fixtures/content-patterns--webflow-hero-image-with-caption.html new file mode 100644 index 000000000..6cf4f6de0 --- /dev/null +++ b/tests/fixtures/content-patterns--webflow-hero-image-with-caption.html @@ -0,0 +1,30 @@ + + + + +Artificial Life in Practice + + + + + +
+
+

Artificial Life in Practice

+
+ Artificial organisms moving through a simulated environment +
Artificial organisms move through a simulated environment. Credit: Example Lab.
+
+
+

Artificial life research studies simple systems that produce complex behavior over time.

+

Researchers use these models to compare how local rules can create lifelike global patterns.

+
+
+
+ + +