Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/blocks-engine/src/theme/native-reconstruct-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,14 @@ describe('preserveDomStrategy', () => {
'<section id="media-panel" class="wp-block-group alignfull media shell lib-i42aa6d9c6f"><!-- wp:image {"className":"photo lib-i0466783d98"} -->\n' +
'<figure class="wp-block-image photo lib-i0466783d98"><img src="/photo.jpg" alt="Photo"/></figure>\n' +
'<!-- /wp:image -->\n' +
'<!-- wp:paragraph {"className":"caption lib-ie3ec02ace9"} -->\n' +
'<p class="caption lib-ie3ec02ace9">Caption</p>\n' +
'<!-- wp:paragraph {"className":"caption lib-ie3ec02ace9 is-lowered-text"} -->\n' +
'<p class="caption lib-ie3ec02ace9 is-lowered-text">Caption</p>\n' +
'<!-- /wp:paragraph --></section>\n' +
'<!-- /wp:group -->',
]);
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}',
Expand All @@ -116,8 +117,8 @@ describe('preserveDomStrategy', () => {
expect(aggregate.sectionMarkup).toEqual([
'<!-- wp:group {"anchor":"nested-panel","tagName":"section","align":"full","className":"shell"} -->\n' +
'<section id="nested-panel" class="wp-block-group alignfull shell"><!-- wp:group {"anchor":"card","className":"card"} -->\n' +
'<div id="card" class="wp-block-group card"><!-- wp:paragraph {"className":"label"} -->\n' +
'<p class="label">Nested</p>\n' +
'<div id="card" class="wp-block-group card"><!-- wp:paragraph {"className":"label is-lowered-text"} -->\n' +
'<p class="label is-lowered-text">Nested</p>\n' +
'<!-- /wp:paragraph --></div>\n' +
'<!-- /wp:group --></section>\n' +
'<!-- /wp:group -->',
Expand Down Expand Up @@ -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:
'<section class="site-footer">' +
'<div class="mt-12 pt-6 border-t text-center text-sm">Handcrafted in small batches since 2019.</div>' +
'<p class="real-copy">A real paragraph from a p tag.</p>' +
'</section>',
}),
],
{ 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(
'<!-- wp:paragraph {"className":"mt-12 pt-6 border-t text-center text-sm is-lowered-text"} -->',
);
expect(markup).toContain('<p class="mt-12 pt-6 border-t text-center text-sm is-lowered-text">');
// A real <p> source is untouched — its UA block margins are part of the source.
expect(markup).toContain('<!-- wp:paragraph {"className":"real-copy"} -->');
expect(markup).toContain('<p class="real-copy">');
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: '<section class="notes">Bare note</section>',
}),
],
{ strategy: preserveDomStrategy },
);

expect(aggregate.sectionMarkup[0]).toContain(
'<p class="is-lowered-text">Bare note</p>',
);
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');
Expand Down
53 changes: 41 additions & 12 deletions packages/blocks-engine/src/theme/preserve-dom/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,20 @@ function classNameWithInstance($el: Cheerio<Element>, sheet: InstanceStyleSheet)
return [base, instance].filter(Boolean).join(' ');
}

function paragraphBlock(inner: string): string {
return `<!-- wp:paragraph -->\n<p>${inner}</p>\n<!-- /wp:paragraph -->`;
/**
* Engine-owned marker class for paragraphs synthesized from non-paragraph source
* text (text-only div/span elements, stray inline text). A real <p> 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 `<!-- wp:paragraph {"className":${attrJson(LOWERED_TEXT_CLASS)}} -->\n<p class="${LOWERED_TEXT_CLASS}">${inner}</p>\n<!-- /wp:paragraph -->`;
}

const HEADING = /^h([1-6])$/;
Expand Down Expand Up @@ -184,7 +196,7 @@ function svgImageBlock($: CheerioAPI, svgEl: Element, sheet: InstanceStyleSheet)
return `<!-- wp:image${attrs} -->\n<figure class="${escapeHtml(figCls)}"><img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}"${styleAttr}/></figure>\n<!-- /wp:image -->`;
}

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);

Expand Down Expand Up @@ -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: `<!-- wp:paragraph${attrs} -->\n<p${clsPart}>${inlineHtml($, el).trim()}</p>\n<!-- /wp:paragraph -->`,
clean: true,
Expand All @@ -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
Expand Down Expand Up @@ -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 });
}
}
}

Expand Down Expand Up @@ -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)));
}
}

Expand Down Expand Up @@ -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 };
},
};
1 change: 1 addition & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p> 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
Expand Down Expand Up @@ -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
);
Expand Down
6 changes: 6 additions & 0 deletions php-transformer/src/HtmlToBlocks/Style/EngineSupportCss.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion php-transformer/tests/unit/list-item-lowering.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
if (str_contains($archiveMarkup, '<!-- wp:list') || !str_contains($archiveMarkup, 'tagName":"ul"') || !str_contains($archiveMarkup, 'tagName":"li"')) throw new RuntimeException('A dated archive index with block descendants stays on the structural list path.');
if (!str_contains($archiveMarkup, 'index blocks-engine-css-owned-layout blocks-engine-css-owned-grid')) throw new RuntimeException('A dated archive index keeps CSS-owned grid so gap, not core flow, sets row rhythm.');
if (!str_contains($archiveMarkup, 'row-inner blocks-engine-css-owned-layout blocks-engine-css-owned-flow') || !str_contains($archiveMarkup, 'body blocks-engine-css-owned-layout blocks-engine-css-owned-flow')) throw new RuntimeException('Structural-list inner flex rows and stacked flex items neutralize WordPress flow so source child margins own vertical rhythm.');
if (!str_contains($archiveMarkup, '<p class="dek">Excerpt one</p>') || str_contains($archiveMarkup, 'wp-block-group dek')) throw new RuntimeException('A margin-styled excerpt div becomes a paragraph carrying its classes, not a group wrapping a classless paragraph.');
if (!str_contains($archiveMarkup, '<p class="dek blocks-engine-lowered-paragraph">Excerpt one</p>') || str_contains($archiveMarkup, 'wp-block-group dek')) throw new RuntimeException('A margin-styled excerpt div becomes a paragraph carrying its classes, not a group wrapping a classless paragraph.');
if (str_contains($archiveMarkup, '<!-- wp:html') || 'pass' !== ($archive['source_reports']['wp_block_validity']['status'] ?? null)) throw new RuntimeException('A dated archive index remains Gutenberg-valid without HTML fallback.');

$stackedCopy = (new HtmlTransformer())->transform(
Expand Down
65 changes: 65 additions & 0 deletions php-transformer/tests/unit/lowered-paragraph-margin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);

/**
* A text-only source element that renders no user-agent block margins (for
* example `<div class="mt-12 pt-6 text-sm">© …</div>`) is lowered to
* core/paragraph. The emitted `<p>` would gain the UA 1em paragraph margins,
* making the box taller than the source whenever author CSS is silent on
* that side (a real import rendered a footer 14px taller on every page).
*
* The lowered paragraph is marked, and a before-author engine-support rule
* zeroes its block margins, so authored margin utilities still win. Real
* `<p>` sources keep their UA margins because those are part of the source.
*/

require dirname(__DIR__, 2) . '/vendor/autoload.php';

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlCompilation;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;

$failures = 0;
$passes = 0;
$assert = static function (bool $condition, string $message, string $detail = '') use (&$failures, &$passes): void {
if ( $condition ) {
++$passes;
return;
}
++$failures;
fwrite(STDERR, 'FAIL: ' . $message . ('' !== $detail ? ' - ' . $detail : '') . PHP_EOL);
};
$beforeAuthorCss = static fn (array $result): string => implode("\n", array_map(
static fn (array $asset): string => (string) ($asset['content'] ?? ''),
array_values(array_filter(
is_array($result['assets'] ?? null) ? $result['assets'] : array(),
static fn (array $asset): bool => 'engine-support' === ($asset['source'] ?? '')
&& 'before-author' === ($asset['stylesheet_placement'] ?? '')
))
));
$transform = static fn (string $html): array => ( new HtmlTransformer() )->transform($html, array())->toArray();
$class = HtmlCompilation::LOWERED_PARAGRAPH_CLASS;
$rule = ':root :where(p.' . $class . '){margin-block-start:0;margin-block-end:0}';

$lowered = $transform('<style>.mt-12{margin-top:3rem}.pt-6{padding-top:1.5rem}</style><footer><div class="mt-12 pt-6">© 2026 <span>Example.</span></div></footer>');
$markup = (string) ($lowered['serialized_blocks'] ?? '');
$assert(
(bool) preg_match('/<p class="[^"]*\bmt-12\b[^"]*\b' . preg_quote($class, '/') . '\b/', $markup),
'a text-only div lowered to core/paragraph carries the lowered-paragraph marker next to its source classes',
$markup
);
$assert(str_contains($beforeAuthorCss($lowered), $rule), 'the zero-margin rule is emitted before author CSS', $beforeAuthorCss($lowered));

$real = $transform('<style>.lead{font-size:1.25rem}</style><div class="wrap"><p class="lead">Real paragraph.</p><h2>Heading</h2></div>');
$realMarkup = (string) ($real['serialized_blocks'] ?? '');
$assert(! str_contains($realMarkup, $class), 'a real <p> source keeps its UA margins and is never marked', $realMarkup);
$assert(! str_contains($beforeAuthorCss($real), $class), 'no lowered paragraph means no rule is emitted', $beforeAuthorCss($real));

$quote = $transform('<blockquote>Quoted text only.</blockquote>');
$quoteMarkup = (string) ($quote['serialized_blocks'] ?? '');
$assert(! str_contains($quoteMarkup, $class), 'sources with their own UA block margins are never marked', $quoteMarkup);

if ( $failures > 0 ) {
fwrite(STDERR, "lowered paragraph margin: {$failures} failed, {$passes} passed\n");
exit(1);
}
echo "lowered paragraph margin: {$passes} passed\n";
Loading