Plugin structure
Directory by directory through an installed XPAC plugin, using Forms as the worked example.
This is what you find if you open an XPAC plugin's directory on your own server.
The example throughout is xpac-forms; substitute the package name and it
describes any of them.
Four entries, and that is the whole plugin. There is no src/, no build config
and no tooling in a shipped plugin — packages/ is the source you read, dist/
is what the browser gets.
plugin.php
The entry file WordPress reads. It opens with the plugin header:
/**
* Plugin Name: Xpac: Forms
* Plugin URI: https://xpac.io
* Description: XPAC forms description.
* Version: 1.0.0
* Author: XPAC
* Author URI: https://xpac.io
* License: GPLv3
* Text Domain: xpac-forms
* Domain Path: /languages
*/Then the entire bootstrap:
defined('ABSPATH') || exit;
require_once __DIR__ . '/vendor/autoload.php';
\XPACGroup\Plugin\Shared\Admin\MenuRoot::boot();
\XPACGroup\Plugin\Forms\Bootstrap::instance(__FILE__);vendor/autoload.php comes first because everything after it is a class that has
to be autoloaded. MenuRoot::boot() claims the single top-level admin menu that
every XPAC page hangs off, and it runs before the package bootstraps because
the filter it installs has to be in place while they register their settings on
plugins_loaded. Then one Bootstrap::instance(__FILE__) per package.
__FILE__ is how the framework learns the plugin's path, directory and URL; it
is also the file the activation, deactivation and uninstall hooks are registered
against. That is why the argument is passed here and nowhere else.
The header's Version is not duplicated in PHP. Plugin::getVersion()
(vendor/xpac/plugin/Plugin.php:324) memoises the value and delegates to
getPluginData('Version'), which reads it back out of this file with
get_plugin_data() (Plugin.php:341). The assets manager uses it as the
cache-busting version on every enqueue.
packages/
The PHP. One directory per package, PascalCase, each mapped to its own namespace:
Inside a package the layout is the package's own business — Forms puts its
classes under core/base, core/entries and core/validation, other packages
are flatter. The only fixed rule is Bootstrap.php at the package root.
These are real, citable paths. When the hook reference says a hook fires at
packages/Forms/core/base/Uploader.php:167, that file is at exactly that path
inside the installed plugin.
packages/Shared
Every XPAC plugin ships packages/Shared. It holds the code that belongs to no
single feature:
| File | What it is |
|---|---|
Admin/MenuRoot.php | Claims the one top-level admin menu, so page order is not decided by plugin load order |
Admin/AdminApp.php | Base class for an admin page that mounts a React app; subclasses pass config and implement bootData() |
Admin/Brand.php | The name, slug and icon of that menu root |
Extensions/Extensions.php | The extensions screen: lists XPAC addons, installs and activates the ones the licence covers |
Settings/* | The settings API — Settings, Page, Field, MenuPage, Rest, Helper |
Because it is copied into every plugin, code in Shared cannot assume it is the
only copy loaded on the site. Settings/Settings.php resolves its own asset
paths from __DIR__ rather than from the plugin singleton for exactly that
reason: on a site running several XPAC plugins, the singleton answers for
whichever one constructed it first.
dist/
Compiled output. A feature package's bundle takes the package name in lowercase,
so Forms builds to dist/packages/forms/:
dist/packages/forms/
├── admin/ the admin app bundle
├── blocks/ one directory per block
├── form/ the editor sidebar bundle
├── plugins/ standalone scripts, e.g. the input-mask plugin
├── masks.json
├── mime-types.json
└── styles.jsonpackages/Shared is the exception to that naming. Its settings screen compiles
to dist/packages/settings/, so a plugin shipping one feature package still has
two directories here.
Each block directory holds the metadata plus everything it references:
dist/packages/forms/blocks/text/
├── block.json
├── index.js editorScript
├── index.asset.php its dependency manifest
├── index.css
├── index-rtl.css
├── view.js viewScript
└── view.asset.php the viewScript's manifestPHP registers blocks from dist/, never from packages/. Forms registers
its form block with
register_block_type(Bootstrap::instance()->getPluginDirPath('dist/packages/forms/blocks/form'))
at packages/Forms/core/base/Blocks.php:331. Move or empty dist/ and the
plugin registers no blocks at all.
.asset.php next to each script is the dependency manifest WordPress reads: the
handles that script needs, plus a version hash used for cache busting. Every
script gets its own, which is why text/ carries both index.asset.php and
view.asset.php. The editor manifest lists sixteen handles, starting at lodash
and react and running through the wp-* packages. Source maps ship beside the
bundles.
The block reference names this path for every block — the
Form block page cites
dist/packages/forms/blocks/form/block.json, which is the file at exactly that
path in your install.
vendor/
Composer's directory, and the only place third-party and framework code lives.
vendor/xpac/plugin is the framework: Plugin.php (the base class every
Bootstrap extends), SettingsManager.php, PagesManager.php and
PluginPage.php. vendor/xpac/assets-manager is AssetsManager.php, which
handles script and style registration. A package that also needs licensing pulls
in xpac/licensing, whose Updater its Bootstrap registers — Akismet does
exactly that.
The framework namespaces are versioned: XPACGroup\PluginFramework\v1_0_12,
XPACGroup\AssetsManager\v1_0_5. The version is part of the class name, not
metadata beside it, so every import spells it out —
use XPACGroup\PluginFramework\v1_0_12\Plugin; at the top of every
Bootstrap.php.
vendor/composer/autoload_psr4.php is the generated map, and it is worth reading
once because it tells you where every class in the plugin actually comes from.
Its package and framework entries, with one further legacy entry left out that
maps an older third-party namespace onto packages/Shared/Settings:
return array(
'XPACGroup\\Plugin\\Shared\\' => array($baseDir . '/packages/Shared'),
'XPACGroup\\Plugin\\Forms\\Validation\\' => array($baseDir . '/packages/Forms/core/validation'),
'XPACGroup\\Plugin\\Forms\\Rules\\' => array($baseDir . '/packages/Forms/core/validation/rules'),
'XPACGroup\\Plugin\\Forms\\Entries\\' => array($baseDir . '/packages/Forms/core/entries'),
'XPACGroup\\Plugin\\Forms\\Core\\' => array($baseDir . '/packages/Forms/core/base'),
'XPACGroup\\Plugin\\Forms\\' => array($baseDir . '/packages/Forms'),
'XPACGroup\\PluginFramework\\v1_0_12\\' => array($vendorDir . '/xpac/plugin'),
'XPACGroup\\AssetsManager\\v1_0_5\\' => array($vendorDir . '/xpac/assets-manager'),
);$baseDir is the plugin root and $vendorDir is vendor/, so the split is
visible in one glance: package namespaces point back into packages/, framework
namespaces point into vendor/xpac/.