Problem

Sometimes a third-party module may need to affect the behavior of all MM pages on the site, without replicating the work of mm_show_page() and using hook_menu_alter() to replace it. Currently, there exists no complete method (or at least, I couldn't find one) to have such a universal impact on MM pages. The obvious choices of hook_mm_showpage_router() or hook_preprocess_HOOK() are insufficient because:

  • hook_preprocess_HOOK() happens after MM has already called render() on the page's nodes
  • hook_mm_showpage_router() is designed to allow adding nodes to a page (and/or removing all normal nodes) at a certain path, either an exact path or a wildcard match (but the levels of the path need to match). Even if a universal wildcard like '%' were allowed, the hook would still be insufficient because there (at least currently) can only be one registered showpage router for each path (so only one module could register itself for the '%' wildcard path)

Neither method allows a generic "I just want to conditionally add a node to every page regardless of path," or "I want to conditionally replace the entire page output regardless of path" approach (without circumventing mm_show_page() altogether).

Example use case

We've written a bridge module to panelize MM pages. The desire is to allow folks with MM_PERMS_WRITE on the page to run that page through the Panels module. All Panes on the corresponding Display are actually nodes attached to the MM page. This allows for deep customization of layouts on a page-per-page basis. Using hook_preprocess() implementations resulted in nodes being rendered twice: once for the mm_show_page() call to _mm_render_pages(), and then again when Panels rendered the Display (Panels jargon) for the MM page. Aside from the obvious needless performance degradation, it caused some corner-case problems with content types that used nonce's or build id's that were changed between renders (e.g. Webforms with the CAPTCHA module).

Proposed solution

Adding (these names are speculative) hook_mm_showpage_prerender and hook_mm_showpage_postrender invocations to mm_show_page(), right above and below the call to _mm_render_pages(), respectively, allows any number of modules to directly influence a page's render, regardless of path.

Our example use case returns an empty $mmtids array in the first hook, preventing any double-rendering by _mm_render_pages(), then replaces the empty $output variable with the resulting Panel content in the latter hook, and restores $mmtids so that page titles, breadcrumbs, etc. can be rendered consistently by the rest of the logic in mm_show_page(). This approach also allows MM to continue to manage page read permissions per-mmtid, plus any other magic that comes from honoring the usual MM render path.

Comments

gribnif’s picture

Hi Jay,

I've been looking at your patch, and it just seems a little too hackish for me. You're using it to change variables that really aren't intended to be changeable. I wonder if there's another way to do the same thing, possibly at the _mm_render_pages() level, so that it would be cleaner.

Could you show me the code you're using now in your hooks to go with this patch?

jay.dansand’s picture

Sure! Here's what we're doing in mm_panels.module (we haven't committed this change to the github copy yet, pending resolution of this issue).

/**
 * Implements hook_mm_showpage_prerender().
 */
function mm_panels_mm_showpage_prerender(&$mmtids, &$oarg_list, &$err) {
  $panelized = &drupal_static('mm_panels_page', FALSE);
  $this_mmtid = end($mmtids);
  reset($mmtids);
  // If "Panelize this page" is checked set $panelized to $mmtids.
  // In theory, $oarg_list is only NON-empty when there are unresolved path
  // components after $this_mmtid in the URL (ultimately, a 404).
  if (empty($oarg_list) && _mm_panels_get_current_status($this_mmtid)) {
    $panelized = $mmtids;
    // Stop anything else from being rendered.
    $mmtids = array(1);
  }
}

/**
 * Implements hook_mm_showpage_postrender().
 */
function mm_panels_mm_showpage_postrender(&$mmtids, &$oarg_list, &$err, &$output) {
  $panelized = &drupal_static('mm_panels_page', FALSE);
  if (!empty($panelized)) {
    $this_mmtid = end($panelized);
    reset($mmtids);
    $err = '';
    $output = mm_panels_page($this_mmtid);
  }
}

Basically, all we need is a pair of preprocess and process hooks, to let mm_show_page() still run, but:

  1. change the MMTIDs that are rendered more granularly than hook_mm_showpage_router() allows
  2. alter the $content post-_mm_render_pages() execution
gribnif’s picture

Jay,

I'd like to consider the hook_mm_showpage_router() method further. It does allow a path of '%'; you just have to make sure to set 'partial path' to TRUE, as well.

Why is it a limitation for you that only one hook can be registered per path? Is it really likely for there to be more than one? I could store an array of hooks per path instead of a single hook, if necessary.

Assuming the above is workable, I think this code would work (untested):

function foo_mm_showpage_routing() {
  return array(
    'page callback' => 'foo_callback',
    'page arguments' => array('_mmtid_', '_all_'),
    'partial path' => TRUE,
  );
}

function foo_callback() {
  $args = func_get_args();
  $this_mmtid = array_shift($args);
  if (!$args && _mm_panels_get_current_status($this_mmtid)) {
    return array(
      'no_nodes' => TRUE,
      'output_post' => mm_panels_page($this_mmtid),
    );
  }
}
gribnif’s picture

OK, that sample code won't work as written. Instead of !$args, I think you need to compare array_search($this_mmtid, $args) === count($args) - 1.

jay.dansand’s picture

I ran a bunch of tests with '%' in July 2013, and for some reason it didn't work right; I should have taken better notes. Maybe I forgot to set partial path (boy, would that be embarrassing). Since I don't have total recall of whatever the issue was, I'll try it again in a moment. Tracing through _mm_render_pages() and _mm_showpage_router() now, it definitely looks like it should work, and registering a partial path of "%" should end up with a REGEXP like ^[^/]+(?:$|/), which pretty much matches any mm/$this_mmtid path.

As for multiple callbacks per path, that's mostly future-proofing. If, for example, mm_panels.module registers the '%' path, then that means absolutely no other module could try to do a similar thing (where that similar thing is to conditionally affect the rendering of an arbitrary MM page anywhere in the tree).

jay.dansand’s picture

Well, one thing is after mm_module_invoke_all_array('url_outbound_alter', array(&$temp_path, &$temp_options, $temp_path)); is called, $temp_path is empty on the homepage, so the REGEXP doesn't match.

Should str_replace('%', '[^/]+', preg_quote($path)) be changed to str_replace('%', '[^/]*', preg_quote($path))? Either % could match zero or more characters (which isn't consistent with hook_menu paths), or we could implement Drupal-like behavior and allow for the "*" wildcard:

function _mm_showpage_router($reset = FALSE) {
  // ...
        $path = '{^' . str_replace(array('%', '\\*'), array('[^/]+', '[^/]*'), preg_quote($path)) . $ending;

Edit to add: Note, you need the "\\" in the str_replace() call because preg_quote() turns "*" into "\*".

jay.dansand’s picture

StatusFileSize
new2.07 KB

With the attached patch (allowing the "*" wildcard), I got mm_panels.module working with these functions:

function mm_panels_mm_showpage_routing() {
  return array(
    '*' => array(
      'page callback' => 'mm_panels_showpage_callback',
      'page arguments' => array('_mmtid_', '_all_'),
      'partial path' => TRUE,
    ),
  );
}
function mm_panels_showpage_callback() {
  $args = func_get_args();
  $this_mmtid = array_shift($args);
  $correct_page = FALSE;
  if ($page = mm_content_get($this_mmtid)) {
    $correct_page = end($args) === $page->alias;
  }
  if ($correct_page && _mm_panels_get_current_status($this_mmtid)) {
    return array(
      'no_nodes' => TRUE,
      'output_post' => mm_panels_page($this_mmtid),
    );
  }
}
gribnif’s picture

I like the new patch, except I have one question: why is the asterisk escaped inarray('%', '\\*')? It seems like this would require you to use a path in mm_panels_mm_showpage_routing() that is '\*'.

Also, a suggestion. Your callback could be written a little more concisely:

  $args = func_get_args();
  $this_mmtid = array_shift($args);
  if (($page = mm_content_get($this_mmtid)) && $page->alias === end($args) && _mm_panels_get_current_status($this_mmtid)) {
    return array(
      'no_nodes' => TRUE,
      'output_post' => mm_panels_page($this_mmtid),
    );
  }

Normally I'd try to avoid an extra call to mm_content_get() upon every page load, but in this case the equivalent will probably be done elsewhere anyway, so the data will be cached. It would also not normally be a good idea to assume that the page's alias only appears once in the entire URL, but it's unlikely you'd have something in the oargs that also appears in the MM portion, so I think this is acceptable.

jay.dansand’s picture

Oops, entirely my fault: I realized it wouldn't be obvious so I edited #6 to add the note about why "\\*" is necessary, but Drupal doesn't email out updates to existing comments (thankfully, most of the time). Since the str_replace() happens after preg_quote(), the string(1) "*" has already been turned into string(2) "\*", so you've got to str_replace() the asterisk along with its backslash.

I'd love your expert opinion on the best thing to do for determining $correct_page. Since the callback gets as its arguments an array of aliases, it's impossible to tell where the real path ends and the joined $oargs_list begins without either a) running mm_parse_args() and comparing $this_mmtid, or b) getting the alias of the current page and checking the path to make sure it's the last item (though as you point out, there might be a collision there).

Here's a version of mm_panels.module that goes the mm_parse_args() route, which at least won't ever accidentally render the panel just because the last $oarg happens to match. I don't really know if mm_parse_args() is too heavy a function to use on every page hit, but this should work (as long as nobody ever wants an MM block to also be Panelized):

function mm_panels_mm_showpage_routing() {
  return array(
    '*' => array(
      'page callback' => 'mm_panels_showpage_callback',
      'page arguments' => array('_mmtid_'),
      'partial path' => TRUE,
    ),
  );
}
function mm_panels_showpage_callback($matched_mmtid) {
  $prefix = mm_parse_args($mmtids, $oarg_list, $this_mmtid);
  $correct_page = $matched_mmtid === $this_mmtid && empty($oarg_list);
  if ($correct_page && _mm_panels_get_current_status($this_mmtid)) {
    return array(
      'no_nodes' => TRUE,
      'output_post' => mm_panels_page($this_mmtid),
    );
  }
}

Come to think of it, mm_parse_args() is always called by mm_show_page() anyway, right? So if we cached the result from that call per-$url parameter, then it really wouldn't be any extra hit. Do you think that's a possibility? If so, I'd be happy to roll the patch for that as well. I know we call mm_parse_args() in other modules too, so it'd be nice to speed that up as much as possible.

gribnif’s picture

Hi Jay,

Have a look at the attached patch. It incorporates your patch, but also adds a new "page arguments" token, "_oargs_", which causes the oargs array to be passed. This avoids the extra call to mm_parse_args().

With this patch, I think your example can just assume that if oargs is an empty array, that $matched_mmtid is the mmtid of an actual page with no path data after it. So the code becomes:

function mm_panels_mm_showpage_routing() {
  return array(
    '*' => array(
      'page callback' => 'mm_panels_showpage_callback',
      'page arguments' => array('_mmtid_', '_oargs_'),
      'partial path' => TRUE,
    ),
  );
}
function mm_panels_showpage_callback($matched_mmtid, $oargs) {
  if (!$oargs && _mm_panels_get_current_status($matched_mmtid)) {
    return array(
      'no_nodes' => TRUE,
      'output_post' => mm_panels_page($this_mmtid),
    );
  }
}
jay.dansand’s picture

That looks great to me!

  • Gribnif committed 514dd59 on 7.x-1.x
    Issue #2049555 by jay.dansand, Gribnif: Allow modules to directly affect...
gribnif’s picture

Status: Active » Closed (fixed)