diff --git a/packages/blocks-engine/src/theme/native-reconstruct-types.ts b/packages/blocks-engine/src/theme/native-reconstruct-types.ts index 4edea73ae..bdfa913e0 100644 --- a/packages/blocks-engine/src/theme/native-reconstruct-types.ts +++ b/packages/blocks-engine/src/theme/native-reconstruct-types.ts @@ -48,6 +48,12 @@ export interface NativeSectionDecision extends NativeSectionResult { export interface StrategyState { instanceStyles?: unknown; + /** + * Count of paragraphs the strategy synthesized from non-paragraph source text + * (text-only div/span elements, stray inline text). Drain sinks use it to ship + * the lowered-paragraph support CSS exactly when such paragraphs exist. + */ + loweredTextBlocks?: number; } export interface StrategyDedupOutput { diff --git a/packages/blocks-engine/src/theme/preserve-dom/__tests__/strategy.test.ts b/packages/blocks-engine/src/theme/preserve-dom/__tests__/strategy.test.ts index 2d1454e94..02423f418 100644 --- a/packages/blocks-engine/src/theme/preserve-dom/__tests__/strategy.test.ts +++ b/packages/blocks-engine/src/theme/preserve-dom/__tests__/strategy.test.ts @@ -84,13 +84,14 @@ describe('preserveDomStrategy', () => { '
\n' + '
Photo
\n' + '\n' + - '\n' + - '

Caption

\n' + + '\n' + + '

Caption

\n' + '
\n' + '', ]); expect(aggregate.dedup).toEqual({ cssRules: [ + ':where(p.is-lowered-text){margin-block-start:0;margin-block-end:0}', '.lib-i0466783d98{width:100%}', '.lib-i42aa6d9c6f{padding:2rem}', '.lib-ie3ec02ace9{font-weight:700}', @@ -116,8 +117,8 @@ describe('preserveDomStrategy', () => { expect(aggregate.sectionMarkup).toEqual([ '\n' + '
\n' + - '
\n' + - '

Nested

\n' + + '
\n' + + '

Nested

\n' + '
\n' + '
\n' + '', @@ -227,6 +228,60 @@ describe('preserveDomStrategy', () => { expect(aggregate.sections[0]?.coverage.lost).toBe(false); }); + it('lowers a text-only div to a paragraph that keeps the source box block margins (issue #2156)', () => { + const aggregate = reconstructNativeAggregate( + [ + sectionSpec({ + sectionIndex: 9, + bodyText: ['Handcrafted in small batches since 2019.', 'A real paragraph from a p tag.'], + sectionHtml: + '', + }), + ], + { strategy: preserveDomStrategy }, + ); + + const markup = aggregate.sectionMarkup[0] ?? ''; + // The div carries its source classes onto the lowered paragraph plus the engine + // marker class, so the support rule keeps its block margins at 0 (the source div + // had none) while any authored margin utility still wins the cascade. + expect(markup).toContain( + '', + ); + expect(markup).toContain('

'); + // A real

source is untouched — its UA block margins are part of the source. + expect(markup).toContain(''); + expect(markup).toContain('

'); + expect(markup).not.toContain('real-copy is-lowered-text'); + // Drain ships the zero-specificity support rule only because lowered text exists. + expect(aggregate.dedup?.cssRules).toEqual([ + ':where(p.is-lowered-text){margin-block-start:0;margin-block-end:0}', + ]); + }); + + it('marks stray inline text paragraphs as lowered so UA margins do not apply', () => { + const aggregate = reconstructNativeAggregate( + [ + sectionSpec({ + sectionIndex: 10, + bodyText: ['Bare note'], + sectionHtml: '

Bare note
', + }), + ], + { strategy: preserveDomStrategy }, + ); + + expect(aggregate.sectionMarkup[0]).toContain( + '

Bare note

', + ); + expect(aggregate.dedup?.cssRules).toContain( + ':where(p.is-lowered-text){margin-block-start:0;margin-block-end:0}', + ); + }); + it('freezes lib-i hash parity for max-width declarations', () => { const sheet = new InstanceStyleSheet(); const cls = sheet.classFor('max-width:46ch'); diff --git a/packages/blocks-engine/src/theme/preserve-dom/strategy.ts b/packages/blocks-engine/src/theme/preserve-dom/strategy.ts index 8b6eea5c3..22ad14846 100644 --- a/packages/blocks-engine/src/theme/preserve-dom/strategy.ts +++ b/packages/blocks-engine/src/theme/preserve-dom/strategy.ts @@ -45,8 +45,20 @@ function classNameWithInstance($el: Cheerio, sheet: InstanceStyleSheet) return [base, instance].filter(Boolean).join(' '); } -function paragraphBlock(inner: string): string { - return `\n

${inner}

\n`; +/** + * Engine-owned marker class for paragraphs synthesized from non-paragraph source + * text (text-only div/span elements, stray inline text). A real

keeps the + * user-agent block margins that are part of the source; a lowered paragraph must + * keep its source box's block margins instead (0 unless authored). The backing + * support rule below is zero-specificity (:where) so any authored margin (class + * or inline) still wins the cascade while the UA's 1em paragraph default is + * suppressed where the author CSS is silent. + */ +export const LOWERED_TEXT_CLASS = 'is-lowered-text'; +export const LOWERED_TEXT_SUPPORT_RULE = `:where(p.${LOWERED_TEXT_CLASS}){margin-block-start:0;margin-block-end:0}`; + +function loweredTextParagraphBlock(inner: string): string { + return `\n

${inner}

\n`; } const HEADING = /^h([1-6])$/; @@ -184,7 +196,7 @@ function svgImageBlock($: CheerioAPI, svgEl: Element, sheet: InstanceStyleSheet) return `\n
${escapeHtml(alt)}
\n`; } -function emitChild($: CheerioAPI, el: Element, sheet: InstanceStyleSheet): ChildResult { +function emitChild($: CheerioAPI, el: Element, sheet: InstanceStyleSheet, state: StrategyState): ChildResult { const tag = el.tagName?.toLowerCase() ?? ''; const $el = $(el); @@ -227,9 +239,13 @@ function emitChild($: CheerioAPI, el: Element, sheet: InstanceStyleSheet): Child const text = $el.text().trim(); const elementChildren = $el.children().toArray(); if ((tag === 'div' || tag === 'span') && !$el.attr('id') && elementChildren.length === 0 && text) { - const cls = classNameWithInstance($el, sheet); + // The source box is a div/span: it has no block margins of its own. Marker-class the + // paragraph so the lowered-text support rule keeps those margins at 0 wherever the + // author CSS is silent, while any authored margin (e.g. a mt-*/mb-* utility) still wins. + const cls = [classNameWithInstance($el, sheet), LOWERED_TEXT_CLASS].filter(Boolean).join(' '); const attrs = blockAttrs([], cls); const clsPart = cls ? ` class="${escapeHtml(cls)}"` : ''; + state.loweredTextBlocks = (state.loweredTextBlocks ?? 0) + 1; return { markup: `\n${inlineHtml($, el).trim()}

\n`, clean: true, @@ -241,7 +257,7 @@ function emitChild($: CheerioAPI, el: Element, sheet: InstanceStyleSheet): Child // structures survive instead of being dropped. `clean` only stays true if every // descendant emitted cleanly (an unhandled leaf still downgrades the whole subtree). if (NESTABLE_CONTAINERS.has(tag) && elementChildren.length > 0) { - return emitContainer($, el, tag, sheet); + return emitContainer($, el, tag, sheet, state); } // Un-convertible element kind (svg, table, form, media): keep it losslessly as a nested @@ -273,16 +289,22 @@ function emitContainer( $: CheerioAPI, el: Element, tag: string, - sheet: InstanceStyleSheet + sheet: InstanceStyleSheet, + state: StrategyState ): ChildResult { const $el = $(el); const childResults: ChildResult[] = []; for (const node of $el.contents().get()) { if (isTag(node)) { - childResults.push(emitChild($, node, sheet)); + childResults.push(emitChild($, node, sheet, state)); } else if (isText(node)) { const text = node.data.trim(); - if (text) childResults.push({ markup: paragraphBlock(escapeHtml(text)), clean: true }); + if (text) { + // A stray inline text node has no block margins; the synthesized paragraph + // must not inherit the UA paragraph margins either. + state.loweredTextBlocks = (state.loweredTextBlocks ?? 0) + 1; + childResults.push({ markup: loweredTextParagraphBlock(escapeHtml(text)), clean: true }); + } } } @@ -353,14 +375,16 @@ export const preserveDomStrategy: SectionStrategy = { for (const node of container.contents().get()) { if (isTag(node)) { total += 1; - const res = emitChild($, node, sheet); + const res = emitChild($, node, sheet, state); if (!res.clean) downgrades += 1; if (res.markup) childMarkup.push(res.markup); } else if (isText(node)) { const text = node.data.trim(); if (!text) continue; total += 1; - childMarkup.push(paragraphBlock(escapeHtml(text))); + // Same as container-level stray text: no source box margins to inherit. + state.loweredTextBlocks = (state.loweredTextBlocks ?? 0) + 1; + childMarkup.push(loweredTextParagraphBlock(escapeHtml(text))); } } @@ -407,7 +431,12 @@ export const preserveDomStrategy: SectionStrategy = { }; }, drainDedup(state) { - if (!(state.instanceStyles instanceof InstanceStyleSheet)) return { cssRules: [] }; - return { cssRules: state.instanceStyles.size ? state.instanceStyles.toCss().split('\n') : [] }; + const sheet = state.instanceStyles instanceof InstanceStyleSheet ? state.instanceStyles : null; + // Engine support CSS first: the zero-specificity lowered-paragraph rule ships only + // when lowered paragraphs exist, and loses to every authored rule by specificity. + const rules: string[] = []; + if (state.loweredTextBlocks) rules.push(LOWERED_TEXT_SUPPORT_RULE); + if (sheet && sheet.size) rules.push(...sheet.toCss().split('\n')); + return { cssRules: rules }; }, }; diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 5bdf33bd6..d4197837b 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -176,6 +176,7 @@ "php tests/unit/auto-fit-grid-carrier.php", "php tests/unit/responsive-grid-track-container-safety.php", "php tests/unit/css-owned-grid-item-min-width-safety.php", + "php tests/unit/lowered-paragraph-margin.php", "php tests/unit/artifact-author-stylesheet-projection.php", "php tests/unit/fallback-finding-normalizer.php", "php tests/unit/editor-anchor-attribute-id-projection.php", diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index dbe61fe0f..605dff0e6 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -428,6 +428,13 @@ public static function emittedCoreBlockContracts(): array private const LAYOUT_TABLE_COLUMNS_CLASS = 'blocks-engine-layout-table-columns'; public const EMPTY_VISUAL_GROUP_CLASS = 'blocks-engine-empty-visual-group'; + /** + * Marks a core/paragraph lowered from a margin-styled text-only div. The + * source div has no user-agent block margins, but the emitted

would + * gain the paragraph's 1em UA margin on any side the author left unset. + */ + public const LOWERED_PARAGRAPH_CLASS = 'blocks-engine-lowered-paragraph'; + /** * Marks an emptied block that exists only as a runtime target. Emitted @@ -5868,9 +5875,11 @@ private function paragraphBlockFromInlineContentWrapper(DOMElement $element): ?a } if ( 'div' === strtolower($element->tagName) && $this->hasMarginWrapperStyling($element) ) { + $attrs = $this->styleResolver->presentationAttributes($element); + $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), self::LOWERED_PARAGRAPH_CLASS); return $this->createBlock( 'core/paragraph', - array_merge($this->styleResolver->presentationAttributes($element), array( 'content' => $content )), + array_merge($attrs, array( 'content' => $content )), array(), $element ); diff --git a/php-transformer/src/HtmlToBlocks/Style/EngineSupportCss.php b/php-transformer/src/HtmlToBlocks/Style/EngineSupportCss.php index a1aa12eed..7d88e8723 100644 --- a/php-transformer/src/HtmlToBlocks/Style/EngineSupportCss.php +++ b/php-transformer/src/HtmlToBlocks/Style/EngineSupportCss.php @@ -186,6 +186,12 @@ public function beforeAuthorCss(string $serializedBlocks, string $layoutShellBlo // must not cover native controls after its source runtime is absent. $parts[] = ':where(.' . HtmlCompilation::EMPTY_VISUAL_GROUP_CLASS . '){pointer-events:none!important}'; } + if ( str_contains($serializedBlocks, HtmlCompilation::LOWERED_PARAGRAPH_CLASS) ) { + // A paragraph lowered from a margin-less source element (e.g. a + // text-only div) must not gain the UA 1em paragraph margins. This + // precedes author CSS so authored margin utilities still win. + $parts[] = ':root :where(p.' . HtmlCompilation::LOWERED_PARAGRAPH_CLASS . '){margin-block-start:0;margin-block-end:0}'; + } if ( str_contains($serializedBlocks, self::CSS_OWNED_FLOW_CLASS) ) { // Core flow spacing is not part of a source grid or flex contract. // This precedes author CSS so source child margins remain authoritative. diff --git a/php-transformer/tests/unit/list-item-lowering.php b/php-transformer/tests/unit/list-item-lowering.php index 3ac36c140..79f1eaa50 100644 --- a/php-transformer/tests/unit/list-item-lowering.php +++ b/php-transformer/tests/unit/list-item-lowering.php @@ -91,7 +91,7 @@ if (str_contains($archiveMarkup, '