Skip to content
Open
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
78 changes: 36 additions & 42 deletions src/Model/TemplateEngine/Decorator/InspectorHints.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,20 +97,6 @@ private function isExcludedTemplate(string $templateFile): bool
return false;
}

/**
* Check if rendered HTML contains wire attributes (Magewire/Livewire components)
*
* Wrapping these in HTML comments breaks wire:id injection which relies on
* finding the first root element via regex.
*
* @param string $html
* @return bool
*/
private function containsWireAttributes(string $html): bool
{
return str_contains($html, 'wire:id=') || str_contains($html, 'wire:initial-data=');
}

/**
* Insert inspector data attributes into the rendered block contents
*
Expand All @@ -137,27 +123,10 @@ public function render(BlockInterface $block, $templateFile, array $dictionary =
}

// Skip inspector wrapping for templates in excluded paths (e.g. /magewire/ directories).
// Magewire injects wire:id AFTER the template engine returns via regex on the root element.
// Wrapping the output in HTML comments before that element breaks the injection.
if ($this->isExcludedTemplate($templateFile)) {
return $result;
}

// Skip inspector wrapping for Magewire component blocks.
// Magewire sets a 'magewire' data key on the block before rendering and injects wire:id
// via regex AFTER the template engine returns. Wrapping the output in HTML comments
// shifts the offset used by insertAttributesIntoHtmlRoot(), causing broken components.
// Soft dependency: hasData() is a Magento DataObject method, not a Magewire class.
if (method_exists($block, 'hasData') && $block->hasData('magewire')) {
return $result;
}

// Skip inspector wrapping if the rendered HTML contains wire attributes (Magewire/Livewire).
// This catches container blocks whose children have already been rendered with wire attributes.
if ($this->containsWireAttributes($result)) {
return $result;
}

// Only inject attributes if there's actual HTML content
if (empty(trim($result))) {
return $result;
Expand All @@ -177,7 +146,13 @@ public function render(BlockInterface $block, $templateFile, array $dictionary =
}

/**
* Inject MageForge inspector comment markers into HTML
* Inject MageForge inspector data attributes into the first root HTML element
*
* Injects data-mageforge-id and data-mageforge-block on the opening tag of the
* first HTML element in the output. If the content does not start with an HTML
* element (e.g. a plain URL or text fragment used inside an href attribute by a
* parent PageBuilder template), injection is skipped entirely to avoid corrupting
* the surrounding markup.
*
* @param string $html
* @param BlockInterface $block
Expand Down Expand Up @@ -213,6 +188,9 @@ private function injectInspectorAttributes(
$cacheMetrics = $this->cacheCollector->getCacheInfo($block);
$formattedMetrics = $this->cacheCollector->formatMetricsForJson($renderMetrics, $cacheMetrics);

// Detect CMS block identifier (e.g. for PageBuilder blocks rendered via Magento\Cms\Block\Block)
$cmsBlockId = method_exists($block, 'getBlockId') ? (string) $block->getBlockId() : '';
Comment on lines +191 to +192

// Build metadata as JSON
$metadata = [
'id' => $wrapperId,
Expand All @@ -223,29 +201,45 @@ private function injectInspectorAttributes(
'parent' => $parentBlock,
'alias' => $blockAlias,
'override' => $isOverride,
'cmsBlockId' => $cmsBlockId,
'performance' => $formattedMetrics['performance'],
'cache' => $formattedMetrics['cache'],
];

// JSON encode with proper escaping for HTML comments
$jsonMetadata = json_encode($metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

if ($jsonMetadata === false) {
return $html;
}

// Escape any comment terminators in JSON to prevent breaking out of comment
$jsonMetadata = str_replace('-->', '-->', $jsonMetadata);

// Wrap content with comment markers
$wrappedHtml = sprintf(
"<!-- MAGEFORGE_START %s -->\n%s\n<!-- MAGEFORGE_END %s -->",
$jsonMetadata,
// Escape single quotes so JSON can be safely embedded in a single-quoted HTML attribute.
// The browser automatically decodes HTML entities when getAttribute() is called,
// so JSON.parse() on the JS side will receive the correct string.
$safeJson = str_replace("'", '&#39;', $jsonMetadata);
Comment on lines +215 to +218
Comment on lines +215 to +218

// Inject data-mageforge-* attributes on the first root HTML element.
// This avoids HTML comment nodes which corrupt markup when block output is
// embedded inside HTML attribute values (e.g. PageBuilder URL blocks in href="...").
$replaced = false;
$result = preg_replace_callback(
'/^(\s*<[a-zA-Z][a-zA-Z0-9]*)/s',
function (array $matches) use ($wrapperId, $safeJson, &$replaced): string {
$replaced = true;
return $matches[0]
. ' data-mageforge-id="' . $wrapperId . '"'
. ' data-mageforge-block=\'' . $safeJson . "'";
},
$html,
$wrapperId,
1,
);
Comment on lines +224 to 234

return $wrappedHtml;
// If content doesn't start with an HTML element (e.g. plain text, URLs),
// skip injection to avoid corrupting attribute values in parent templates.
if (!$replaced || $result === null) {
return $html;
}

return $result;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Service/Hyva/CompatibilityChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ private function getStatusDisplay(array $moduleData): string
return '<fg=green>✓ Compatible</>';
}

if ($moduleData['compatible'] && $moduleData['hasWarnings']) {
if ($moduleData['compatible']) {
return '<fg=yellow>⚠ Warnings</>';
}

Expand Down
183 changes: 79 additions & 104 deletions src/view/frontend/web/js/inspector/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,134 +4,109 @@

export const domMethods = {
/**
* Parse MageForge comment markers in DOM
* Find all MageForge block elements in DOM.
*
* Blocks are identified by the data-mageforge-id attribute injected by
* InspectorHints on the first root HTML element of each rendered block.
*
* @returns {Array<{ data: Object, elements: Element[] }>}
*/
parseCommentMarker(comment) {
const text = comment.textContent.trim();

// Check if it's a start marker
if (text.startsWith('MAGEFORGE_START ')) {
const jsonStr = text.substring('MAGEFORGE_START '.length);
try {
// Unescape any escaped comment terminators
const unescapedJson = jsonStr.replace(/--&gt;/g, '-->');
return {
type: 'start',
data: JSON.parse(unescapedJson)
};
} catch (e) {
console.error('Failed to parse MageForge start marker:', e);
return null;
findAllMageForgeBlocks() {
const blocks = [];
const elements = document.querySelectorAll('[data-mageforge-id]');
for (const el of elements) {
const block = this._parseBlockElement(el);
if (block) {
blocks.push(block);
}
}

// Check if it's an end marker
if (text.startsWith('MAGEFORGE_END ')) {
const id = text.substring('MAGEFORGE_END '.length).trim();
return blocks;
},

/**
* Parse block metadata from an element's data-mageforge-block attribute.
*
* @param {Element} el
* @returns {{ data: Object, elements: Element[] }|null}
*/
_parseBlockElement(el) {
const blockJson = el.getAttribute('data-mageforge-block');
if (!blockJson) return null;

try {
const data = JSON.parse(blockJson);
data.id = el.getAttribute('data-mageforge-id');
return {
type: 'end',
id: id
data,
elements: [el, ...el.querySelectorAll('*')],
};
} catch (e) {
console.error('Failed to parse MageForge block data:', e);
return null;
}

return null;
},

/**
* Find all MageForge block regions in DOM
* Find the MageForge block that contains a given element.
*
* Primary: walks up via closest() for the nearest [data-mageforge-id] ancestor.
* Fallback: for PageBuilder content with multiple root elements (rows), only the
* first root gets data-mageforge-id injected. Walk up to the root [data-content-type]
* element and search siblings for the nearest [data-mageforge-id].
*
* @param {Element} element
* @returns {{ data: Object, elements: Element[] }|null}
*/
findAllMageForgeBlocks() {
const blocks = [];
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_COMMENT,
null
);

const stack = [];
let comment;

while ((comment = walker.nextNode())) {
const parsed = this.parseCommentMarker(comment);

if (!parsed) continue;

if (parsed.type === 'start') {
stack.push({
startComment: comment,
data: parsed.data,
elements: []
});
} else if (parsed.type === 'end' && stack.length > 0) {
const currentBlock = stack[stack.length - 1];
if (currentBlock.data.id === parsed.id) {
currentBlock.endComment = comment;

// Collect all elements between start and end comments
currentBlock.elements = this.getElementsBetweenComments(
currentBlock.startComment,
currentBlock.endComment
);

blocks.push(currentBlock);
stack.pop();
}
}
findBlockForElement(element) {
const blockEl = element.closest('[data-mageforge-id]');
if (blockEl) return this._parseBlockElement(blockEl);

// PageBuilder fallback: multi-root CMS blocks (e.g. multiple rows)
const rootPb = this._findRootPageBuilderElement(element);
if (rootPb) {
const sibling = this._findNearestMageForgeBlock(rootPb);
if (sibling) return this._parseBlockElement(sibling);
}

return blocks;
return null;
},

/**
* Get all elements between two comment nodes
* Walk up the DOM to find the topmost [data-content-type] element (PageBuilder root).
*
* @param {Element} element
* @returns {Element|null}
*/
getElementsBetweenComments(startComment, endComment) {
const elements = [];
let node = startComment.nextSibling;

while (node && node !== endComment) {
if (node.nodeType === Node.ELEMENT_NODE) {
elements.push(node);
// Also add all descendants
elements.push(...node.querySelectorAll('*'));
_findRootPageBuilderElement(element) {
let current = element;
let rootPb = null;
while (current && current !== document.body) {
if (current.hasAttribute('data-content-type')) {
rootPb = current;
}
node = node.nextSibling;
current = current.parentElement;
}

return elements;
return rootPb;
},

/**
* Find MageForge block data for a given element
* Search preceding and following siblings for the nearest [data-mageforge-id] element.
*
* @param {Element} element
* @returns {Element|null}
*/
findBlockForElement(element) {
// Cache blocks for performance
if (!this.cachedBlocks || Date.now() - this.lastBlocksCacheTime > 1000) {
this.cachedBlocks = this.findAllMageForgeBlocks();
this.lastBlocksCacheTime = Date.now();
_findNearestMageForgeBlock(element) {
let sibling = element.previousElementSibling;
while (sibling) {
if (sibling.hasAttribute('data-mageforge-id')) return sibling;
sibling = sibling.previousElementSibling;
}

let closestBlock = null;
let closestDepth = -1;

// Find the deepest (most specific) block containing this element
for (const block of this.cachedBlocks) {
if (block.elements.includes(element)) {
// Calculate depth (how many ancestors between element and body)
let depth = 0;
let node = element;
while (node && node !== document.body) {
depth++;
node = node.parentElement;
}

if (depth > closestDepth) {
closestBlock = block;
closestDepth = depth;
}
}
sibling = element.nextElementSibling;
while (sibling) {
if (sibling.hasAttribute('data-mageforge-id')) return sibling;
sibling = sibling.nextElementSibling;
}

return closestBlock;
return null;
},
};
6 changes: 6 additions & 0 deletions src/view/frontend/web/js/inspector/picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ export const pickerMethods = {
return target;
}

// For PageBuilder elements where no block could be resolved (e.g. injection
// was skipped entirely), still open the inspector showing inherited/no-data state.
if (target.closest('[data-content-type]')) {
return target;
}

return null;
},
};
Loading
Loading