PluginProbe
WP MyLinks / trunk
WP MyLinks vtrunk
1.1.1 1.1.0 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8
wp-mylinks / wp-mylinks.php

wp-mylinks.php in WP MyLinks trunk, at wp-mylinks.php

1,630 lines 52.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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('&mdash; ', $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