Freshet Studio Pages Plus

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.