Extending & internals
Pages Plus exposes one filter of its own, on the per-row payload the path line is built from, and nothing else: as of 1.6.1 there is no other apply_filters() and no do_action() anywhere in it. That one is documented below as a contract; everything else on this page is what the plugin attaches to, and the handles, keys and class names you can build on.
The one filter: freshet_pagesplus_path_payload
Every row on the list carries a small payload to the footer script that draws the path line: path (the relative, decoded URL path) and href (the same, or '' when the post is not viewable — a draft, say — so the line renders as text). The payload is built per post and passed through this filter before it is stored:
/**
* @param array $payload Keys `path` and `href`.
* @param WP_Post $post The row's post.
*/
add_filter( 'freshet_pagesplus_path_payload', function ( array $payload, WP_Post $post ): array {
$payload['path'] = rtrim( $payload['path'], '/' );
return $payload;
}, 10, 2 );
The script reads path and href and keeps whatever other keys arrive, so a build that needs more per row — a status, a template, an ID it can act on — adds keys here and reads them back from window.freshetPagesplusPaths[postId] in its own script. Returning an empty array leaves the row without a path line.
What it hooks
Everything except the textdomain load and the duplicate handler is wired inside load-edit.php, scoped to the current screen's post type.
| Hook | Callback | Removable |
|---|---|---|
manage_{$type}_posts_columns |
freshet_pagesplus_columns |
yes |
manage_{$type}_posts_custom_column |
freshet_pagesplus_render_column |
yes |
manage_edit-{$type}_sortable_columns |
freshet_pagesplus_sortable_columns |
yes |
the_title |
freshet_pagesplus_capture_path |
yes |
restrict_manage_posts |
freshet_pagesplus_parent_filter, freshet_pagesplus_template_filter, freshet_pagesplus_modified_before_filter |
yes |
post_row_actions, page_row_actions |
freshet_pagesplus_duplicate_link |
yes |
bulk_actions-edit-{$type} |
freshet_pagesplus_register_bulk_duplicate |
yes |
handle_bulk_actions-edit-{$type} |
freshet_pagesplus_handle_bulk_duplicate |
yes |
admin_notices |
freshet_pagesplus_bulk_notice |
yes |
admin_enqueue_scripts |
freshet_pagesplus_enqueue_assets |
yes |
admin_footer |
freshet_pagesplus_print_paths |
yes |
admin_action_freshet_pagesplus_duplicate |
freshet_pagesplus_duplicate_handler |
yes |
wp_ajax_inline-save (priority 0) |
closure — re-wires the row hooks so a row Quick Edit re-renders keeps its columns, path and Duplicate | no |
pre_get_posts (parent filter, template filter, modified-before filter) |
closure | no |
posts_search (path search) |
closure | no |
posts_clauses (parent sorting) |
closure | no |
the_posts (branch re-rooting) |
closure | no |
The named callbacks can be removed; the closures cannot, because an anonymous function has no handle to pass to remove_filter(). If you need one of those off, deactivating the plugin is the honest answer.
Removing a piece
The named callbacks are added during load-edit.php, so remove them at a later priority on that same hook — anything earlier runs before they are attached:
add_action( 'load-edit.php', function () {
remove_filter( 'post_row_actions', 'freshet_pagesplus_duplicate_link' );
remove_filter( 'page_row_actions', 'freshet_pagesplus_duplicate_link' );
}, 20 );
Dropping a column is easier from the other side — filter the columns after they are added:
add_filter( 'manage_page_posts_columns', function ( array $columns ): array {
unset( $columns['freshet_pagesplus_modified'] );
return $columns;
}, 20 );
Keys and query arguments
| String | What it is |
|---|---|
freshet_pagesplus_modified |
Modified column key |
freshet_pagesplus_parent |
Parent column key; also the filter's query arg (top, or a parent ID) |
freshet_pagesplus_template |
Template column key; also the filter's query arg (a template file, default, or empty) |
freshet_pagesplus_modified_before |
The Modified-before filter's query arg (a Y-m-d date; rows modified before it) |
orderby=freshet_pagesplus_parent |
Sort by parent title |
freshet_pagesplus_duplicated |
Count appended to the redirect after a bulk duplicate |
Each is also available as a constant — FRESHET_PAGESPLUS_COL_MODIFIED, FRESHET_PAGESPLUS_COL_PARENT, FRESHET_PAGESPLUS_COL_TEMPLATE, FRESHET_PAGESPLUS_PARENT_QV, FRESHET_PAGESPLUS_TEMPLATE_QV, FRESHET_PAGESPLUS_MODIFIED_BEFORE_QV, FRESHET_PAGESPLUS_TOP_VALUE, FRESHET_PAGESPLUS_DUP_ACTION, FRESHET_PAGESPLUS_HANDLE, FRESHET_PAGESPLUS_VERSION. So links into a filtered view compose:
$branch = add_query_arg(
[ 'post_type' => 'page', FRESHET_PAGESPLUS_PARENT_QV => $parent_id ],
admin_url( 'edit.php' )
);
Styling
The stylesheet and script share one src-less handle, freshet-pagesplus, registered and enqueued only on the list-table screens the plugin enhances. Add your own rules to it after it is registered:
add_action( 'admin_enqueue_scripts', function () {
if ( wp_style_is( 'freshet-pagesplus', 'enqueued' ) ) {
wp_add_inline_style( 'freshet-pagesplus', '.freshet-pagesplus-path{font-size:13px}' );
}
}, 20 );
The class names are stable:
| Class | Element |
|---|---|
.freshet-pagesplus-path |
The URL path under a title (an <a> when the post is viewable, a <span> otherwise) |
.freshet-pagesplus-sub |
The #ID · age · author line in the Modified column |
.column-freshet_pagesplus_modified and friends |
The columns themselves, via WordPress' usual .column-{key} |
The path payload
Paths are collected while the table renders and printed in the admin footer as window.freshetPagesplusPaths — { postId: { path, href, …} }, where an empty href means "not viewable, render as text" and any further keys are whatever the filter above added. The injector runs once and skips any row that already has a path element, so it is safe to read the payload yourself. The two named keys are the contract; the global's name and the timing of the print are not, and may change.
Where a build extends from
The main file ends with a seam rather than a feature: it registers an autoloader for the FreshetPagesPlus\ namespace, one file per class under src/, and then asks once — on PHP 8.1 or newer — whether FreshetPagesPlus\Extension\Bootstrap exists, and calls its boot() if it does. Nothing above that line depends on the answer.
if ( PHP_VERSION_ID >= 80100 && class_exists( FreshetPagesPlus\Extension\Bootstrap::class ) ) {
FreshetPagesPlus\Extension\Bootstrap::boot();
} elseif ( PHP_VERSION_ID < 80100 && is_readable( __DIR__ . '/src/compat.php' ) ) {
require __DIR__ . '/src/compat.php';
}
The wordpress.org build has no src/ directory, so the class does not exist, the line is a no-op, and the plugin is whole as it stands — every function, hook and constant on this page is in that file. A build that carries src/ wires itself up from there, on a PHP its classes are written for; below that PHP the same build shows one notice from src/compat.php, a file written for the older PHP on its own, and loads nothing else. If you are writing a companion plugin rather than a build, you do not need the seam: the functions above are plain global functions, the constants are constants, and the payload filter is where per-row data goes.
Translation
Text domain freshet-pages-plus, loaded on init from the plugin's own /languages, so a bundled .mo works as well as one delivered by wordpress.org.