WP Links
A repository to test out how WordPress's legacy Links functionality works.
by George Stephanis · github.com/georgestephanis/wp-links · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/georgestephanis/wp-links/archive/refs/heads/main.zipReadme
WP Links
This repository has two related things in it:
- A standalone plugin (
wp-links.php+includes/) that reproduces WordPress core's legacy Links (Link Manager / Bookmarks) feature verbatim — same function names, same class names, same behavior — written in support of Trac #56362, which plans to eventually remove this functionality from core. - A WP Playground blueprint (
blueprint.json) that spins up a disposable WordPress instance with Links enabled and some sample categories/links already in place, for quickly poking at the feature (with or without the plugin).
There's also a draft Trac ticket (trac-ticket-link-manager-default-category.md) documenting some real bugs found in core's handling of default_link_category while building this, not yet submitted.
Why this exists
Links/Bookmarks has been hidden from wp-admin by default since WordPress 3.5 (2012) — see #21307 — but all of the underlying code has stayed in core the whole time, re-enabled by a single add_filter( 'pre_option_link_manager_enabled', '__return_true' ) or by installing the official (very thin) Link Manager plugin. #56362 is the follow-up ticket proposing to finally remove the underlying API from core entirely, and its own rollout plan says the first step is:
Move all related functionality into a new release of the Link Manager plugin defensively with the use of
(function|class)_exists()checks.
This plugin is a prototype of exactly that. It's written so it can be installed today, on a completely normal WordPress install, and do nothing at the code level (every declaration is guarded and core's own versions keep running unmodified) — and then, the moment a future WordPress release actually deletes this code from core, this plugin's own definitions silently take over with no user-visible change. It's meant to be a concrete, working answer to "what would the replacement plugin from step 1 actually need to contain?"
How the polyfill actually works
The naive version of this ("wrap everything in function_exists()") doesn't work, because function_exists()/class_exists() only protect you if your check runs after core's own declaration of the same thing has already happened in that request — and PHP's require_once dedups by file path, not by the symbols a file declares. Two different files (core's and this plugin's) each unconditionally declaring function get_bookmark() will always fatal with "Cannot redeclare," full stop, regardless of guards, if both actually execute.
So getting this right meant figuring out, file by file, when in the request lifecycle core's own equivalent gets loaded, confirmed against a local core checkout rather than assumed:
| What | Core loads it… | So this plugin… |
|---|---|---|
wp-includes/bookmark.php, bookmark-template.php |
Unconditionally in wp-settings.php, before plugins load |
Declares eagerly at top-level — core's guard check always wins first when core still has it |
wp-includes/widgets/class-wp-widget-links.php |
Unconditionally via wp_maybe_load_widgets(), hooked to plugins_loaded priority 0 — after plugin files have already run their top-level code |
Defers the require + class_exists() check to inside its own widgets_init callback, safely after that priority-0 hook |
wp-admin/includes/bookmark.php, wp-admin/includes/template.php |
Unconditionally from wp-admin/includes/admin.php, only reached during an actual admin request — well after any plugin's top-level code |
Defers to an admin_init-hooked loader (this also just matches core's own behavior: these functions were never available outside of wp-admin anyway) |
link_category taxonomy, manage_links capability |
create_initial_taxonomies() runs before plugins load; the manage_links case lives inside map_meta_cap(), which will basically never be removed |
taxonomy_exists() check before registering; a map_meta_cap filter that's a no-op unless it detects the unmapped case ($caps === array('manage_links'), meaning core's own case didn't run) |
wp-admin/link-manager.php, link.php, link-add.php, edit-link-form.php |
Literal top-level files in wp-admin/ — a plugin can't recreate a file at that path |
Registers its own admin.php?page=... pages via admin_menu, gated on file_exists( ABSPATH . 'wp-admin/link-manager.php' ) — dormant entirely while core's files exist |
wp-links-opml.php (root-level export) |
Literal file at ABSPATH, executed directly by the webserver, not routed through WordPress |
A rewrite rule + template_redirect handler reproducing the same URL and output, gated the same way |
This is why every URL this plugin ever generates goes through one helper, wp_links_admin_url( $core_file, $own_page_slug, $args ): it points at the literal core file for as long as that file exists, and only falls back to this plugin's own registered page once it doesn't — so nothing about the generated markup changes for as long as core still has everything.
Both of these classes of bug (the two "declares too eagerly" ones, and the URL-fallback approach) were found and fixed by actually testing against a real core checkout, not just by reasoning about the code — see "How this was tested" below.
What's reproduced
Same global function/class names as core, each individually guarded:
- Bookmark API (
includes/functions-bookmark.php,includes/functions-bookmark-template.php):get_bookmark(),get_bookmark_field(),get_bookmarks(),sanitize_bookmark(),sanitize_bookmark_field(),clean_bookmark_cache(),_walk_bookmarks(),wp_list_bookmarks(), plusget_edit_bookmark_link()/edit_bookmark_link()(which live in the otherwise-genericwp-includes/link-template.phpin core, but are part of this API surface). - Admin bookmark API (
includes/admin-functions-bookmark.php):add_link(),edit_link(),get_default_link_to_edit(),wp_delete_link(),wp_get_link_cats(),get_link_to_edit(),wp_insert_link(),wp_set_link_cats(),wp_update_link(),wp_link_manager_disabled_message(). - Admin UI (
includes/admin-functions-template.php,includes/class-wp-links-list-table.php):wp_link_category_checklist(), the fivelink_*_meta_box()functions,xfn_check(),WP_Links_List_Table. - Taxonomy, capability, widget (
includes/taxonomy-and-capabilities.php,includes/class-wp-widget-links.php): thelink_categorytaxonomy, themanage_linkscapability mapping,WP_Widget_Links. - Admin pages (
includes/admin-pages.php): the Links / Add New / Link Categories menu and the equivalent oflink-manager.php,link.php,link-add.php,edit-link-form.php. - OPML (
includes/opml.php): export (as a virtual endpoint) and thestartElement()/endElement()parser functions used by the separate "Blogroll Importer" plugin. - A real core bug fix (
includes/split-terms.php):_wp_check_split_default_terms()only ever re-pointsdefault_category/default_email_categorywhen a legacy shared term's category half gets split off, neverdefault_link_categorywhen the link_category half does. See the Trac ticket draft for the full writeup.
One deliberate deviation from "verbatim"
wp_insert_link(), wp_set_link_cats(), and wp_link_category_checklist() don't reproduce core's current default_link_category handling — they use the fixed version from this project's own Trac ticket instead (a link is allowed to have no category at all; the configured default, if valid, is only ever used as a pre-checked suggestion in the UI, never synthesized or auto-created). Since this is our own code, it doesn't need core's sign-off to just do the right thing. See trac-ticket-link-manager-default-category.md for why.
Known gaps
- OPML import's
startElement()/endElement()are still declared eagerly (documented inincludes/opml.php). Unlike the admin-only functions, there's no reliable hook to defer them to, since the hazard is a third-party importer plugin's own unguardedrequireofwp-admin/link-parse-opml.php, which could happen at any point in a request. Narrow, but real — flagged rather than solved. - The "add category" AJAX quick-add on the Categories meta box isn't reproduced (no
wp_ajax_add-link-categoryhandler). - Whether a future core removal would actually delete the
wp-admin/*.phppage files themselves (vs. just the underlying API functions) is genuinely unclear — see #1 for more on this and other open questions.
How this was tested
Both directions were verified against an actual local core checkout (~/code/WordPress), not just reasoned about:
- No collision on a normal, current install. Activated the plugin on a stock WordPress instance and confirmed no "Cannot redeclare" fatals — this is what caught the eager-loading bugs described above in the first place.
- Full standalone operation when core doesn't have it. Built a scratch copy of core with every Links-related file, function, and registration actually stripped out (
wp-includes/bookmark.php/bookmark-template.phptruncated,wp-admin/includes/bookmark.php/class-wp-links-list-table.phptruncated, the widget class file truncated and its now-dangling call site inwp_widgets_init()removed, the five admin page files deleted, thelink_categorytaxonomy registration andmanage_linkscapability case removed fromtaxonomy.php/capabilities.php, the Links menu entries removed frommenu.php, and the checklist/meta-box functions removed fromtemplate.php/meta-boxes.php) — simulating a completed #56362 — and confirmed, with only this plugin active: a link category can be created, a link can be inserted/listed/rendered/deleted through the reproduced admin UI (list table, Add New, Edit Link screens all render with no fatals),wp_list_bookmarks()renders correctly on the front end, and the Links widget registers.
Both used @wp-playground/cli, mounting individual files or a scratch-copied core tree rather than a real checkout, to avoid Playground's installer writing files back into a real git working tree (learned that one the hard way — --mount/--mount-before-install on a whole directory can pull "missing" vendor files into whatever's mounted there).
The Playground blueprint
blueprint.json spins up a disposable WordPress instance with Links enabled and 6 sample link categories / 36 sample links already created, useful for quickly poking at the feature in a browser without any local setup. It currently seeds that sample data with a runPHP step rather than installing this plugin — see #1 for the plan to change that once the plugin is pushed somewhere Playground can fetch it from directly.
npx @wp-playground/cli server --blueprint=blueprint.json
Or click the badge above to open it directly in your browser.
- WP Playground CLI: https://github.com/WordPress/wordpress-playground/tree/trunk/packages/playground/cli
Files
wp-links.php+includes/— the plugin.blueprint.json— the Playground demo blueprint.trac-ticket-link-manager-default-category.md— draft ticket for thedefault_link_categorybugs found along the way.
Development tooling
composer install # WPCS / PHPCS
composer lint # check
composer lint:fix # auto-fix what phpcbf can
npm install
npm run lint:js # @wordpress/scripts, once there's any JS under assets/
npm run lint:css # same, for CSS under assets/
phpcs.xml.dist targets PHP 7.2+ / WP 6.5+ and excludes a handful of security/i18n sniffs specifically for includes/ — they correctly flag patterns that exist verbatim in core's own equivalent files today (see the comments in that file for the specifics). There's no JS or CSS in the plugin yet, so lint:js/lint:css currently have nothing to check — see #2 for the modern-parity gaps (including core's link/xfn admin scripts) that would introduce some.
Notes
-
Links can be enabled programmatically on any site, with or without this plugin, using:
add_filter( 'pre_option_link_manager_enabled', '__return_true' ); -
Activating this plugin does that for you automatically (
update_option( 'link_manager_enabled', true )on activation), matching how the official Link Manager plugin behaves today — installing it is enough on its own.