| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* WP MyLinks Plugin. |
| 5 |
* |
| 6 |
* @link https://walterpinem.me/ |
| 7 |
* @since 1.0.0 |
| 8 |
* @package Wp_Mylinks |
| 9 |
* @copyright Copyright (c) 2020-2026, Walter Pinem, Seni Berpikir |
| 10 |
* |
| 11 |
* @wordpress-plugin |
| 12 |
* Plugin Name: WP MyLinks |
| 13 |
* Plugin URI: https://www.onlinestorekit.com/wp-mylinks/ |
| 14 |
* Description: Easily build your own micro landing page showing all the links you want to share to engage your audience. Use your own brand, link it anywhere. |
| 15 |
* Version: 1.1.1 |
| 16 |
* Author: Walter Pinem |
| 17 |
* Author URI: https://walterpinem.me/ |
| 18 |
* License: GPL-2.0+ |
| 19 |
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt |
| 20 |
* Text Domain: wp-mylinks |
| 21 |
* Domain Path: /languages/ |
| 22 |
* Requires at least: 6.0 |
| 23 |
* Tested up to: 7.1 |
| 24 |
* Requires PHP: 7.4 |
| 25 |
*/ |
| 26 |
|
| 27 |
// If this file is called directly, abort. |
| 28 |
if ( ! defined('WPINC') ) { |
| 29 |
die; |
| 30 |
} |
| 31 |
|
| 32 |
/** |
| 33 |
* Currently plugin version. |
| 34 |
* Start at version 1.0.0 and use SemVer - https://semver.org |
| 35 |
*/ |
| 36 |
define('WP_MYLINKS_NAME', 'WP MyLinks'); |
| 37 |
define('WP_MYLINKS_VERSION', '1.1.1'); |
| 38 |
define('WP_MYLINKS_PREFIX', 'mylinks_'); |
| 39 |
define('WP_MYLINKS_FILE', __FILE__); |
| 40 |
define('WP_MYLINKS_PATH', plugin_dir_path(__FILE__)); |
| 41 |
define('WP_MYLINKS_URL', plugin_dir_url(__FILE__)); |
| 42 |
|
| 43 |
/** |
| 44 |
* Build a meta-key prefix for the mylink CPT. |
| 45 |
* |
| 46 |
* @param string $key |
| 47 |
* @return string |
| 48 |
*/ |
| 49 |
function mylinks_prefix( $key ) { |
| 50 |
return 'mylinks_' . $key; |
| 51 |
} |
| 52 |
|
| 53 |
/** |
| 54 |
* Build a meta-key prefix for the mylinks-collection CPT. |
| 55 |
* |
| 56 |
* @param string $key |
| 57 |
* @return string |
| 58 |
*/ |
| 59 |
function mylinks_collection( $key ) { |
| 60 |
return 'mylinks_collection_' . $key; |
| 61 |
} |
| 62 |
|
| 63 |
/** |
| 64 |
* Activation: flush rewrite rules so /<post-name>/ resolves immediately. |
| 65 |
*/ |
| 66 |
function activate_wp_mylinks() { |
| 67 |
require_once WP_MYLINKS_PATH . 'includes/class-wp-mylinks-activator.php'; |
| 68 |
Wp_Mylinks_Activator::activate(); |
| 69 |
} |
| 70 |
register_activation_hook(__FILE__, 'activate_wp_mylinks'); |
| 71 |
|
| 72 |
/** |
| 73 |
* Deactivation: drop the CPT's rewrite rules cleanly. Does NOT delete user data. |
| 74 |
*/ |
| 75 |
function deactivate_wp_mylinks() { |
| 76 |
require_once WP_MYLINKS_PATH . 'includes/class-wp-mylinks-deactivator.php'; |
| 77 |
Wp_Mylinks_Deactivator::deactivate(); |
| 78 |
} |
| 79 |
register_deactivation_hook(__FILE__, 'deactivate_wp_mylinks'); |
| 80 |
|
| 81 |
/** |
| 82 |
* The core plugin classes that are used to define internationalization, |
| 83 |
* admin-specific hooks, and public-facing site hooks. |
| 84 |
*/ |
| 85 |
require WP_MYLINKS_PATH . 'includes/class-wp-mylinks.php'; |
| 86 |
require WP_MYLINKS_PATH . 'includes/fields/load.php'; |
| 87 |
require WP_MYLINKS_PATH . 'admin/partials/wp-mylinks-admin-settings.php'; |
| 88 |
require WP_MYLINKS_PATH . 'includes/class-wp-mylinks-more-plugins.php'; |
| 89 |
require WP_MYLINKS_PATH . 'includes/class-wp-mylinks-tools.php'; |
| 90 |
require WP_MYLINKS_PATH . 'includes/class-wp-mylinks-post-type.php'; |
| 91 |
require WP_MYLINKS_PATH . 'includes/class-wp-mylinks-rewrites.php'; |
| 92 |
require WP_MYLINKS_PATH . 'admin/partials/wp-mylinks-links-collection.php'; |
| 93 |
|
| 94 |
/** |
| 95 |
* Run a deferred rewrite-rules flush after a version bump. |
| 96 |
* |
| 97 |
* If users update from 1.0.7 (or earlier) to 1.0.8+, the old broken slug rules |
| 98 |
* will still be cached. Flush once on the first admin page load post-upgrade. |
| 99 |
* |
| 100 |
* @since 1.0.8 |
| 101 |
*/ |
| 102 |
function wp_mylinks_maybe_flush_on_upgrade() { |
| 103 |
$stored = get_option('wp_mylinks_version'); |
| 104 |
if ( $stored === WP_MYLINKS_VERSION ) { |
| 105 |
return; |
| 106 |
} |
| 107 |
if ( class_exists('Wp_Mylinks_Rewrites') ) { |
| 108 |
Wp_Mylinks_Rewrites::flush(); |
| 109 |
} |
| 110 |
update_option('wp_mylinks_version', WP_MYLINKS_VERSION, false); |
| 111 |
} |
| 112 |
add_action('admin_init', 'wp_mylinks_maybe_flush_on_upgrade'); |
| 113 |
|
| 114 |
/** |
| 115 |
* Load base template for the landing page. |
| 116 |
*/ |
| 117 |
add_filter('single_template', 'wp_mylinks_template'); |
| 118 |
function wp_mylinks_template( $single ) { |
| 119 |
global $post; |
| 120 |
if ( $post && 'mylink' === $post->post_type ) { |
| 121 |
$template = WP_MYLINKS_PATH . 'public/partials/wp-mylinks-base-template.php'; |
| 122 |
if ( file_exists($template) ) { |
| 123 |
return $template; |
| 124 |
} |
| 125 |
} |
| 126 |
return $single; |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Cache-busting version for a public asset: the file's mtime, so a changed |
| 131 |
* stylesheet or script is never served stale from cache under an unchanged |
| 132 |
* plugin version. Falls back to the plugin version if the file is unreadable. |
| 133 |
* |
| 134 |
* @since 1.1.0 |
| 135 |
* |
| 136 |
* @param string $relative Path relative to the plugin root. |
| 137 |
* @return string |
| 138 |
*/ |
| 139 |
function wp_mylinks_asset_ver( $relative ) { |
| 140 |
$mtime = @filemtime(WP_MYLINKS_PATH . $relative); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- version fallback below covers failure. |
| 141 |
return $mtime ? (string) $mtime : WP_MYLINKS_VERSION; |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* Register the main public CSS. |
| 146 |
*/ |
| 147 |
function wp_mylinks_register_style() { |
| 148 |
wp_register_style('mylinks-public-css', WP_MYLINKS_URL . 'public/css/wp-mylinks-public.min.css', array(), wp_mylinks_asset_ver('public/css/wp-mylinks-public.min.css')); |
| 149 |
wp_register_style('mylinks-youtube-css', WP_MYLINKS_URL . 'public/css/wp-mylinks-youtube.min.css', array(), wp_mylinks_asset_ver('public/css/wp-mylinks-youtube.min.css')); |
| 150 |
} |
| 151 |
add_action('init', 'wp_mylinks_register_style'); |
| 152 |
|
| 153 |
/** |
| 154 |
* Register the main public JS. |
| 155 |
*/ |
| 156 |
function wp_mylinks_register_script() { |
| 157 |
wp_register_script('mylinks-public-js', WP_MYLINKS_URL . 'public/js/wp-mylinks-public.js', array(), wp_mylinks_asset_ver('public/js/wp-mylinks-public.js'), true); |
| 158 |
// Translatable accessibility labels for the lazy-loaded YouTube embed. |
| 159 |
wp_localize_script( |
| 160 |
'mylinks-public-js', |
| 161 |
'wpMylinksPublic', |
| 162 |
array( |
| 163 |
'youtubePlayer' => __('YouTube video player', 'wp-mylinks'), |
| 164 |
'playVideo' => __('Play video', 'wp-mylinks'), |
| 165 |
) |
| 166 |
); |
| 167 |
} |
| 168 |
add_action('init', 'wp_mylinks_register_script'); |
| 169 |
|
| 170 |
/** |
| 171 |
* Metabox definitions (native fields framework — replaced bundled CMB2 in 1.1.0). |
| 172 |
*/ |
| 173 |
require_once WP_MYLINKS_PATH . 'includes/class-wp-mylinks-metaboxes.php'; |
| 174 |
|
| 175 |
/** |
| 176 |
* Are we currently on a mylink single view? |
| 177 |
*/ |
| 178 |
function wp_mylinks_is_queried() { |
| 179 |
return ( 'mylink' === get_post_type() ); |
| 180 |
} |
| 181 |
|
| 182 |
function wp_mylinks_collection_is_queried() { |
| 183 |
return ( 'mylinks-collection' === get_post_type() ); |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* Check if Yoast SEO / Premium is active. |
| 188 |
*/ |
| 189 |
function wp_mylinks_isYoastActive() { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionNameInvalid -- Public API since 1.0.0; renaming would break function_exists() integrations. |
| 190 |
if ( ! function_exists('is_plugin_active') ) { |
| 191 |
include_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 192 |
} |
| 193 |
return is_plugin_active('wordpress-seo/wp-seo.php') |
| 194 |
|| is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'); |
| 195 |
} |
| 196 |
|
| 197 |
/** |
| 198 |
* Is the current request from an obvious bot (or otherwise not worth |
| 199 |
* counting)? Shared by the page-visit and link-click trackers. |
| 200 |
* |
| 201 |
* A substring user-agent check, not a full bot database: it catches the |
| 202 |
* crawlers that actually hit bio pages, and stays intentionally lightweight. |
| 203 |
* |
| 204 |
* @since 1.1.0 |
| 205 |
* |
| 206 |
* @return bool |
| 207 |
*/ |
| 208 |
if ( ! function_exists('wp_mylinks_is_bot_request') ) { |
| 209 |
function wp_mylinks_is_bot_request() { |
| 210 |
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? strtolower( (string) wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; |
| 211 |
return '' === $ua || (bool) preg_match('/bot|crawl|spider|slurp|curl|wget|python-|headless|lighthouse|pingdom|monitor|preview|facebookexternalhit/', $ua); |
| 212 |
} |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* Track visited MyLinks page(s). |
| 217 |
* |
| 218 |
* Increments with a single in-place UPDATE so concurrent hits don't lose |
| 219 |
* counts, and skips admin, cron, and obvious bots so the number stays |
| 220 |
* meaningful. Pro's analytics will read this same meta key. |
| 221 |
* |
| 222 |
* @since 1.1.0 Atomic increment + user-agent bot filter (was a lossy |
| 223 |
* read-then-write that also counted crawlers). |
| 224 |
*/ |
| 225 |
if ( ! function_exists('wp_mylinks_track_mylink_page') ) : |
| 226 |
|
| 227 |
function wp_mylinks_track_mylink_page( $post_id ) { |
| 228 |
$post_id = (int) $post_id; |
| 229 |
if ( $post_id <= 0 ) { |
| 230 |
return; |
| 231 |
} |
| 232 |
if ( is_admin() || ( function_exists('wp_doing_cron') && wp_doing_cron() ) ) { |
| 233 |
return; |
| 234 |
} |
| 235 |
if ( wp_mylinks_is_bot_request() ) { |
| 236 |
return; |
| 237 |
} |
| 238 |
|
| 239 |
$count_key = 'wp_mylinks_count_visits'; |
| 240 |
|
| 241 |
// First visit: add_post_meta with $unique = true is race-safe — the |
| 242 |
// loser of a concurrent first insert falls through to the UPDATE. |
| 243 |
if ( '' === (string) get_post_meta($post_id, $count_key, true) ) { |
| 244 |
if ( add_post_meta($post_id, $count_key, 1, true) ) { |
| 245 |
return; |
| 246 |
} |
| 247 |
} |
| 248 |
|
| 249 |
global $wpdb; |
| 250 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic increment; the meta cache is invalidated right after. |
| 251 |
$wpdb->query( |
| 252 |
$wpdb->prepare( |
| 253 |
"UPDATE {$wpdb->postmeta} SET meta_value = meta_value + 1 WHERE post_id = %d AND meta_key = %s", |
| 254 |
$post_id, |
| 255 |
$count_key |
| 256 |
) |
| 257 |
); |
| 258 |
wp_cache_delete($post_id, 'post_meta'); |
| 259 |
} |
| 260 |
endif; |
| 261 |
|
| 262 |
/** |
| 263 |
* The set of currently valid link URLs for a MyLink page, keyed by md5. |
| 264 |
* |
| 265 |
* Uses the same render-time Collection resolution as the template, so the |
| 266 |
* hashes match what visitors actually click. |
| 267 |
* |
| 268 |
* @since 1.1.0 |
| 269 |
* |
| 270 |
* @param int $post_id Post ID. |
| 271 |
* @return array<string,string> md5( url ) => url. |
| 272 |
*/ |
| 273 |
function wp_mylinks_valid_link_urls( $post_id ) { |
| 274 |
$post_id = (int) $post_id; |
| 275 |
$urls = array(); |
| 276 |
$links = get_post_meta( $post_id, mylinks_prefix('links'), true ); |
| 277 |
|
| 278 |
foreach ( (array) $links as $link ) { |
| 279 |
if ( ! is_array( $link ) ) { |
| 280 |
continue; |
| 281 |
} |
| 282 |
$url = isset( $link['url'] ) ? (string) $link['url'] : ''; |
| 283 |
if ( isset( $link['select_url'] ) ) { |
| 284 |
$fresh = wp_mylinks_resolve_selected_url( $link['select_url'] ); |
| 285 |
if ( '' !== $fresh ) { |
| 286 |
$url = $fresh; |
| 287 |
} |
| 288 |
} |
| 289 |
if ( '' !== $url ) { |
| 290 |
// Accept every serialization the click beacon might report for this |
| 291 |
// href: the raw stored value, its esc_url_raw() encoding (what the |
| 292 |
// href actually renders as), and the browser's bare-authority "/" |
| 293 |
// normalization of that. Without the last, a path-less URL such as |
| 294 |
// "https://example.com" never matches the browser's "…/.com/". |
| 295 |
foreach ( array( $url, esc_url_raw( $url ), wp_mylinks_browser_normalize_url( esc_url_raw( $url ) ) ) as $variant ) { |
| 296 |
if ( '' !== (string) $variant ) { |
| 297 |
$urls[ md5( $variant ) ] = $url; |
| 298 |
} |
| 299 |
} |
| 300 |
} |
| 301 |
} |
| 302 |
|
| 303 |
return $urls; |
| 304 |
} |
| 305 |
|
| 306 |
/** |
| 307 |
* Normalize a URL the way a browser serializes an <a>.href property, so the |
| 308 |
* click beacon (which reads anchor.href) matches a stored value that lacks the |
| 309 |
* trailing slash. Browsers insert "/" after a bare authority (before any query |
| 310 |
* or fragment); URLs that already have a path are returned unchanged. |
| 311 |
* |
| 312 |
* @since 1.1.0 |
| 313 |
* |
| 314 |
* @param string $url URL to normalize. |
| 315 |
* @return string |
| 316 |
*/ |
| 317 |
function wp_mylinks_browser_normalize_url( $url ) { |
| 318 |
$parts = wp_parse_url( (string) $url ); |
| 319 |
if ( ! is_array( $parts ) || empty( $parts['host'] ) || ! empty( $parts['path'] ) ) { |
| 320 |
return (string) $url; |
| 321 |
} |
| 322 |
$auth = ( isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '//' ) |
| 323 |
. ( isset( $parts['user'] ) ? $parts['user'] . ( isset( $parts['pass'] ) ? ':' . $parts['pass'] : '' ) . '@' : '' ) |
| 324 |
. $parts['host'] |
| 325 |
. ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); |
| 326 |
return $auth . '/' |
| 327 |
. ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ) |
| 328 |
. ( isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '' ); |
| 329 |
} |
| 330 |
|
| 331 |
/** |
| 332 |
* AJAX endpoint recording one click on a MyLink page link. |
| 333 |
* |
| 334 |
* Data shape (read by Pro's analytics as-is): post meta |
| 335 |
* `wp_mylinks_link_clicks` = array( md5( link URL ) => int count ). Keying by |
| 336 |
* URL hash survives row reordering; editing a link's URL starts a fresh |
| 337 |
* count, which is the honest semantic for "a different link". |
| 338 |
* |
| 339 |
* Deliberately nonce-free: bio pages are routinely full-page-cached, so a |
| 340 |
* rendered nonce would expire in the cache and silently drop every click. |
| 341 |
* Instead the input is strictly validated — the post must be a published |
| 342 |
* mylink and the clicked URL must hash-match one of its stored links — and |
| 343 |
* obvious bots are filtered. Same trust level as the visit counter. |
| 344 |
* |
| 345 |
* @since 1.1.0 |
| 346 |
* @return void |
| 347 |
*/ |
| 348 |
function wp_mylinks_handle_link_click() { |
| 349 |
if ( wp_mylinks_is_bot_request() ) { |
| 350 |
wp_send_json_error( null, 400 ); |
| 351 |
} |
| 352 |
|
| 353 |
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public tracking beacon; see docblock. Input is strictly validated below. |
| 354 |
$post_id = isset( $_POST['post_id'] ) ? absint( wp_unslash( $_POST['post_id'] ) ) : 0; |
| 355 |
$url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; |
| 356 |
// phpcs:enable WordPress.Security.NonceVerification.Missing |
| 357 |
|
| 358 |
if ( $post_id <= 0 || '' === $url ) { |
| 359 |
wp_send_json_error( null, 400 ); |
| 360 |
} |
| 361 |
|
| 362 |
$post = get_post( $post_id ); |
| 363 |
if ( ! $post instanceof WP_Post || 'mylink' !== $post->post_type || 'publish' !== $post->post_status ) { |
| 364 |
wp_send_json_error( null, 404 ); |
| 365 |
} |
| 366 |
|
| 367 |
$hash = md5( $url ); |
| 368 |
$valid = wp_mylinks_valid_link_urls( $post_id ); |
| 369 |
if ( ! isset( $valid[ $hash ] ) ) { |
| 370 |
wp_send_json_error( null, 400 ); |
| 371 |
} |
| 372 |
|
| 373 |
// Read-modify-write on the array meta: a lost race costs at most one count |
| 374 |
// in a per-link tally, so no lock is warranted. The page total stays exact. |
| 375 |
$clicks = get_post_meta( $post_id, 'wp_mylinks_link_clicks', true ); |
| 376 |
$clicks = is_array( $clicks ) ? $clicks : array(); |
| 377 |
|
| 378 |
$clicks[ $hash ] = isset( $clicks[ $hash ] ) ? (int) $clicks[ $hash ] + 1 : 1; |
| 379 |
update_post_meta( $post_id, 'wp_mylinks_link_clicks', $clicks ); |
| 380 |
|
| 381 |
/** |
| 382 |
* Fires after a link click is recorded. |
| 383 |
* |
| 384 |
* Pro's analytics listens here to store richer, per-event data. |
| 385 |
* |
| 386 |
* @since 1.1.0 |
| 387 |
* |
| 388 |
* @param int $post_id Post ID of the MyLink page. |
| 389 |
* @param string $url The clicked link URL. |
| 390 |
* @param string $hash md5 of the URL — the key in wp_mylinks_link_clicks. |
| 391 |
* @param int $count The new count for this link. |
| 392 |
*/ |
| 393 |
do_action( 'wp_mylinks_track_link_click', $post_id, $url, $hash, $clicks[ $hash ] ); |
| 394 |
|
| 395 |
wp_send_json_success(); |
| 396 |
} |
| 397 |
add_action( 'wp_ajax_wp_mylinks_link_click', 'wp_mylinks_handle_link_click' ); |
| 398 |
add_action( 'wp_ajax_nopriv_wp_mylinks_link_click', 'wp_mylinks_handle_link_click' ); |
| 399 |
|
| 400 |
/** |
| 401 |
* Total recorded link clicks for a MyLink page. |
| 402 |
* |
| 403 |
* @since 1.1.0 |
| 404 |
* |
| 405 |
* @param int $post_id Post ID. |
| 406 |
* @return int |
| 407 |
*/ |
| 408 |
function wp_mylinks_get_link_clicks_total( $post_id ) { |
| 409 |
$clicks = get_post_meta( (int) $post_id, 'wp_mylinks_link_clicks', true ); |
| 410 |
return is_array( $clicks ) ? (int) array_sum( array_map( 'intval', $clicks ) ) : 0; |
| 411 |
} |
| 412 |
|
| 413 |
/** |
| 414 |
* Build the accent-color override CSS for a MyLink page (F8, 1.1.0). |
| 415 |
* |
| 416 |
* Four values, resolved per-page meta → global option → unset. Only set |
| 417 |
* values emit declarations, so pages without accents keep their theme |
| 418 |
* untouched. The values are also exposed as custom properties on the body |
| 419 |
* for developers who want to build on them. |
| 420 |
* |
| 421 |
* @since 1.1.0 |
| 422 |
* |
| 423 |
* @param int $post_id Post ID. |
| 424 |
* @return string CSS, or '' when no accent is set. |
| 425 |
*/ |
| 426 |
function wp_mylinks_accent_css( $post_id ) { |
| 427 |
$accents = array( |
| 428 |
'bg' => wp_mylinks_resolve_meta_or_option( $post_id, mylinks_prefix('accent-bg'), 'wp_mylinks_accent_bg', '' ), |
| 429 |
'button-bg' => wp_mylinks_resolve_meta_or_option( $post_id, mylinks_prefix('accent-button-bg'), 'wp_mylinks_accent_button_bg', '' ), |
| 430 |
'button-text' => wp_mylinks_resolve_meta_or_option( $post_id, mylinks_prefix('accent-button-text'), 'wp_mylinks_accent_button_text', '' ), |
| 431 |
'text' => wp_mylinks_resolve_meta_or_option( $post_id, mylinks_prefix('accent-text'), 'wp_mylinks_accent_text', '' ), |
| 432 |
); |
| 433 |
|
| 434 |
$accents = array_filter( array_map( 'sanitize_hex_color', array_map( 'strval', $accents ) ) ); |
| 435 |
if ( empty( $accents ) ) { |
| 436 |
return ''; |
| 437 |
} |
| 438 |
|
| 439 |
$vars = ''; |
| 440 |
foreach ( $accents as $key => $color ) { |
| 441 |
$vars .= '--mylinks-accent-' . $key . ':' . $color . ';'; |
| 442 |
} |
| 443 |
|
| 444 |
$css = '.mylinks-body{' . $vars . '}'; |
| 445 |
if ( isset( $accents['bg'] ) ) { |
| 446 |
$css .= '.mylinks-body{background:var(--mylinks-accent-bg) !important}'; |
| 447 |
} |
| 448 |
if ( isset( $accents['button-bg'] ) ) { |
| 449 |
$css .= '.mylinks-body .button,.mylinks-body .mylink-card-title-wrapper{background:var(--mylinks-accent-button-bg) !important;border-color:var(--mylinks-accent-button-bg) !important}'; |
| 450 |
} |
| 451 |
if ( isset( $accents['button-text'] ) ) { |
| 452 |
$css .= '.mylinks-body .button,.mylinks-body .button .link-text,.mylinks-body .mylink-card-title{color:var(--mylinks-accent-button-text) !important}'; |
| 453 |
} |
| 454 |
if ( isset( $accents['text'] ) ) { |
| 455 |
$css .= '.mylinks-body,.mylinks-body .name h1,.mylinks-body .description,.mylinks-body .description p{color:var(--mylinks-accent-text) !important}'; |
| 456 |
} |
| 457 |
|
| 458 |
return $css; |
| 459 |
} |
| 460 |
|
| 461 |
/** |
| 462 |
* Add Settings link on the plugin row. |
| 463 |
*/ |
| 464 |
function wp_mylinks_settings_link( $links_array, $plugin_file_name ) { |
| 465 |
if ( false !== strpos($plugin_file_name, basename(__FILE__)) ) { |
| 466 |
array_unshift( |
| 467 |
$links_array, |
| 468 |
'<a href="' . esc_url(admin_url('edit.php?post_type=mylink&page=welcome')) . '">' . esc_html__('Settings', 'wp-mylinks') . '</a>' |
| 469 |
); |
| 470 |
} |
| 471 |
return $links_array; |
| 472 |
} |
| 473 |
add_filter('plugin_action_links', 'wp_mylinks_settings_link', 10, 2); |
| 474 |
|
| 475 |
/** |
| 476 |
* Theme list for the per-page theme selector and the global setting. |
| 477 |
*/ |
| 478 |
function wp_mylinks_theme_callback() { |
| 479 |
return array( |
| 480 |
'none' => __('None', 'wp-mylinks'), |
| 481 |
'default' => __('Default', 'wp-mylinks'), |
| 482 |
'merbabu' => __('Merbabu', 'wp-mylinks'), |
| 483 |
'cikuray' => __('Cikuray', 'wp-mylinks'), |
| 484 |
'ciremai' => __('Ciremai', 'wp-mylinks'), |
| 485 |
'slamet' => __('Slamet', 'wp-mylinks'), |
| 486 |
'papandayan' => __('Papandayan', 'wp-mylinks'), |
| 487 |
'sindoro' => __('Sindoro', 'wp-mylinks'), |
| 488 |
'krakatau' => __('Krakatau', 'wp-mylinks'), |
| 489 |
'bromo' => __('Bromo', 'wp-mylinks'), |
| 490 |
'prau' => __('Prau', 'wp-mylinks'), |
| 491 |
'polos' => __('Polos', 'wp-mylinks'), |
| 492 |
'datar' => __('Datar', 'wp-mylinks'), |
| 493 |
'pastel' => __('Pastel', 'wp-mylinks'), |
| 494 |
'kopi-hitam' => __('Kopi Hitam', 'wp-mylinks'), |
| 495 |
'kopi-susu' => __('Kopi Susu', 'wp-mylinks'), |
| 496 |
'klepon' => __('Klepon Viral', 'wp-mylinks'), |
| 497 |
); |
| 498 |
} |
| 499 |
|
| 500 |
/** |
| 501 |
* Sanitize a raw code field (custom scripts / CSS) by the author's capability. |
| 502 |
* |
| 503 |
* These fields legitimately hold `<script>`, `<style>`, and `<iframe>` — but |
| 504 |
* only for users WordPress trusts with raw markup. `current_user_can( |
| 505 |
* 'unfiltered_html' )` is the correct gate: on multisite, core withholds that |
| 506 |
* capability from Editors AND site Administrators (only super admins keep it), |
| 507 |
* so anyone without it gets the value filtered through `wp_kses_post()`, which |
| 508 |
* strips `<script>`/`<iframe>`. This mirrors how core protects post content and |
| 509 |
* the Custom HTML widget, and closes a stored-XSS bypass where a multisite |
| 510 |
* Editor could persist arbitrary JavaScript onto a public MyLink page. |
| 511 |
* |
| 512 |
* Single-site Administrators/Editors already hold `unfiltered_html`, so their |
| 513 |
* behavior is unchanged: the value is returned verbatim (still slashed, per the |
| 514 |
* meta storage contract) after only control-character cleanup. |
| 515 |
* |
| 516 |
* @since 1.1.0 |
| 517 |
* |
| 518 |
* @param string $value Raw field value. |
| 519 |
* @return string |
| 520 |
*/ |
| 521 |
function wp_mylinks_sanitize_raw_code( $value ) { |
| 522 |
if ( ! is_string($value) ) { |
| 523 |
return ''; |
| 524 |
} |
| 525 |
// Strip NULL and other low control chars; keep \n, \r, \t. No /u modifier: |
| 526 |
// the class is single-byte ASCII controls, so byte-wise matching is correct |
| 527 |
// and never touches multibyte UTF-8 (whose bytes are all >= 0x80). With /u, |
| 528 |
// a single non-UTF-8 byte (e.g. a Windows-1252 smart quote pasted from Word) |
| 529 |
// makes preg_replace return null, which would silently wipe the whole field. |
| 530 |
$stripped = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value); |
| 531 |
if ( null !== $stripped ) { |
| 532 |
$value = $stripped; |
| 533 |
} |
| 534 |
// Normalize line endings to LF. |
| 535 |
$value = str_replace(array( "\r\n", "\r" ), "\n", $value); |
| 536 |
|
| 537 |
if ( current_user_can('unfiltered_html') ) { |
| 538 |
return $value; |
| 539 |
} |
| 540 |
return wp_kses_post($value); |
| 541 |
} |
| 542 |
|
| 543 |
/** |
| 544 |
* Per-page custom-script field sanitizer (native fields save path). |
| 545 |
* |
| 546 |
* Delegates to the shared capability gate. Named for the field framework's |
| 547 |
* `sanitization_cb`; the trailing CMB2-era arguments are unused. |
| 548 |
* |
| 549 |
* @since 1.0.2 |
| 550 |
* |
| 551 |
* @param string $original_value Raw field value. |
| 552 |
* @return string |
| 553 |
*/ |
| 554 |
function wp_mylinks_sanitization_func( $original_value ) { |
| 555 |
return wp_mylinks_sanitize_raw_code($original_value); |
| 556 |
} |
| 557 |
|
| 558 |
/** |
| 559 |
* Sanitize a CSS font-family value (custom font support, 1.1.0). |
| 560 |
* |
| 561 |
* Keeps everything a valid `font-family` needs (font names, generic families, |
| 562 |
* commas, single/double quotes, spaces, hyphens) and strips anything that |
| 563 |
* could break out of the CSS declaration it lands in: braces, semicolons, |
| 564 |
* angle brackets, parentheses, backslashes, colons. The result is safe to drop |
| 565 |
* verbatim into a scoped `font-family:<value>` rule. Length-capped. |
| 566 |
* |
| 567 |
* @since 1.1.0 |
| 568 |
* |
| 569 |
* @param mixed $value Submitted value. |
| 570 |
* @return string |
| 571 |
*/ |
| 572 |
function wp_mylinks_sanitize_font_family( $value ) { |
| 573 |
if ( ! is_string($value) ) { |
| 574 |
return ''; |
| 575 |
} |
| 576 |
$clean = preg_replace('/[^A-Za-z0-9 ,\'"\-]/', '', $value); |
| 577 |
$clean = trim( (string) $clean ); |
| 578 |
return function_exists('mb_substr') ? mb_substr($clean, 0, 200) : substr($clean, 0, 200); |
| 579 |
} |
| 580 |
|
| 581 |
/** |
| 582 |
* Sanitize a space-separated list of CSS icon classes (bring-your-own icon |
| 583 |
* font support, 1.1.0). |
| 584 |
* |
| 585 |
* Icon fonts use one or more classes (e.g. "fa-brands fa-discord"). Each token |
| 586 |
* passes through sanitize_html_class(), so only safe class characters survive, |
| 587 |
* then they are rejoined with single spaces. Capped at 10 classes. Returns '' |
| 588 |
* when nothing valid remains. |
| 589 |
* |
| 590 |
* @since 1.1.0 |
| 591 |
* |
| 592 |
* @param mixed $value Submitted value. |
| 593 |
* @return string |
| 594 |
*/ |
| 595 |
function wp_mylinks_sanitize_icon_classes( $value ) { |
| 596 |
if ( ! is_string($value) ) { |
| 597 |
return ''; |
| 598 |
} |
| 599 |
$tokens = preg_split('/\s+/', trim($value)); |
| 600 |
$clean = array(); |
| 601 |
foreach ( (array) $tokens as $token ) { |
| 602 |
$token = sanitize_html_class($token); |
| 603 |
if ( '' !== $token ) { |
| 604 |
$clean[] = $token; |
| 605 |
} |
| 606 |
} |
| 607 |
return implode(' ', array_slice($clean, 0, 10)); |
| 608 |
} |
| 609 |
|
| 610 |
/** |
| 611 |
* Allow MyLinks to be used as the front page. |
| 612 |
* |
| 613 |
* Adds the mylink post type to the get_pages list shown on the Reading Settings |
| 614 |
* "Static page → Front page" dropdown. |
| 615 |
* |
| 616 |
* @since 1.0.1 |
| 617 |
*/ |
| 618 |
add_action('admin_head-options-reading.php', 'wp_mylinks_front_page_dropdown'); |
| 619 |
function wp_mylinks_front_page_dropdown() { |
| 620 |
add_filter('get_pages', 'wp_mylinks_enable_front_page'); |
| 621 |
} |
| 622 |
function wp_mylinks_enable_front_page( $pages ) { |
| 623 |
// Note: we intentionally do NOT pass suppress_filters here — the |
| 624 |
// VIP-Minimum coding standard prohibits it, and on a normal admin |
| 625 |
// Reading Settings request the small filter overhead is acceptable. |
| 626 |
$mylinks = get_posts( |
| 627 |
array( |
| 628 |
'post_type' => 'mylink', |
| 629 |
'post_status' => 'publish', |
| 630 |
'posts_per_page' => -1, |
| 631 |
'orderby' => 'title', |
| 632 |
'order' => 'ASC', |
| 633 |
) |
| 634 |
); |
| 635 |
return array_merge( (array) $pages, (array) $mylinks); |
| 636 |
} |
| 637 |
|
| 638 |
/** |
| 639 |
* If a mylink post is set as the front page, ensure WordPress queries it |
| 640 |
* correctly. Replaces the previous duplicate-registered, buggy implementation. |
| 641 |
*/ |
| 642 |
function wp_mylinks_show_front_page( $query ) { |
| 643 |
if ( ! $query->is_main_query() ) { |
| 644 |
return; |
| 645 |
} |
| 646 |
|
| 647 |
$page_id = (int) $query->get('page_id'); |
| 648 |
if ( $page_id <= 0 ) { |
| 649 |
return; |
| 650 |
} |
| 651 |
|
| 652 |
// If WP didn't already set a post_type, mirror the page_id's actual post_type. |
| 653 |
if ( '' === (string) $query->get('post_type') ) { |
| 654 |
$pt = get_post_type($page_id); |
| 655 |
if ( $pt ) { |
| 656 |
$query->set('post_type', $pt); |
| 657 |
} |
| 658 |
} |
| 659 |
} |
| 660 |
add_action('pre_get_posts', 'wp_mylinks_show_front_page'); |
| 661 |
|
| 662 |
/** |
| 663 |
* Use the dedicated MyLinks template when a mylink post is the front page. |
| 664 |
*/ |
| 665 |
add_filter('template_include', 'wp_mylinks_front_page_template', 1); |
| 666 |
function wp_mylinks_front_page_template( $template_path ) { |
| 667 |
if ( is_front_page() && 'mylink' === get_post_type() ) { |
| 668 |
$single_template = WP_MYLINKS_PATH . 'public/partials/wp-mylinks-base-template.php'; |
| 669 |
if ( file_exists($single_template) ) { |
| 670 |
return $single_template; |
| 671 |
} |
| 672 |
} |
| 673 |
return $template_path; |
| 674 |
} |
| 675 |
|
| 676 |
/** |
| 677 |
* Public post types the user may include as Link URL sources, keyed by slug |
| 678 |
* with a display label. Auto-populates as plugins/themes register new public |
| 679 |
* CPTs. The plugin's own types are excluded: attachments are not link targets, |
| 680 |
* and Collections are always included by wp_mylinks_get_link_post_types(). |
| 681 |
* |
| 682 |
* @since 1.1.0 |
| 683 |
* |
| 684 |
* @return array<string,string> slug => label. |
| 685 |
*/ |
| 686 |
function wp_mylinks_selectable_link_post_types() { |
| 687 |
$exclude = array( 'attachment', 'mylink', 'mylinks-collection' ); |
| 688 |
$out = array(); |
| 689 |
foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $slug => $obj ) { |
| 690 |
if ( in_array( $slug, $exclude, true ) ) { |
| 691 |
continue; |
| 692 |
} |
| 693 |
$out[ $slug ] = isset( $obj->labels->name ) && $obj->labels->name ? $obj->labels->name : $obj->label; |
| 694 |
} |
| 695 |
return $out; |
| 696 |
} |
| 697 |
|
| 698 |
/** |
| 699 |
* Default Link URL post types when the user has never saved a choice: Posts and |
| 700 |
* Pages, plus Products only when WooCommerce is active. Computed on the fly (not |
| 701 |
* stored at install) so activating WooCommerce later adds Products without a |
| 702 |
* re-save. |
| 703 |
* |
| 704 |
* @since 1.1.0 |
| 705 |
* |
| 706 |
* @return string[] Post type slugs. |
| 707 |
*/ |
| 708 |
function wp_mylinks_default_link_post_types() { |
| 709 |
$defaults = array( 'post', 'page' ); |
| 710 |
if ( class_exists( 'WooCommerce' ) && post_type_exists( 'product' ) ) { |
| 711 |
$defaults[] = 'product'; |
| 712 |
} |
| 713 |
return $defaults; |
| 714 |
} |
| 715 |
|
| 716 |
/** |
| 717 |
* The effective Link URL post types: the saved Tools choice when present, |
| 718 |
* otherwise the dynamic defaults. Stale slugs (a CPT that was deregistered) are |
| 719 |
* dropped. Collections are always a valid link source regardless of this list, |
| 720 |
* so pass $include_collection to append 'mylinks-collection'. |
| 721 |
* |
| 722 |
* @since 1.1.0 |
| 723 |
* |
| 724 |
* @param bool $include_collection Append the mylinks-collection post type. |
| 725 |
* @return string[] Post type slugs. |
| 726 |
*/ |
| 727 |
function wp_mylinks_get_link_post_types( $include_collection = false ) { |
| 728 |
$stored = get_option( 'wp_mylinks_link_post_types', null ); |
| 729 |
if ( ! is_array( $stored ) ) { |
| 730 |
$types = wp_mylinks_default_link_post_types(); |
| 731 |
} else { |
| 732 |
$valid = wp_mylinks_selectable_link_post_types(); |
| 733 |
$types = array(); |
| 734 |
foreach ( $stored as $slug ) { |
| 735 |
if ( isset( $valid[ $slug ] ) ) { |
| 736 |
$types[] = $slug; |
| 737 |
} |
| 738 |
} |
| 739 |
} |
| 740 |
if ( $include_collection ) { |
| 741 |
$types[] = 'mylinks-collection'; |
| 742 |
} |
| 743 |
return $types; |
| 744 |
} |
| 745 |
|
| 746 |
/** |
| 747 |
* Sanitize the Tools-tab Link URL post-types option: keep only registered, |
| 748 |
* selectable public post types; drop unknowns and duplicates. An empty result |
| 749 |
* is a valid "Collections only" choice. |
| 750 |
* |
| 751 |
* @since 1.1.0 |
| 752 |
* |
| 753 |
* @param mixed $value Raw submitted value (array of slugs, or null when the |
| 754 |
* user unchecked every box). |
| 755 |
* @return string[] Sanitized slug list. |
| 756 |
*/ |
| 757 |
function wp_mylinks_sanitize_post_types( $value ) { |
| 758 |
$valid = wp_mylinks_selectable_link_post_types(); |
| 759 |
$out = array(); |
| 760 |
foreach ( (array) $value as $slug ) { |
| 761 |
$slug = sanitize_key( $slug ); |
| 762 |
if ( isset( $valid[ $slug ] ) && ! in_array( $slug, $out, true ) ) { |
| 763 |
$out[] = $slug; |
| 764 |
} |
| 765 |
} |
| 766 |
return $out; |
| 767 |
} |
| 768 |
|
| 769 |
/** |
| 770 |
* Options callback for the Links box "Link URL" picker (the `pw_select` |
| 771 |
* / Select2 field). |
| 772 |
* |
| 773 |
* LIVE code, not CMB2 legacy: the native fields framework |
| 774 |
* (Wp_Mylinks_Field::options()) invokes this with the field object, whose |
| 775 |
* public `->args` deliberately mirrors CMB2's shape. Returns up to ten |
| 776 |
* pages/posts/collections as `id => "Title - URL"`; the admin JS splits that |
| 777 |
* label on " - " to auto-fill the URL. Renamed from |
| 778 |
* wp_mylinks_get_cmb2_post_options() in 1.1.0 (thin alias kept below). |
| 779 |
* |
| 780 |
* @since 1.0.6 |
| 781 |
* |
| 782 |
* @param Wp_Mylinks_Field $field The field requesting its options. |
| 783 |
* @return array<int,string> Map of post ID => "Title - URL" label. |
| 784 |
*/ |
| 785 |
function wp_mylinks_get_post_options( $field ) { |
| 786 |
$query_args = ( isset($field->args['wp_query_args']) && is_array($field->args['wp_query_args']) ) |
| 787 |
? $field->args['wp_query_args'] |
| 788 |
: array(); |
| 789 |
$args = wp_parse_args( |
| 790 |
$query_args, |
| 791 |
array( |
| 792 |
'post_type' => wp_mylinks_get_link_post_types( true ), |
| 793 |
'post_status' => 'publish', |
| 794 |
'posts_per_page' => 10, |
| 795 |
'orderby' => 'title', |
| 796 |
'order' => 'ASC', |
| 797 |
'fields' => 'ids', |
| 798 |
) |
| 799 |
); |
| 800 |
|
| 801 |
// The editor renders this picker once per existing link row with identical |
| 802 |
// args, so memoize per request: many rows then cost a single query, not one |
| 803 |
// each. AJAX search is the primary path; this is only the preloaded set. |
| 804 |
static $memo = array(); |
| 805 |
$memo_key = md5( wp_json_encode( $args ) ); |
| 806 |
if ( isset( $memo[ $memo_key ] ) ) { |
| 807 |
return $memo[ $memo_key ]; |
| 808 |
} |
| 809 |
|
| 810 |
$posts = new WP_Query($args); |
| 811 |
$post_options = array(); |
| 812 |
|
| 813 |
if ( $posts->have_posts() ) { |
| 814 |
$posts_with_hierarchy = wp_mylinks_build_post_tree($posts->posts); |
| 815 |
foreach ( $posts_with_hierarchy as $post_data ) { |
| 816 |
$post_id = $post_data['ID']; |
| 817 |
$post_title = $post_data['post_title']; |
| 818 |
$post_type = get_post_type($post_id); |
| 819 |
if ( 'mylinks-collection' === $post_type ) { |
| 820 |
$post_url = (string) get_post_meta($post_id, mylinks_collection('link_collection'), true); |
| 821 |
} else { |
| 822 |
$post_url = (string) get_permalink($post_id); |
| 823 |
} |
| 824 |
$post_options[ $post_id ] = $post_title . ' - ' . $post_url; |
| 825 |
} |
| 826 |
} |
| 827 |
|
| 828 |
$memo[ $memo_key ] = $post_options; |
| 829 |
return $post_options; |
| 830 |
} |
| 831 |
|
| 832 |
/** |
| 833 |
* The Link URL picker label for one post: "Title - URL". Collections resolve |
| 834 |
* to their stored destination URL rather than a permalink, matching |
| 835 |
* wp_mylinks_get_post_options(). |
| 836 |
* |
| 837 |
* @since 1.1.0 |
| 838 |
* |
| 839 |
* @param int $post_id Post ID. |
| 840 |
* @return string |
| 841 |
*/ |
| 842 |
function wp_mylinks_link_option_label( $post_id ) { |
| 843 |
$post_id = (int) $post_id; |
| 844 |
if ( 'mylinks-collection' === get_post_type( $post_id ) ) { |
| 845 |
$url = (string) get_post_meta( $post_id, mylinks_collection( 'link_collection' ), true ); |
| 846 |
} else { |
| 847 |
$url = (string) get_permalink( $post_id ); |
| 848 |
} |
| 849 |
return get_the_title( $post_id ) . ' - ' . $url; |
| 850 |
} |
| 851 |
|
| 852 |
add_action( 'wp_ajax_wp_mylinks_link_search', 'wp_mylinks_ajax_link_search' ); |
| 853 |
/** |
| 854 |
* AJAX search for the Links box "Link URL" picker. |
| 855 |
* |
| 856 |
* The picker is a Select2 that searches server-side, so the post types chosen |
| 857 |
* on the Tools tab (Collections always included) are reachable at any catalog |
| 858 |
* size instead of being capped at a preloaded page. Requires the fields AJAX |
| 859 |
* nonce and an editing-capable user; returns Select2's {results:[{id,text}]}. |
| 860 |
* |
| 861 |
* @since 1.1.0 |
| 862 |
* |
| 863 |
* @return void |
| 864 |
*/ |
| 865 |
function wp_mylinks_ajax_link_search() { |
| 866 |
check_ajax_referer( 'wp_mylinks_fields_ajax', 'nonce' ); |
| 867 |
|
| 868 |
// The mylink post type registers page-level capabilities, so gate on the |
| 869 |
// same primitive that lets a user reach the editor this picker lives in. |
| 870 |
if ( ! current_user_can( 'edit_pages' ) ) { |
| 871 |
wp_send_json_error( array( 'message' => __( 'Not allowed.', 'wp-mylinks' ) ), 403 ); |
| 872 |
} |
| 873 |
|
| 874 |
$term = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : ''; |
| 875 |
$query = new WP_Query( |
| 876 |
array( |
| 877 |
'post_type' => wp_mylinks_get_link_post_types( true ), |
| 878 |
'post_status' => 'publish', |
| 879 |
's' => $term, |
| 880 |
'posts_per_page' => 30, |
| 881 |
'orderby' => '' !== $term ? 'relevance' : 'title', |
| 882 |
'order' => 'ASC', |
| 883 |
'fields' => 'ids', |
| 884 |
'no_found_rows' => true, |
| 885 |
'ignore_sticky_posts' => true, |
| 886 |
) |
| 887 |
); |
| 888 |
|
| 889 |
$results = array(); |
| 890 |
foreach ( $query->posts as $post_id ) { |
| 891 |
$results[] = array( |
| 892 |
'id' => (string) $post_id, |
| 893 |
'text' => wp_mylinks_link_option_label( $post_id ), |
| 894 |
); |
| 895 |
} |
| 896 |
|
| 897 |
wp_send_json_success( array( 'results' => $results ) ); |
| 898 |
} |
| 899 |
|
| 900 |
/** |
| 901 |
* Backward-compatible alias for the pre-1.1.0 callback name. |
| 902 |
* |
| 903 |
* Kept because the name may be referenced as a string in a third-party or Pro |
| 904 |
* box definition passed through the `wp_mylinks_meta_boxes` filter. |
| 905 |
* |
| 906 |
* @since 1.0.6 |
| 907 |
* @deprecated 1.1.0 Use wp_mylinks_get_post_options(). |
| 908 |
* |
| 909 |
* @param Wp_Mylinks_Field $field The field requesting its options. |
| 910 |
* @return array<int,string> |
| 911 |
*/ |
| 912 |
function wp_mylinks_get_cmb2_post_options( $field ) { |
| 913 |
return wp_mylinks_get_post_options($field); |
| 914 |
} |
| 915 |
|
| 916 |
/** |
| 917 |
* Flatten a set of post IDs into a title-indented hierarchy list. |
| 918 |
* |
| 919 |
* Helper for wp_mylinks_get_post_options(): orders children under parents and |
| 920 |
* prefixes each nested title with em-dashes so the Select2 dropdown reads as a |
| 921 |
* tree. Recurses by parent ID. |
| 922 |
* |
| 923 |
* @since 1.0.6 |
| 924 |
* |
| 925 |
* @param int[] $post_ids Post IDs to arrange. |
| 926 |
* @param int $parent_id Parent to collect children for (0 = top level). |
| 927 |
* @param int $level Current depth, for indentation. |
| 928 |
* @return array<int,array{ID:int,post_title:string}> |
| 929 |
*/ |
| 930 |
function wp_mylinks_build_post_tree( $post_ids, $parent_id = 0, $level = 0 ) { |
| 931 |
$branch = array(); |
| 932 |
foreach ( (array) $post_ids as $post_id ) { |
| 933 |
$post_parent = wp_get_post_parent_id($post_id); |
| 934 |
if ( (int) $parent_id === (int) $post_parent ) { |
| 935 |
$post_title = get_the_title($post_id); |
| 936 |
$post_title = str_repeat('— ', $level) . $post_title; |
| 937 |
$branch[] = array( |
| 938 |
'ID' => $post_id, |
| 939 |
'post_title' => $post_title, |
| 940 |
); |
| 941 |
$children = wp_mylinks_build_post_tree($post_ids, $post_id, $level + 1); |
| 942 |
if ( ! empty($children) ) { |
| 943 |
$branch = array_merge($branch, $children); |
| 944 |
} |
| 945 |
} |
| 946 |
} |
| 947 |
return $branch; |
| 948 |
} |
| 949 |
|
| 950 |
/* |
| 951 |
* Removed in 1.1.0: wp_mylinks_custom_pw_select_render_row() and its |
| 952 |
* cmb2_render_row_cb filter. The script it injected read data-url attributes |
| 953 |
* that only the never-hooked wp_mylinks_modify_select_url_options() would have |
| 954 |
* set, so the handler could never fire. The working URL auto-fill lives in |
| 955 |
* admin/js/wp-mylinks-select.js (label-string splitting). |
| 956 |
*/ |
| 957 |
|
| 958 |
/** |
| 959 |
* Resolve a link row's Collection/post selection to its CURRENT URL. |
| 960 |
* |
| 961 |
* Link rows store both the picked post ID (select_url) and a snapshot of its |
| 962 |
* URL (url) that admin JS copied at pick time. Before 1.1.0 only the snapshot |
| 963 |
* rendered, so editing a Collection link later left stale URLs on every page |
| 964 |
* that used it. Render-time resolution fixes that; the stored string remains |
| 965 |
* the fallback when the picked post is gone. |
| 966 |
* |
| 967 |
* @since 1.1.0 |
| 968 |
* |
| 969 |
* @param int $selected_id Post ID stored in the row's select_url. |
| 970 |
* @return string Fresh URL, or '' when it cannot be resolved. |
| 971 |
*/ |
| 972 |
function wp_mylinks_resolve_selected_url( $selected_id ) { |
| 973 |
$selected_id = (int) $selected_id; |
| 974 |
if ( $selected_id <= 0 ) { |
| 975 |
return ''; |
| 976 |
} |
| 977 |
$selected = get_post( $selected_id ); |
| 978 |
if ( ! $selected instanceof WP_Post || 'publish' !== $selected->post_status ) { |
| 979 |
return ''; |
| 980 |
} |
| 981 |
if ( 'mylinks-collection' === $selected->post_type ) { |
| 982 |
return (string) get_post_meta( $selected->ID, mylinks_collection('link_collection'), true ); |
| 983 |
} |
| 984 |
return (string) get_permalink( $selected ); |
| 985 |
} |
| 986 |
|
| 987 |
/** |
| 988 |
* Read social-platform URL and icon meta for the current post. |
| 989 |
* |
| 990 |
* @since 1.0.6 |
| 991 |
*/ |
| 992 |
function wp_mylinks_get_social_meta( $platform ) { |
| 993 |
$post_id = get_the_ID(); |
| 994 |
$url = $post_id ? get_post_meta($post_id, mylinks_prefix("{$platform}-url"), true) : ''; |
| 995 |
$icon = $post_id ? get_post_meta($post_id, mylinks_prefix("{$platform}-icon"), true) : ''; |
| 996 |
return array( $url, $icon ); |
| 997 |
} |
| 998 |
|
| 999 |
/** |
| 1000 |
* Extract a YouTube video ID from any common URL format. |
| 1001 |
* |
| 1002 |
* Supports: |
| 1003 |
* - https://www.youtube.com/watch?v=VIDEO_ID |
| 1004 |
* - https://youtu.be/VIDEO_ID |
| 1005 |
* - https://www.youtube.com/embed/VIDEO_ID |
| 1006 |
* - https://m.youtube.com/watch?v=VIDEO_ID |
| 1007 |
* - https://www.youtube.com/shorts/VIDEO_ID |
| 1008 |
* |
| 1009 |
* @since 1.0.8 |
| 1010 |
* |
| 1011 |
* @param string $url |
| 1012 |
* @return string The 11-character video ID, or '' if not found. |
| 1013 |
*/ |
| 1014 |
function wp_mylinks_extract_youtube_id( $url ) { |
| 1015 |
if ( ! is_string($url) || '' === $url ) { |
| 1016 |
return ''; |
| 1017 |
} |
| 1018 |
|
| 1019 |
$parsed = wp_parse_url($url); |
| 1020 |
if ( ! is_array($parsed) || empty($parsed['host']) ) { |
| 1021 |
return ''; |
| 1022 |
} |
| 1023 |
|
| 1024 |
$host = strtolower($parsed['host']); |
| 1025 |
$path = isset($parsed['path']) ? trim($parsed['path'], '/') : ''; |
| 1026 |
|
| 1027 |
// youtu.be/<id> |
| 1028 |
if ( 'youtu.be' === $host ) { |
| 1029 |
$candidate = $path; |
| 1030 |
} elseif ( preg_match('#^(?:.*\.)?youtube(?:-nocookie)?\.com$#', $host) ) { |
| 1031 |
// /watch?v=<id> |
| 1032 |
if ( ! empty($parsed['query']) ) { |
| 1033 |
parse_str($parsed['query'], $qs); |
| 1034 |
if ( ! empty($qs['v']) ) { |
| 1035 |
$candidate = (string) $qs['v']; |
| 1036 |
} |
| 1037 |
} |
| 1038 |
// /embed/<id> or /shorts/<id> |
| 1039 |
if ( empty($candidate) && '' !== $path ) { |
| 1040 |
$parts = explode('/', $path); |
| 1041 |
if ( count($parts) >= 2 && in_array($parts[0], array( 'embed', 'shorts', 'v' ), true) ) { |
| 1042 |
$candidate = $parts[1]; |
| 1043 |
} |
| 1044 |
} |
| 1045 |
} |
| 1046 |
|
| 1047 |
if ( empty($candidate) ) { |
| 1048 |
return ''; |
| 1049 |
} |
| 1050 |
|
| 1051 |
// YouTube IDs are 11 characters from the URL-safe base64 alphabet. |
| 1052 |
return preg_match('/^[A-Za-z0-9_-]{11}$/', $candidate) ? $candidate : ''; |
| 1053 |
} |
| 1054 |
|
| 1055 |
/** |
| 1056 |
* Optional: dequeue all non-mylink scripts and styles on a single mylink view. |
| 1057 |
* |
| 1058 |
* @since 1.0.6 |
| 1059 |
*/ |
| 1060 |
function wp_mylinks_dequeue_others() { |
| 1061 |
if ( ! is_singular('mylink') ) { |
| 1062 |
return; |
| 1063 |
} |
| 1064 |
|
| 1065 |
global $wp_scripts, $wp_styles; |
| 1066 |
if ( $wp_scripts && ! empty($wp_scripts->queue) ) { |
| 1067 |
foreach ( $wp_scripts->queue as $handle ) { |
| 1068 |
if ( ! isset($wp_scripts->registered[ $handle ]) ) { |
| 1069 |
continue; |
| 1070 |
} |
| 1071 |
$src = (string) $wp_scripts->registered[ $handle ]->src; |
| 1072 |
if ( false === strpos($src, '/wp-mylinks/') ) { |
| 1073 |
wp_dequeue_script($handle); |
| 1074 |
} |
| 1075 |
} |
| 1076 |
} |
| 1077 |
if ( $wp_styles && ! empty($wp_styles->queue) ) { |
| 1078 |
foreach ( $wp_styles->queue as $handle ) { |
| 1079 |
if ( ! isset($wp_styles->registered[ $handle ]) ) { |
| 1080 |
continue; |
| 1081 |
} |
| 1082 |
$src = (string) $wp_styles->registered[ $handle ]->src; |
| 1083 |
if ( false === strpos($src, '/wp-mylinks/') ) { |
| 1084 |
wp_dequeue_style($handle); |
| 1085 |
} |
| 1086 |
} |
| 1087 |
} |
| 1088 |
} |
| 1089 |
if ( 'yes' === get_option('wp_mylinks_dequeue') ) { |
| 1090 |
add_action('wp_print_scripts', 'wp_mylinks_dequeue_others', 100); |
| 1091 |
add_action('wp_print_styles', 'wp_mylinks_dequeue_others', 100); |
| 1092 |
} |
| 1093 |
|
| 1094 |
/** |
| 1095 |
* Decorate an Online Store Kit / author-owned URL with campaign parameters. |
| 1096 |
* |
| 1097 |
* Only for links to our own properties (onlinestorekit.com, walterpinem.me, |
| 1098 |
* seniberpikir.com), and only ever followed by an admin who clicked one. |
| 1099 |
* Nothing is requested in the background and nothing is sent unless the link |
| 1100 |
* is used, so this is link tagging rather than telemetry — WordPress.org URLs |
| 1101 |
* are deliberately left alone. |
| 1102 |
* |
| 1103 |
* Matches the shared Online Store Kit helper shape (see Indonesian Banks' |
| 1104 |
* `ibfw_url()`); the `wpmylinks_url_params` filter name predates that and is |
| 1105 |
* kept as public API. |
| 1106 |
* |
| 1107 |
* @param string $base_url URL to decorate. |
| 1108 |
* @return string Fully escaped URL. |
| 1109 |
*/ |
| 1110 |
if ( ! function_exists('wpmylinks_url') ) { |
| 1111 |
function wpmylinks_url( $base_url ) { |
| 1112 |
$base_url = esc_url_raw($base_url); |
| 1113 |
|
| 1114 |
if ( function_exists('get_user_locale') ) { |
| 1115 |
$user_language = get_user_locale(); |
| 1116 |
} elseif ( function_exists('determine_locale') ) { |
| 1117 |
$user_language = determine_locale(); |
| 1118 |
} else { |
| 1119 |
$user_language = get_locale(); |
| 1120 |
} |
| 1121 |
|
| 1122 |
$screen = 'frontend'; |
| 1123 |
if ( is_admin() ) { |
| 1124 |
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only screen naming for the campaign parameter. |
| 1125 |
$page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; |
| 1126 |
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : ''; |
| 1127 |
// phpcs:enable WordPress.Security.NonceVerification.Recommended |
| 1128 |
if ( $page ) { |
| 1129 |
$screen = $page . ( $tab ? '-' . $tab : '' ); |
| 1130 |
} |
| 1131 |
} |
| 1132 |
|
| 1133 |
$params = array( |
| 1134 |
'php_version' => phpversion(), |
| 1135 |
'wp_version' => get_bloginfo('version'), |
| 1136 |
'plugin_name' => sanitize_title(WP_MYLINKS_NAME), |
| 1137 |
'plugin_version' => WP_MYLINKS_VERSION, |
| 1138 |
'user_language' => $user_language, |
| 1139 |
'screen' => $screen, |
| 1140 |
); |
| 1141 |
|
| 1142 |
/** |
| 1143 |
* Filter the parameters appended to Online Store Kit links. |
| 1144 |
* |
| 1145 |
* @param array $params Parameters before the URL is built. |
| 1146 |
* @param string $base_url The original URL. |
| 1147 |
*/ |
| 1148 |
$params = apply_filters('wpmylinks_url_params', $params, $base_url); |
| 1149 |
|
| 1150 |
return esc_url(add_query_arg($params, $base_url)); |
| 1151 |
} |
| 1152 |
} |
| 1153 |
|
| 1154 |
/** |
| 1155 |
* Resolve a value with the standard fallback chain: |
| 1156 |
* per-post meta → global option → ultimate fallback. |
| 1157 |
* |
| 1158 |
* Used by the public template and by the SEO/Schema/OG helpers below so |
| 1159 |
* they all share consistent precedence rules. |
| 1160 |
* |
| 1161 |
* @since 1.0.8 |
| 1162 |
* |
| 1163 |
* @param int $post_id Current post ID; 0 to skip the meta lookup. |
| 1164 |
* @param string $post_meta_key Meta key on the post. |
| 1165 |
* @param string $option_key Site option key to fall back to. |
| 1166 |
* @param mixed $fallback Final fallback when both are empty. |
| 1167 |
* @return mixed |
| 1168 |
*/ |
| 1169 |
if ( ! function_exists('wp_mylinks_resolve_meta_or_option') ) { |
| 1170 |
function wp_mylinks_resolve_meta_or_option( $post_id, $post_meta_key, $option_key, $fallback = '' ) { |
| 1171 |
$post_id = (int) $post_id; |
| 1172 |
if ( $post_id > 0 ) { |
| 1173 |
$value = get_post_meta($post_id, $post_meta_key, true); |
| 1174 |
if ( '' !== $value && null !== $value ) { |
| 1175 |
return $value; |
| 1176 |
} |
| 1177 |
} |
| 1178 |
$value = get_option($option_key); |
| 1179 |
if ( '' !== $value && null !== $value && false !== $value ) { |
| 1180 |
return $value; |
| 1181 |
} |
| 1182 |
return $fallback; |
| 1183 |
} |
| 1184 |
} |
| 1185 |
|
| 1186 |
/** |
| 1187 |
* Detect whether Yoast SEO is currently emitting Open Graph tags for this |
| 1188 |
* site. Used so we don't double-emit when Yoast is active. |
| 1189 |
* |
| 1190 |
* Yoast stores its OG toggle in the 'wpseo_social' option under the |
| 1191 |
* 'opengraph' key. If Yoast is active and that toggle is on, we step aside. |
| 1192 |
* |
| 1193 |
* @since 1.0.8 |
| 1194 |
* |
| 1195 |
* @return bool |
| 1196 |
*/ |
| 1197 |
if ( ! function_exists('wp_mylinks_yoast_handles_og') ) { |
| 1198 |
function wp_mylinks_yoast_handles_og() { |
| 1199 |
if ( ! function_exists('wp_mylinks_isYoastActive') || ! wp_mylinks_isYoastActive() ) { |
| 1200 |
return false; |
| 1201 |
} |
| 1202 |
$social = get_option('wpseo_social'); |
| 1203 |
if ( ! is_array($social) ) { |
| 1204 |
return false; |
| 1205 |
} |
| 1206 |
return ! empty($social['opengraph']); |
| 1207 |
} |
| 1208 |
} |
| 1209 |
|
| 1210 |
/** |
| 1211 |
* Detect whether Yoast SEO is currently emitting Schema.org JSON-LD. |
| 1212 |
* |
| 1213 |
* Yoast's schema output is on by default and toggled via the 'wpseo' option |
| 1214 |
* under 'enable_index_now' / 'breadcrumbs-enable' style keys; the practical |
| 1215 |
* way to check is via the wpseo_json_ld_output filter Yoast itself respects. |
| 1216 |
* |
| 1217 |
* Conservative behavior: if Yoast is active, assume it handles JSON-LD |
| 1218 |
* unless it is explicitly disabled in Yoast's settings. |
| 1219 |
* |
| 1220 |
* @since 1.0.8 |
| 1221 |
* |
| 1222 |
* @return bool |
| 1223 |
*/ |
| 1224 |
if ( ! function_exists('wp_mylinks_yoast_handles_schema') ) { |
| 1225 |
function wp_mylinks_yoast_handles_schema() { |
| 1226 |
if ( ! function_exists('wp_mylinks_isYoastActive') || ! wp_mylinks_isYoastActive() ) { |
| 1227 |
return false; |
| 1228 |
} |
| 1229 |
// Yoast 14+ outputs schema by default. Allow site owners to opt out |
| 1230 |
// via the standard wpseo_json_ld_output filter (returns false to |
| 1231 |
// disable). We mirror that contract. |
| 1232 |
$enabled = apply_filters('wpseo_json_ld_output', '', ''); |
| 1233 |
return false !== $enabled; |
| 1234 |
} |
| 1235 |
} |
| 1236 |
|
| 1237 |
/** |
| 1238 |
* Build the array of social URLs (hardcoded 8 + additional repeater) for the |
| 1239 |
* given post. Used to populate Schema.org `sameAs` and to render the social |
| 1240 |
* row in the public template. |
| 1241 |
* |
| 1242 |
* Each item is `array( 'name' => string, 'url' => string, 'icon' => string )`. |
| 1243 |
* Empty rows are filtered out automatically. |
| 1244 |
* |
| 1245 |
* @since 1.0.8 |
| 1246 |
* |
| 1247 |
* @param int $post_id |
| 1248 |
* @return array<int,array{name:string,url:string,icon:string}> |
| 1249 |
*/ |
| 1250 |
if ( ! function_exists('wp_mylinks_collect_socials') ) { |
| 1251 |
function wp_mylinks_collect_socials( $post_id ) { |
| 1252 |
$post_id = (int) $post_id; |
| 1253 |
$collected = array(); |
| 1254 |
|
| 1255 |
if ( $post_id <= 0 ) { |
| 1256 |
return $collected; |
| 1257 |
} |
| 1258 |
|
| 1259 |
// Hardcoded 8 platforms — preserved exactly to avoid breaking existing data. |
| 1260 |
$platforms = array( 'facebook', 'twitter', 'linkedin', 'instagram', 'youtube', 'pinterest', 'tiktok', 'discord' ); |
| 1261 |
foreach ( $platforms as $platform ) { |
| 1262 |
$url = (string) get_post_meta($post_id, mylinks_prefix($platform . '-url'), true); |
| 1263 |
if ( '' === trim($url) ) { |
| 1264 |
continue; |
| 1265 |
} |
| 1266 |
$icon = (string) get_post_meta($post_id, mylinks_prefix($platform . '-icon'), true); |
| 1267 |
$collected[] = array( |
| 1268 |
'name' => ucfirst($platform), |
| 1269 |
'url' => $url, |
| 1270 |
'icon' => $icon, |
| 1271 |
); |
| 1272 |
} |
| 1273 |
|
| 1274 |
// Additional Social Platforms (CMB2 group, new in 1.0.8). |
| 1275 |
$additional = get_post_meta($post_id, mylinks_prefix('additional-socials'), true); |
| 1276 |
if ( is_array($additional) ) { |
| 1277 |
foreach ( $additional as $row ) { |
| 1278 |
if ( ! is_array($row) ) { |
| 1279 |
continue; |
| 1280 |
} |
| 1281 |
$name = isset($row['name']) ? (string) $row['name'] : ''; |
| 1282 |
$url = isset($row['url']) ? (string) $row['url'] : ''; |
| 1283 |
$icon = isset($row['icon']) ? (string) $row['icon'] : ''; |
| 1284 |
if ( '' === trim($url) || '' === trim($name) ) { |
| 1285 |
continue; |
| 1286 |
} |
| 1287 |
$collected[] = array( |
| 1288 |
'name' => $name, |
| 1289 |
'url' => $url, |
| 1290 |
'icon' => $icon, |
| 1291 |
); |
| 1292 |
} |
| 1293 |
} |
| 1294 |
|
| 1295 |
return $collected; |
| 1296 |
} |
| 1297 |
} |
| 1298 |
|
| 1299 |
/** |
| 1300 |
* Resolve the og:image URL for a MyLink with this fallback chain: |
| 1301 |
* 1. Per-page mylinks_og-image |
| 1302 |
* 2. Global mylinks_og_image |
| 1303 |
* 3. Per-page Yoast og:image (if Yoast active and set) |
| 1304 |
* 4. Per-page mylinks_avatar |
| 1305 |
* 5. Global mylinks_upload_favicon |
| 1306 |
* 6. '' (caller should omit the meta tag entirely) |
| 1307 |
* |
| 1308 |
* @since 1.0.8 |
| 1309 |
* |
| 1310 |
* @param int $post_id |
| 1311 |
* @return string |
| 1312 |
*/ |
| 1313 |
if ( ! function_exists('wp_mylinks_resolve_og_image') ) { |
| 1314 |
function wp_mylinks_resolve_og_image( $post_id ) { |
| 1315 |
$post_id = (int) $post_id; |
| 1316 |
|
| 1317 |
if ( $post_id > 0 ) { |
| 1318 |
$per_page = (string) get_post_meta($post_id, mylinks_prefix('og-image'), true); |
| 1319 |
if ( '' !== $per_page ) { |
| 1320 |
return $per_page; |
| 1321 |
} |
| 1322 |
} |
| 1323 |
|
| 1324 |
$global_og = (string) get_option('wp_mylinks_og_image', ''); |
| 1325 |
if ( '' !== $global_og ) { |
| 1326 |
return $global_og; |
| 1327 |
} |
| 1328 |
|
| 1329 |
if ( $post_id > 0 && function_exists('wp_mylinks_isYoastActive') && wp_mylinks_isYoastActive() ) { |
| 1330 |
$yoast_og = (string) get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true); |
| 1331 |
if ( '' !== $yoast_og ) { |
| 1332 |
return $yoast_og; |
| 1333 |
} |
| 1334 |
} |
| 1335 |
|
| 1336 |
if ( $post_id > 0 ) { |
| 1337 |
$avatar = (string) get_post_meta($post_id, mylinks_prefix('avatar'), true); |
| 1338 |
if ( '' !== $avatar ) { |
| 1339 |
return $avatar; |
| 1340 |
} |
| 1341 |
} |
| 1342 |
|
| 1343 |
$favicon = (string) get_option('mylinks_upload_favicon', ''); |
| 1344 |
if ( '' !== $favicon ) { |
| 1345 |
return $favicon; |
| 1346 |
} |
| 1347 |
|
| 1348 |
return ''; |
| 1349 |
} |
| 1350 |
} |
| 1351 |
|
| 1352 |
/** |
| 1353 |
* Build the JSON-LD payload for a MyLink page. |
| 1354 |
* |
| 1355 |
* Returns null when JSON-LD output is disabled or when `wp_json_encode()` |
| 1356 |
* fails. Caller treats null as "do not emit a <script> tag at all". |
| 1357 |
* |
| 1358 |
* Output structure: |
| 1359 |
* - When the user has only the Schema toggle on, this returns a single |
| 1360 |
* Person/Organization JSON object (the original 1.0.8 shape). |
| 1361 |
* - When the ProfilePage wrapper toggle is also on, the output becomes a |
| 1362 |
* `@graph` array containing the entity (Person/Organization), a |
| 1363 |
* ProfilePage that references it via @id, and a BreadcrumbList. |
| 1364 |
* - A BreadcrumbList is added unconditionally when schema is enabled. It's |
| 1365 |
* a 2-step path (site Home → page name) which Google promotes in SERPs. |
| 1366 |
* |
| 1367 |
* @since 1.0.8 |
| 1368 |
* |
| 1369 |
* @param int $post_id |
| 1370 |
* @return string|null Encoded JSON, or null to skip output. |
| 1371 |
*/ |
| 1372 |
if ( ! function_exists('wp_mylinks_build_schema_json') ) { |
| 1373 |
function wp_mylinks_build_schema_json( $post_id ) { |
| 1374 |
$post_id = (int) $post_id; |
| 1375 |
if ( $post_id <= 0 ) { |
| 1376 |
return null; |
| 1377 |
} |
| 1378 |
|
| 1379 |
// Per-page override: 'inherit' / 'Person' / 'Organization' / '' (= inherit). |
| 1380 |
$per_page_type = (string) get_post_meta($post_id, mylinks_prefix('schema-type'), true); |
| 1381 |
if ( 'Person' === $per_page_type || 'Organization' === $per_page_type ) { |
| 1382 |
$schema_type = $per_page_type; |
| 1383 |
} else { |
| 1384 |
$global_type = (string) get_option('wp_mylinks_schema_type', 'Person'); |
| 1385 |
$schema_type = ( 'Organization' === $global_type ) ? 'Organization' : 'Person'; |
| 1386 |
} |
| 1387 |
|
| 1388 |
$post = get_post($post_id); |
| 1389 |
if ( ! $post ) { |
| 1390 |
return null; |
| 1391 |
} |
| 1392 |
|
| 1393 |
$name = (string) get_post_meta($post_id, mylinks_prefix('name'), true); |
| 1394 |
$avatar = (string) get_post_meta($post_id, mylinks_prefix('avatar'), true); |
| 1395 |
$description = (string) get_post_meta($post_id, mylinks_prefix('description'), true); |
| 1396 |
|
| 1397 |
// Description may contain HTML from the WYSIWYG; strip for schema. |
| 1398 |
$description_plain = trim(wp_strip_all_tags($description)); |
| 1399 |
|
| 1400 |
// Resolve the human-readable display name with sensible fallbacks. |
| 1401 |
$display_name = '' !== $name ? $name : (string) get_the_title($post_id); |
| 1402 |
|
| 1403 |
// Canonical URL of the page itself. |
| 1404 |
$permalink = (string) get_permalink($post_id); |
| 1405 |
if ( '' === $permalink ) { |
| 1406 |
return null; |
| 1407 |
} |
| 1408 |
|
| 1409 |
// ------------------------------------------------------------- |
| 1410 |
// Build the Person / Organization (the entity). |
| 1411 |
// ------------------------------------------------------------- |
| 1412 |
|
| 1413 |
$entity_id = $permalink . '#' . strtolower($schema_type); |
| 1414 |
$entity = array( |
| 1415 |
'@type' => $schema_type, |
| 1416 |
'@id' => $entity_id, |
| 1417 |
); |
| 1418 |
|
| 1419 |
if ( '' !== $display_name ) { |
| 1420 |
$entity['name'] = $display_name; |
| 1421 |
} |
| 1422 |
if ( '' !== $description_plain ) { |
| 1423 |
$entity['description'] = $description_plain; |
| 1424 |
} |
| 1425 |
if ( '' !== $avatar ) { |
| 1426 |
$entity['image'] = esc_url_raw($avatar); |
| 1427 |
} |
| 1428 |
$entity['url'] = esc_url_raw($permalink); |
| 1429 |
|
| 1430 |
// sameAs from collected socials. |
| 1431 |
$socials = wp_mylinks_collect_socials($post_id); |
| 1432 |
$same_as = array(); |
| 1433 |
foreach ( $socials as $social ) { |
| 1434 |
if ( ! empty($social['url']) ) { |
| 1435 |
$same_as[] = esc_url_raw($social['url']); |
| 1436 |
} |
| 1437 |
} |
| 1438 |
if ( ! empty($same_as) ) { |
| 1439 |
$entity['sameAs'] = array_values(array_unique($same_as)); |
| 1440 |
} |
| 1441 |
|
| 1442 |
// ------------------------------------------------------------- |
| 1443 |
// Build the ProfilePage wrapper (optional, controlled by toggle). |
| 1444 |
// ------------------------------------------------------------- |
| 1445 |
|
| 1446 |
$emit_profilepage = ( 'yes' === get_option('wp_mylinks_enable_profilepage') ); |
| 1447 |
$profilepage = null; |
| 1448 |
|
| 1449 |
if ( $emit_profilepage ) { |
| 1450 |
$profilepage = array( |
| 1451 |
'@type' => 'ProfilePage', |
| 1452 |
'@id' => $permalink . '#profilepage', |
| 1453 |
'url' => esc_url_raw($permalink), |
| 1454 |
); |
| 1455 |
|
| 1456 |
if ( '' !== $display_name ) { |
| 1457 |
$profilepage['name'] = $display_name; |
| 1458 |
} |
| 1459 |
if ( '' !== $description_plain ) { |
| 1460 |
$profilepage['description'] = $description_plain; |
| 1461 |
} |
| 1462 |
|
| 1463 |
// dateCreated / dateModified — required for high-quality ProfilePage. |
| 1464 |
if ( ! empty($post->post_date_gmt) && '0000-00-00 00:00:00' !== $post->post_date_gmt ) { |
| 1465 |
$profilepage['dateCreated'] = mysql2date('c', $post->post_date_gmt, false); |
| 1466 |
} |
| 1467 |
if ( ! empty($post->post_modified_gmt) && '0000-00-00 00:00:00' !== $post->post_modified_gmt ) { |
| 1468 |
$profilepage['dateModified'] = mysql2date('c', $post->post_modified_gmt, false); |
| 1469 |
} |
| 1470 |
|
| 1471 |
// inLanguage — useful for multilingual sites. |
| 1472 |
$locale = function_exists('determine_locale') ? determine_locale() : get_locale(); |
| 1473 |
if ( is_string($locale) && '' !== $locale ) { |
| 1474 |
// Schema.org expects a BCP-47 tag (e.g. en-US). WP locales are |
| 1475 |
// underscore-joined (en_US); convert. |
| 1476 |
$profilepage['inLanguage'] = str_replace('_', '-', $locale); |
| 1477 |
} |
| 1478 |
|
| 1479 |
// mainEntity — the link to the Person/Organization we built above. |
| 1480 |
$profilepage['mainEntity'] = array( '@id' => $entity_id ); |
| 1481 |
} |
| 1482 |
|
| 1483 |
// ------------------------------------------------------------- |
| 1484 |
// Build the BreadcrumbList (always emitted when schema is on). |
| 1485 |
// |
| 1486 |
// Two-step path: Home → page name. Google explicitly supports the |
| 1487 |
// 2-step BreadcrumbList rich result and uses it in SERPs in place of |
| 1488 |
// the URL line. Linktree, GitHub, LinkedIn all use this pattern. |
| 1489 |
// ------------------------------------------------------------- |
| 1490 |
|
| 1491 |
$home_url = (string) home_url('/'); |
| 1492 |
$site_name = (string) get_bloginfo('name'); |
| 1493 |
$breadcrumb = array( |
| 1494 |
'@type' => 'BreadcrumbList', |
| 1495 |
'@id' => $permalink . '#breadcrumb', |
| 1496 |
'itemListElement' => array( |
| 1497 |
array( |
| 1498 |
'@type' => 'ListItem', |
| 1499 |
'position' => 1, |
| 1500 |
'name' => '' !== $site_name ? $site_name : __('Home', 'wp-mylinks'), |
| 1501 |
'item' => esc_url_raw($home_url), |
| 1502 |
), |
| 1503 |
array( |
| 1504 |
'@type' => 'ListItem', |
| 1505 |
'position' => 2, |
| 1506 |
'name' => $display_name, |
| 1507 |
), |
| 1508 |
), |
| 1509 |
); |
| 1510 |
// Note: per Google guidelines, the FINAL breadcrumb item should NOT |
| 1511 |
// include `item` (the URL) — it's the current page. Only the |
| 1512 |
// non-current items get `item`. Above we omit it from position 2. |
| 1513 |
|
| 1514 |
// ------------------------------------------------------------- |
| 1515 |
// Assemble the final payload. |
| 1516 |
// ------------------------------------------------------------- |
| 1517 |
|
| 1518 |
// We always have the entity. ProfilePage is optional. Breadcrumb is |
| 1519 |
// always emitted. So the graph will have either 2 or 3 items. |
| 1520 |
$graph = array( $entity ); |
| 1521 |
if ( $profilepage ) { |
| 1522 |
$graph[] = $profilepage; |
| 1523 |
} |
| 1524 |
$graph[] = $breadcrumb; |
| 1525 |
|
| 1526 |
$data = array( |
| 1527 |
'@context' => 'https://schema.org', |
| 1528 |
'@graph' => array_values($graph), |
| 1529 |
); |
| 1530 |
|
| 1531 |
/** |
| 1532 |
* Filter the Schema.org JSON-LD data array before encoding. |
| 1533 |
* |
| 1534 |
* The data shape is: |
| 1535 |
* { |
| 1536 |
* "@context": "https://schema.org", |
| 1537 |
* "@graph": [ <entity>, [<ProfilePage>,] <BreadcrumbList> ] |
| 1538 |
* } |
| 1539 |
* |
| 1540 |
* @since 1.0.8 |
| 1541 |
* |
| 1542 |
* @param array $data Schema array (graph-shaped). |
| 1543 |
* @param int $post_id Current post ID. |
| 1544 |
*/ |
| 1545 |
$data = apply_filters('wp_mylinks_schema_data', $data, $post_id); |
| 1546 |
|
| 1547 |
if ( ! is_array($data) || empty($data) ) { |
| 1548 |
return null; |
| 1549 |
} |
| 1550 |
|
| 1551 |
// JSON_UNESCAPED_SLASHES + JSON_UNESCAPED_UNICODE keeps URLs and |
| 1552 |
// non-ASCII names readable in source view. JSON_HEX_TAG + JSON_HEX_AMP |
| 1553 |
// hex-encode <, >, & so a literal "</script>" in any value can never |
| 1554 |
// break out of the surrounding <script type="application/ld+json"> block |
| 1555 |
// — a latent breakout that JSON_UNESCAPED_SLASHES would otherwise leave |
| 1556 |
// open for any future filter or relaxed field sanitizer. |
| 1557 |
$flags = 0; |
| 1558 |
if ( defined('JSON_UNESCAPED_SLASHES') ) { |
| 1559 |
$flags |= JSON_UNESCAPED_SLASHES; |
| 1560 |
} |
| 1561 |
if ( defined('JSON_UNESCAPED_UNICODE') ) { |
| 1562 |
$flags |= JSON_UNESCAPED_UNICODE; |
| 1563 |
} |
| 1564 |
if ( defined('JSON_HEX_TAG') ) { |
| 1565 |
$flags |= JSON_HEX_TAG; |
| 1566 |
} |
| 1567 |
if ( defined('JSON_HEX_AMP') ) { |
| 1568 |
$flags |= JSON_HEX_AMP; |
| 1569 |
} |
| 1570 |
|
| 1571 |
$json = wp_json_encode($data, $flags); |
| 1572 |
return is_string($json) ? $json : null; |
| 1573 |
} |
| 1574 |
} |
| 1575 |
|
| 1576 |
/** |
| 1577 |
* Sanitize the schema_type radio: only 'Person' or 'Organization' allowed. |
| 1578 |
* |
| 1579 |
* @since 1.0.8 |
| 1580 |
* |
| 1581 |
* @param mixed $value |
| 1582 |
* @return string |
| 1583 |
*/ |
| 1584 |
if ( ! function_exists('wp_mylinks_sanitize_schema_type') ) { |
| 1585 |
function wp_mylinks_sanitize_schema_type( $value ) { |
| 1586 |
// Case-sensitive compare against the exact form value; sanitize_key() |
| 1587 |
// was used here before 1.1.0 and lowercased the value first, which made |
| 1588 |
// 'Organization' unsaveable (it always reset to 'Person'). |
| 1589 |
return ( is_string($value) && 'Organization' === trim($value) ) ? 'Organization' : 'Person'; |
| 1590 |
} |
| 1591 |
} |
| 1592 |
|
| 1593 |
/** |
| 1594 |
* Sanitize a Twitter handle: ensure it starts with '@' and contains only |
| 1595 |
* permitted characters. Returns empty string for empty input. |
| 1596 |
* |
| 1597 |
* @since 1.0.8 |
| 1598 |
* |
| 1599 |
* @param mixed $value |
| 1600 |
* @return string |
| 1601 |
*/ |
| 1602 |
if ( ! function_exists('wp_mylinks_sanitize_twitter_handle') ) { |
| 1603 |
function wp_mylinks_sanitize_twitter_handle( $value ) { |
| 1604 |
if ( ! is_string($value) ) { |
| 1605 |
return ''; |
| 1606 |
} |
| 1607 |
$value = trim($value); |
| 1608 |
if ( '' === $value ) { |
| 1609 |
return ''; |
| 1610 |
} |
| 1611 |
// Keep only alphanumerics and underscore (Twitter rules). |
| 1612 |
$value = preg_replace('/[^A-Za-z0-9_]/', '', $value); |
| 1613 |
if ( '' === $value ) { |
| 1614 |
return ''; |
| 1615 |
} |
| 1616 |
return '@' . $value; |
| 1617 |
} |
| 1618 |
} |
| 1619 |
|
| 1620 |
/** |
| 1621 |
* Begins execution of the plugin. |
| 1622 |
* |
| 1623 |
* @since 1.0.0 |
| 1624 |
*/ |
| 1625 |
function run_wp_mylinks() { |
| 1626 |
$plugin = new Wp_Mylinks(); |
| 1627 |
$plugin->run(); |
| 1628 |
} |
| 1629 |
run_wp_mylinks(); |
| 1630 |
|