Making Paths and URLs in WordPress reliable
With the merging of the WordPress REST API PR into the CiviCRM-WordPress plugin imminent (well, in the near future) this might be a good time to talk about the way that CiviCRM builds its paths and URLs in WordPress and what the future should look like.
I'm basing quite a lot of the following on the MatterMost thread starting here, my work in implementing Clean URLs, recent efforts to make CiviCRM work with multiple WordPress languages and, beyond that, implementing compatibility with multilingual plugins, starting with Polylang.
Why is the REST API wrapper important?
Largely covered by discussion on this issue but the tl;dr
is that, with WordPress REST API wrapper in place, there is no longer any need to call scripts in CiviCRM's extern
directory. It is actually the final piece of the WordPress-integration puzzle and Andrei has done a superb job making it happen.
The first major win is that CiviCRM is guaranteed to know all of the above paths from WordPress - something it cannot do when it tries to bootstrap WordPress. A second win is that no scripts in the wp-content
directory are ever called directly, avoiding problems such as this issue with Mailings. I'm sure there are many more benefits of simplifying complicated and fragile code.
Why are languages important?
Paths and URLs are central to the way in which WordPress multilingual plugins work. With Polylang, for example, https://site.org/en/slug/
and https://site.org/de/slug/
are, with one particular choice of settings, the same page in different languages. Other settings choices might produce https://site.org/slug-en/
and https://site.org/slug-de/
. The most pressing implication for CiviCRM is when it comes to identifying the Base Page.
By default, a Base Page with the slug civicrm
is created. However, this may be assigned a language via Polylang and a "translated" Base Page could have be created with a different slug. The Polylang settings allow for language selection from the loaded page - actually a great way for CiviCRM to show its content on the front-end in the appropriate language. The trick is to figure out which Base Page is which, since $config->wpBasePage
is no longer a reliable guide.
Luckily for us, when all routes go through WordPress, we can solve these issues reliably.
Requirements
From what I can tell, CiviCRM needs to know these paths and URLs:
WordPress UI
- The URL to the WordPress Admin page where the CiviCRM back-end action happens
- The URL of the WordPress Base Page, where the default CiviCRM front-end action happens (i.e. not via a Shortcode)
WordPress plugin directory
- The URL of the CiviCRM plugin directory, for finding Core resources
- The path to the CiviCRM plugin directory, for Core autoloaders and scripts
WordPress files directory
- The URL of the CiviCRM files directory, for finding Extension and other resources
- The path to the CiviCRM files directory, for
civicrm.settings.php
, Extensions and other includes
The Code
Ignoring multilingual plugins like Polylang and WPML for now, CRM_Utils_System_WordPress:: getBaseUrl()
could just be replaced with:
private function getBaseUrl($absolute, $frontend, $forceBackend) {
$config = CRM_Core_Config::singleton();
if ((is_admin() && !$frontend) || $forceBackend) {
return admin_url('admin.php');
}
else {
$basepage = get_page_by_path($config->wpBasePage);
return get_permalink($basepage->ID);
}
}
I've tested this extensively (excluding extern
routes, of course) and cannot find a situation where this doesn't produce the correct URLs.
(FWIW, even when the route is via extern
, CRM_Utils_System_WordPress::url()
calls $this->loadBootStrap()
prior to calculating the resulting URL, so I suspect this would work even then, assuming that the bootstrap process is reliable.)
Both admin_url()
and get_permalink()
will produce the correct paths whatever the WordPress settings and context are - whether single site, multisite or multi-network. No parsing of URLs in CiviCRM would be needed. I can't find any usage of wp.frontend.base
and wp.backend.base
except as a means to build wp.frontend
and wp.backend
respectively.
As for civicrm.root
, the following will always be correct:
public function getCiviSourceStorage() {
return [
'url' => CIVICRM_PLUGIN_URL . '/civicrm/',
'path' => CIVICRM_PLUGIN_DIR . DIRECTORY_SEPARATOR . 'civicrm' . DIRECTORY_SEPARATOR,
];
}
Therefore all paths based off civicrm.root
can then be defined without resorting to the regexes in Paths::getUrl()
and Paths::getPath()
(or any other kind of parsing, calculation, recursion or traversal) instead they can be defined like this:
$paths = $this;
$sep = DIRECTORY_SEPARATOR;
$storage = \CRM_Core_Config::singleton()->userSystem->getCiviSourceStorage();
$this
->register('civicrm.root', function () {
return $storage;
})
->register('civicrm.packages', function () {
return [
'path' => $storage['path'] . $sep . 'packages' . $sep,
'url' => $storage['url'] . '/packages/',
];
})
// etc etc
And likewise for civicrm.files
, the following is always valid:
public function getDefaultFileStorage() {
$upload_dir = wp_get_upload_dir();
return [
'url' => $upload_dir['baseurl'] . '/civicrm/',
'path' => $upload_dir['basedir'] . DIRECTORY_SEPARATOR . 'civicrm' . DIRECTORY_SEPARATOR,
];
}
And finally, I can't think of any reason why CiviCRM needs to know cms.root
other than to link to the homepage perhaps. I think all relevant paths that CiviCRM needs are covered by the above.
Where now?
- I know how to override classes in CiviCRM's
CRM
directory, but could use some advice on how one might do that for classes in CiviCRM'sCivi
directory. - Failing that, how to modify
Paths::getPath()
to accommodate the changes I've detailed here.
With the above approach applied, the groundwork will have been done to further enhance the code so that CiviCRM can accommodate Polylang, WPML, qTranslate and other multilingual plugins. Knowing what WordPress knows, being certain that it's bootstrapped and having access to the means of building native paths and URLs make this possible.