Skip to content
Draft
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
29 changes: 28 additions & 1 deletion _bootstrap/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@
], 'name', true)) {
echo "Error creating MagicPreview Plugin.\n";
}

if (!createObject('modSnippet', [
'name' => 'mpField',
'description' => 'Returns click-to-field data attributes for a resource field or TV.',
'static' => true,
'static_file' => $componentPath.'/core/components/magicpreview/elements/snippets/mpfield.snippet.php',
], 'name', true)) {
echo "Error creating mpField snippet.\n";
}
$vcPlugin = $modx->getObject('modPlugin', ['name' => 'MagicPreview']);
if ($vcPlugin) {
if (!createObject('modPluginEvent', [
Expand Down Expand Up @@ -127,7 +136,7 @@
}
if (!createObject('modPluginEvent', [
'pluginid' => $vcPlugin->get('id'),
'event' => 'OnWebPagePrerender',
'event' => 'ContentBlocks_BeforeParse',
'priority' => 0,
], ['pluginid','event'], false)) {
echo "Error creating modPluginEvent.\n";
Expand All @@ -139,6 +148,24 @@
], ['pluginid','event'], false)) {
echo "Error creating modPluginEvent.\n";
}

// Drop event registrations older versions created that are no longer
// handled. Mirrors _build/resolvers/staleevents.resolver.php, which does
// the same on package upgrade -- keep the two lists in step.
$staleEvents = [
'OnWebPagePrerender',
'OnWebPageComplete',
];
foreach ($staleEvents as $staleEvent) {
$stalePluginEvent = $modx->getObject('modPluginEvent', [
'pluginid' => $vcPlugin->get('id'),
'event' => $staleEvent,
]);
if ($stalePluginEvent) {
$stalePluginEvent->remove();
echo "Removed stale modPluginEvent {$staleEvent}.\n";
}
}
}


Expand Down
21 changes: 20 additions & 1 deletion _build/build.transport.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function getSnippetContent($filename = '') {
/* define version */
define('PKG_NAME','MagicPreview');
define('PKG_NAME_LOWER',strtolower(PKG_NAME));
define('PKG_VERSION','1.7.1');
define('PKG_VERSION','1.8.0');
define('PKG_RELEASE','pl');

/* load modx */
Expand Down Expand Up @@ -89,6 +89,10 @@ function getSnippetContent($filename = '') {
'type' => 'php',
'source' => $sources['resolvers'] . 'customevents.resolver.php',
],
[
'type' => 'php',
'source' => $sources['resolvers'] . 'staleevents.resolver.php',
],
]
]
);
Expand Down Expand Up @@ -142,6 +146,21 @@ function getSnippetContent($filename = '') {
$modx->log(modX::LOG_LEVEL_INFO,'Packaged in '.count($plugins).' plugins.'); flush();
unset($plugins,$plugin,$attributes);

/* add snippets */
$snippets = include $sources['data'] . 'transport.snippets.php';
if (!is_array($snippets)) { $modx->log(modX::LOG_LEVEL_FATAL,'Adding snippets failed.'); }
$attributes= [
xPDOTransport::UNIQUE_KEY => 'name',
xPDOTransport::PRESERVE_KEYS => false,
xPDOTransport::UPDATE_OBJECT => true,
];
foreach ($snippets as $snippet) {
$vehicle = $builder->createVehicle($snippet, $attributes);
$builder->putVehicle($vehicle);
}
$modx->log(modX::LOG_LEVEL_INFO,'Packaged in '.count($snippets).' snippets.'); flush();
unset($snippets,$snippet,$attributes);


/* now pack in the license file, readme and setup options */
$builder->setPackageAttributes([
Expand Down
13 changes: 13 additions & 0 deletions _build/data/transport.snippets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php
$snippets = [];

$snippets[0] = $modx->newObject('modSnippet');
$snippets[0]->fromArray([
'id' => 1,
'name' => 'mpField',
'description' => 'Returns click-to-field data attributes for a resource field or TV.',
'snippet' => getSnippetContent($sources['snippets'] . 'mpfield.snippet.php'),
'category' => 0,
], '', true, true);

return $snippets;
2 changes: 1 addition & 1 deletion _build/events/events.magicpreview.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
'OnDocFormSave',
'OnLoadWebDocument',
'OnManagerPageBeforeRender',
'OnWebPagePrerender',
'ContentBlocks_BeforeParse',
'ContentBlocks_AfterParse',
];

Expand Down
41 changes: 41 additions & 0 deletions _build/resolvers/staleevents.resolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
/**
* Removes plugin event registrations that older MagicPreview versions created
* but current versions no longer use.
*
* The plugin vehicle registers PluginEvents with UPDATE_OBJECT => false, so
* dropping an event from the package does not delete an existing row. Without
* this, an upgraded install keeps firing the MagicPreview plugin on every
* front-end page render for OnWebPagePrerender, which no longer has a handler.
*
* @var modX $modx
* @var modTransportPackage $transport
* @var array $options
*/
if ($transport->xpdo) {
$modx = $transport->xpdo;

$staleEvents = [
'OnWebPagePrerender',
'OnWebPageComplete',
];

switch ($options[xPDOTransport::PACKAGE_ACTION]) {
case xPDOTransport::ACTION_UPGRADE:
$plugin = $modx->getObject('modPlugin', ['name' => 'MagicPreview']);
if ($plugin) {
foreach ($staleEvents as $eventName) {
$pluginEvent = $modx->getObject('modPluginEvent', [
'pluginid' => $plugin->get('id'),
'event' => $eventName,
]);
if ($pluginEvent) {
$pluginEvent->remove();
}
}
}

break;
}
}
return true;
12 changes: 12 additions & 0 deletions core/components/magicpreview/docs/changelog.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
MagicPreview 1.8.0-pl
---------------------
Released on 2026-08-14

- Fix click_to_field breaking Fenom (and any other custom parser) output in previews. MagicPreview no longer replaces the MODX parser during a preview render. (#55)
- BREAKING: core resource fields and TVs are no longer marked clickable automatically. Mark them in your template with the new mpField snippet, e.g. <h1 [[mpField? &name=`pagetitle`]]>[[*pagetitle]]</h1>
- Add mpField snippet, usable as a MODX tag or a Fenom modifier ({'pagetitle'|mpField})
- Add [[+mpClickToFieldAttributes]] / {$mpClickToFieldAttributes} placeholder for ContentBlocks field templates, so the attributes can go on your own element instead of an added wrapper. This also gives @PDO_FILE field templates a click target for the first time.
- ContentBlocks fields keep their automatic wrapper unless the template uses the placeholder
- Strip the ContentBlocks placeholder on every save, not only during a preview, so it is never stored in the resource content
- Only replace the parser during preview generation when ContentBlocks marking actually needs it

MagicPreview 1.7.1-pl
---------------------
Released on 2026-07-10
Expand Down
36 changes: 36 additions & 0 deletions core/components/magicpreview/docs/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,39 @@ Support & Documentation
-----------------------

Full documentation is available at: https://www.modmore.com/extras/magicpreview/

Click to Field
--------------

With the magicpreview.click_to_field system setting enabled, clicking part of the
preview scrolls the resource form to the matching field.

MagicPreview does not guess where your fields are rendered - you mark them.

Core resource fields and TVs, with the mpField snippet:

<h1 [[mpField? &name=`pagetitle`]]>[[*pagetitle]]</h1>
<div [[mpField? &name=`mytv`]]>[[*mytv]]</div>

With pdoTools/Fenom, any of these work:

<h1 {'pagetitle' | mpField}>{$_modx->resource.pagetitle}</h1>
<h1 {'mpField' | snippet: ['name' => 'pagetitle']}>...</h1>

ContentBlocks fields are wrapped automatically and need no changes. To place the
attributes on your own element instead, use the placeholder - the wrapper is then
skipped for that field:

<h2 [[+mpClickToFieldAttributes]]>[[+value]]</h2>
<h2 {$mpClickToFieldAttributes}>{$value}</h2>

ContentBlocks field templates using @CHUNK are a special case: parse() passes the
field data to the chunk as properties, so write the attributes directly:

<h2 data-magicpreview-field="[[+field]]" data-magicpreview-idx="[[+field_type_idx]]">

The snippet and placeholder produce nothing outside a manager preview, so they are
safe to leave in live templates and do not appear on public share links. Outside a
preview the ContentBlocks placeholder is removed as the field is parsed, so it is
not stored in the resource content either. The @CHUNK form above is the exception:
its attributes are written by hand and are always present in the saved content.
123 changes: 58 additions & 65 deletions core/components/magicpreview/elements/plugins/magicpreview.plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
define('MAGICPREVIEW_FILTER_ALLOW', 'Allow Listed Only');
define('MAGICPREVIEW_RESOURCE_ENABLED', 'Yes');
define('MAGICPREVIEW_RESOURCE_DISABLED', 'No');
define('MAGICPREVIEW_CB_PLACEHOLDER_MODX', '[[+mpClickToFieldAttributes]]');
define('MAGICPREVIEW_CB_PLACEHOLDER_FENOM', '{$mpClickToFieldAttributes}');
}

$path = $modx->getOption('magicpreview.core_path', null, $modx->getOption('core_path') . 'components/magicpreview/');
Expand Down Expand Up @@ -266,17 +268,15 @@
$service->applyPreviewData($modx->resource, $data);
}

if ($modx->getOption('magicpreview.click_to_field', null, false)) {
// No restoration needed — the request ends after the page is rendered.
$modx->getParser();
if (!class_exists('MagicPreviewCoreParser', false)) {
require_once $service->config['modelPath'] . 'magicpreview/MagicPreviewCoreParser.class.php';
}
$modx->parser = new MagicPreviewCoreParser($modx);

if ($service->isClickToFieldActive()) {
$modx->regClientStartupHTMLBlock('<style>
[data-magicpreview-field]{cursor:pointer;}
[data-magicpreview-field]:hover{outline:2px dashed rgba(52,152,219,0.6);outline-offset:2px;}
/* The ContentBlocks wrapper is display:contents, so it generates no box and the
rule above has nothing to paint on. Outline its children instead, and only for
the innermost wrapper under the cursor so nested layouts match what a click
actually targets (the handler uses closest()). */
.mmmp-cb-field:hover:not(:has(.mmmp-cb-field:hover))>*{outline:2px dashed rgba(52,152,219,0.6);outline-offset:2px;}
</style>
<script>
document.addEventListener("click",function(e){
Expand All @@ -291,6 +291,49 @@

break;

case 'ContentBlocks_BeforeParse':
/**
* @var string $tpl Field template, before ContentBlocks parses it
* @var array $phs Field data: 'field' (id), 'field_type_idx', settings
*
* Resolves the click-to-field placeholder so the template author can put
* the attributes on their own element instead of receiving the automatic
* wrapper. Fires for every ContentBlocks template style that reaches
* parse(), including @PDO_FILE, which returns before
* ContentBlocks_AfterParse and therefore never gets a wrapper.
*
* The placeholder is resolved on every parse, not only during a preview:
* ContentBlocks writes generateHtml() output straight into the resource
* content column, so on a normal save an unresolved placeholder would be
* stored there verbatim. It never renders (MODX drops unresolved tags on
* the final parse pass, and Fenom renders an undefined variable empty),
* but anything reading content directly would see it. Outside a preview
* it therefore resolves to an empty string.
*/
if (!is_string($tpl)
|| (strpos($tpl, MAGICPREVIEW_CB_PLACEHOLDER_MODX) === false
&& strpos($tpl, MAGICPREVIEW_CB_PLACEHOLDER_FENOM) === false)) {
// Template did not opt in; the automatic wrapper still applies.
break;
}
$mpAttrs = '';
// addFieldMarkers, NOT isClickToFieldActive(): the marking window opens
// during the manager preview-generation request, where there is no
// show_preview query parameter. PreviewTrait sets this flag for exactly
// that window. Anywhere else the placeholder is simply stripped.
if ($service->addFieldMarkers
&& is_array($phs) && array_key_exists('field', $phs) && isset($phs['field_type_idx'])) {
$mpAttrs = 'data-magicpreview-field="' . (int)$phs['field'] . '"'
. ' data-magicpreview-idx="' . (int)$phs['field_type_idx'] . '"';
$service->cbOptedIn[$phs['field'] . ':' . $phs['field_type_idx']] = true;
}
$modx->event->output(str_replace(
[MAGICPREVIEW_CB_PLACEHOLDER_MODX, MAGICPREVIEW_CB_PLACEHOLDER_FENOM],
$mpAttrs,
$tpl
));
break;

case 'ContentBlocks_AfterParse':
/**
* @var string $tpl Rendered field output (by reference — set via $modx->event->output())
Expand All @@ -313,70 +356,20 @@
if (array_key_exists('value', $phs) && array_key_exists('items', $phs)) {
break;
}
// The template placed the attributes itself via the placeholder, so do
// not also wrap it — see ContentBlocks_BeforeParse.
if (!empty($service->cbOptedIn[$phs['field'] . ':' . $phs['field_type_idx']])) {
break;
}
$modx->event->output(
'<div style="display:contents"'
'<div class="mmmp-cb-field" style="display:contents"'
. ' data-magicpreview-field="' . (int)$phs['field'] . '"'
. ' data-magicpreview-idx="' . (int)$phs['field_type_idx'] . '">'
. $tpl
. '</div>'
);
break;

case 'OnWebPagePrerender':
if (!array_key_exists('show_preview', $_GET)) {
break;
}
$output = &$modx->resource->_output;
if (strpos($output, "\x02") === false) {
break;
}

// Four passes: strip from <head>, strip from <script>/<style> bodies,
// strip from HTML opening-tag attribute values, then convert what
// remains in body text to click-to-field spans.
$output = preg_replace_callback(
'/(<head[^>]*>)(.*?)(<\/head>)/si',
function ($m) {
return $m[1]
. preg_replace("/\x02MMMP:[^\x02]*\x02(.*?)\x03MMMP\x03/s", '$1', $m[2])
. $m[3];
},
$output
);

// Strip from <script> and <style> bodies in the page body — a marker
// inside a JS string literal would otherwise become a <span> tag.
$output = preg_replace_callback(
'/<(script|style)[^>]*>.*?<\/\1>/si',
function ($m) {
return preg_replace("/\x02MMMP:[^\x02]*\x02(.*?)\x03MMMP\x03/s", '$1', $m[0]);
},
$output
);

// Strip from HTML opening tags. The regex handles quoted attribute values
// so a literal > inside an attribute (e.g. content="a > b") does not
// cause early termination and leave a marker tail in body-text position.
$output = preg_replace_callback(
'/<[a-zA-Z][^>"\']*(?:"[^"]*"|\'[^\']*\'|[^>])*>/s',
function ($m) {
return preg_replace("/\x02MMMP:[^\x02]*\x02(.*?)\x03MMMP\x03/s", '$1', $m[0]);
},
$output
);

$output = preg_replace_callback(
"/\x02MMMP:([^\x02]*)\x02(.*?)\x03MMMP\x03/s",
function ($m) {
return '<span data-magicpreview-field="' . htmlspecialchars($m[1], ENT_QUOTES) . '"'
. ' style="display:contents">'
. $m[2]
. '</span>';
},
$output
);
break;

}

return true;
return true;
Loading