Created
February 22, 2026 06:17
-
-
Save arenagroove/f4c9b882db170f20756cedb23342d218 to your computer and use it in GitHub Desktop.
WP mu-plugins: Centralized preload / prefetch / preconnect manager for critical assets (hero videos, images, fonts).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: LR Resource Hints Manager | |
| * Description: Centralized preload / prefetch / preconnect manager for critical assets (hero videos, images, fonts). | |
| * Supports responsive image preloading via the `media` attribute — define separate hints for | |
| * desktop and mobile breakpoints on the same page slot. | |
| * Page-specific and infrastructure-safe. Designed for performance tuning. | |
| * | |
| * Admin: | |
| * Tools → LR Resource Hints | |
| * | |
| * Author: Luis Martinez | |
| * Author URI: https://www.lessrain.com | |
| * Version: 3.0 | |
| * Requires PHP: 7.4 | |
| */ | |
| if (!defined('ABSPATH')) { | |
| exit; | |
| } | |
| /* ========================================================= | |
| * Constants | |
| * ========================================================= */ | |
| // Using define() instead of const so the file can be included more than once | |
| // without a fatal "constant already defined" error (e.g. in test suites or | |
| // unusual MU-plugin load orders). | |
| if (!defined('LR_HINTS_OPTION')) { define('LR_HINTS_OPTION', 'lr_resource_hints_v2'); } | |
| // Standard project breakpoints — used as defaults in the UI when as=image. | |
| // Adjust here if the project breakpoint changes. | |
| if (!defined('LR_HINTS_BP_DESKTOP')) { define('LR_HINTS_BP_DESKTOP', '(min-width:51.3em)'); } | |
| if (!defined('LR_HINTS_BP_MOBILE')) { define('LR_HINTS_BP_MOBILE', '(max-width:51.29875em)'); } | |
| /* ========================================================= | |
| * Option helpers | |
| * ========================================================= */ | |
| function lr_hints_defaults() { | |
| return ['pages' => []]; | |
| } | |
| function lr_hints_get() { | |
| $stored = get_option(LR_HINTS_OPTION, []); | |
| return wp_parse_args( | |
| is_array($stored) ? $stored : [], | |
| lr_hints_defaults() | |
| ); | |
| } | |
| function lr_hints_update(array $data) { | |
| $next = array_merge(lr_hints_get(), $data); | |
| update_option(LR_HINTS_OPTION, $next, false); | |
| return $next; | |
| } | |
| /* ========================================================= | |
| * Runtime: inject <link> tags into <head> | |
| * | |
| * Each rule may carry an optional `media` field so that responsive | |
| * image preloads fire only for the matching viewport, e.g.: | |
| * | |
| * <link rel="preload" as="image" href="hero-desktop.webp" | |
| * media="(min-width:51.3em)" fetchpriority="high"> | |
| * <link rel="preload" as="image" href="hero-mobile.webp" | |
| * media="(max-width:51.29875em)" fetchpriority="high"> | |
| * | |
| * Non-image rules typically omit `media` entirely. | |
| * ========================================================= */ | |
| add_action('wp_head', function () { | |
| if (is_admin() || is_feed()) { | |
| return; | |
| } | |
| $post_id = get_queried_object_id(); | |
| // Handle homepage (static page or blog index) | |
| if (!$post_id && is_front_page()) { | |
| $post_id = (int) get_option('page_on_front'); | |
| } | |
| if (!$post_id) { | |
| return; | |
| } | |
| $o = lr_hints_get(); | |
| if (empty($o['pages'][$post_id])) { | |
| return; | |
| } | |
| // Debug comment — visible in view-source when WP_DEBUG is on or LR_ENV is 'dev'. | |
| // Helps quickly confirm which rules are active for a given page. | |
| if ((defined('WP_DEBUG') && WP_DEBUG) || (defined('LR_ENV') && LR_ENV === 'dev')) { | |
| echo "\n<!-- LR Resource Hints: Post {$post_id} (" . count($o['pages'][$post_id]) . " rule(s)) -->\n"; | |
| } | |
| $allowed_types = ['preload', 'prefetch', 'preconnect']; | |
| $allowed_as = ['video', 'image', 'font', 'script', 'style']; | |
| // Deduplicate on type|as|url|media — same URL with different media queries | |
| // are intentionally distinct (responsive image pair). | |
| $seen = []; | |
| foreach ($o['pages'][$post_id] as $hint) { | |
| $type = $hint['type'] ?? ''; | |
| $as = $hint['as'] ?? ''; | |
| $url = esc_url($hint['url'] ?? ''); | |
| $media = $hint['media'] ?? ''; | |
| if (!in_array($type, $allowed_types, true) || !$url) { | |
| continue; | |
| } | |
| if ($as && !in_array($as, $allowed_as, true)) { | |
| continue; | |
| } | |
| $key = $type . '|' . $as . '|' . $url . '|' . $media; | |
| if (isset($seen[$key])) { | |
| continue; | |
| } | |
| $seen[$key] = true; | |
| echo "\n<link rel=\"" . esc_attr($type) . "\" href=\"{$url}\""; | |
| if ($as && $type !== 'preconnect') { | |
| echo " as=\"" . esc_attr($as) . "\""; | |
| } | |
| if ($media) { | |
| echo " media=\"" . esc_attr($media) . "\""; | |
| } | |
| if ($as === 'font' || $as === 'video') { | |
| echo ' crossorigin'; | |
| } | |
| // fetchpriority="high" is appropriate for visual/render-blocking assets. | |
| // Avoid it for scripts and styles — it can trigger console warnings in Chrome | |
| // and the browser's own priority scheduler handles those better. | |
| if ($type === 'preload' && in_array($as, ['image', 'video', 'font'], true)) { | |
| echo ' fetchpriority="high"'; | |
| } | |
| echo ">\n"; | |
| } | |
| }, 0); | |
| /* ========================================================= | |
| * Admin menu | |
| * ========================================================= */ | |
| add_action('admin_menu', function () { | |
| add_management_page( | |
| 'LR Resource Hints', | |
| 'LR Resource Hints', | |
| 'manage_options', | |
| 'lr-resource-hints', | |
| 'lr_hints_render_page' | |
| ); | |
| }); | |
| /* ========================================================= | |
| * AJAX: save rules | |
| * ========================================================= */ | |
| add_action('wp_ajax_lr_hints_save', function () { | |
| check_ajax_referer('lr_hints_save', 'nonce'); | |
| if (!current_user_can('manage_options')) { | |
| wp_send_json_error('Insufficient permissions'); | |
| } | |
| $raw = json_decode(stripslashes($_POST['rules'] ?? '[]'), true); | |
| if (!is_array($raw)) { | |
| wp_send_json_error('Invalid data format'); | |
| } | |
| $parsed = lr_hints_parse_json_rules($raw); | |
| lr_hints_update(['pages' => $parsed]); | |
| wp_send_json_success([ | |
| 'message' => 'Rules saved successfully', | |
| 'count' => count($parsed), | |
| ]); | |
| }); | |
| /* ========================================================= | |
| * AJAX: resolve Post ID → title + edit URL | |
| * Called live as the user types a post ID in the editor. | |
| * ========================================================= */ | |
| add_action('wp_ajax_lr_hints_get_post_title', function () { | |
| check_ajax_referer('lr_hints_save', 'nonce'); | |
| if (!current_user_can('manage_options')) { | |
| wp_send_json_error('Insufficient permissions'); | |
| } | |
| $post_id = (int) ($_GET['post_id'] ?? 0); | |
| if (!$post_id) { | |
| wp_send_json_error('Invalid ID'); | |
| } | |
| $post = get_post($post_id); | |
| if (!$post) { | |
| wp_send_json_error('Post not found'); | |
| } | |
| wp_send_json_success([ | |
| 'title' => get_the_title($post), | |
| 'edit_url' => get_edit_post_link($post_id, 'raw'), | |
| 'type' => $post->post_type, | |
| 'status' => $post->post_status, | |
| ]); | |
| }); | |
| /* ========================================================= | |
| * Admin page | |
| * ========================================================= */ | |
| function lr_hints_render_page() { | |
| if (!current_user_can('manage_options')) { | |
| return; | |
| } | |
| $o = lr_hints_get(); | |
| ?> | |
| <div class="wrap" id="lr-hints-app"> | |
| <h1>LR Resource Hints Manager</h1> | |
| <p class="description"> | |
| Manage preload, prefetch, and preconnect hints for critical assets. | |
| For responsive images, add two rules per page — one per breakpoint — using the <strong>Media Query</strong> field. | |
| </p> | |
| <h2 class="nav-tab-wrapper"> | |
| <a href="#" class="nav-tab nav-tab-active" data-tab="visual">Visual Editor</a> | |
| <a href="#" class="nav-tab" data-tab="preview">Preview</a> | |
| <a href="#" class="nav-tab" data-tab="importexport">Import / Export</a> | |
| </h2> | |
| <!-- ── Visual Editor ─────────────────────────────── --> | |
| <div id="tab-visual" class="tab-content active"> | |
| <div class="lr-hints-toolbar"> | |
| <button type="button" class="button button-primary" id="add-rule"> | |
| <span class="dashicons dashicons-plus-alt2"></span> Add Rule | |
| </button> | |
| <button type="button" class="button" id="save-rules"> | |
| <span class="dashicons dashicons-saved"></span> Save All Changes | |
| </button> | |
| <button type="button" class="button" id="collapse-all">Collapse All</button> | |
| <button type="button" class="button" id="expand-all">Expand All</button> | |
| <span class="lr-hints-status"></span> | |
| </div> | |
| <div id="rules-container"></div> | |
| <div class="lr-hints-empty" style="display:none;"> | |
| <p>No resource hints configured yet.</p> | |
| <button type="button" class="button button-primary" id="add-first-rule"> | |
| Add Your First Rule | |
| </button> | |
| </div> | |
| </div> | |
| <!-- ── Preview ───────────────────────────────────── --> | |
| <div id="tab-preview" class="tab-content"> | |
| <p class="description"> | |
| HTML that will be injected into <head> for each page (reflects unsaved changes too). | |
| </p> | |
| <div id="preview-container"></div> | |
| </div> | |
| <!-- ── Import / Export ───────────────────────────── --> | |
| <div id="tab-importexport" class="tab-content"> | |
| <div class="lr-ie-section"> | |
| <h3>Export</h3> | |
| <p class="description">Copy the JSON below to back up or transfer rules to another environment.</p> | |
| <textarea id="export-area" readonly rows="12"></textarea> | |
| <button type="button" class="button" id="copy-export"> | |
| <span class="dashicons dashicons-clipboard"></span> Copy to Clipboard | |
| </button> | |
| </div> | |
| <div class="lr-ie-section"> | |
| <h3>Import</h3> | |
| <p class="description">Paste a previously exported JSON here. <strong>This will replace all current rules.</strong></p> | |
| <textarea id="import-area" rows="12" placeholder='{"7312":[{"type":"preload","as":"image","url":"/wp-content/uploads/hero.webp","media":"(min-width:51.3em)"}]}'></textarea> | |
| <div style="display:flex;align-items:center;gap:10px;margin-top:6px;"> | |
| <button type="button" class="button button-primary" id="do-import"> | |
| <span class="dashicons dashicons-upload"></span> Import & Save | |
| </button> | |
| <span class="lr-hints-status" id="ie-status"></span> | |
| </div> | |
| </div> | |
| </div> | |
| </div><!-- /#lr-hints-app --> | |
| <style> | |
| #lr-hints-app { max-width: 960px; } | |
| /* ── Tabs ─────────────────────────────────────────── */ | |
| #lr-hints-app .nav-tab-wrapper { margin-bottom: 20px; } | |
| #lr-hints-app .tab-content { display: none; } | |
| #lr-hints-app .tab-content.active { display: block; } | |
| /* ── Toolbar ──────────────────────────────────────── */ | |
| .lr-hints-toolbar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| align-items: center; | |
| margin-bottom: 15px; | |
| padding: 12px; | |
| background: #fff; | |
| border: 1px solid #c3c4c7; | |
| box-shadow: 0 1px 1px rgba(0,0,0,.04); | |
| } | |
| .lr-hints-toolbar .button { | |
| height: 32px; | |
| line-height: 30px; | |
| padding: 0 10px; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| } | |
| .lr-hints-toolbar .dashicons { font-size: 16px; width: 16px; height: 16px; } | |
| .lr-hints-status { margin-left: auto; font-size: 13px; color: #50575e; font-weight: 500; } | |
| .lr-hints-status.success { color: #00a32a; } | |
| .lr-hints-status.error { color: #d63638; } | |
| /* ── Page group ───────────────────────────────────── */ | |
| .lr-page-group { margin-bottom: 24px; } | |
| .lr-page-group-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 14px; | |
| background: #f0f0f1; | |
| border: 1px solid #c3c4c7; | |
| border-bottom: none; | |
| font-size: 13px; | |
| font-weight: 600; | |
| } | |
| .lr-page-group-header .page-title { flex: 1; } | |
| .lr-page-group-header .page-type-badge { | |
| font-size: 10px; | |
| font-weight: 500; | |
| padding: 1px 7px; | |
| background: #dcdcde; | |
| border-radius: 10px; | |
| color: #50575e; | |
| text-transform: uppercase; | |
| } | |
| .lr-page-group-header .page-edit-link { | |
| font-size: 11px; | |
| font-weight: 400; | |
| color: #646970; | |
| text-decoration: none; | |
| } | |
| .lr-page-group-header .page-edit-link:hover { color: #2271b1; } | |
| /* ── Rule card ────────────────────────────────────── */ | |
| .lr-hints-rule { | |
| background: #fff; | |
| border: 1px solid #c3c4c7; | |
| border-top: none; | |
| box-shadow: 0 1px 1px rgba(0,0,0,.04); | |
| margin-bottom: 0; | |
| } | |
| .lr-hints-rule + .lr-hints-rule { border-top: 1px solid #f0f0f1; } | |
| /* ── Rule header (toggle bar) ─────────────────────── */ | |
| .lr-hints-rule-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 14px; | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .lr-hints-rule-header:hover { background: #f6f7f7; } | |
| .lr-rule-toggle { | |
| color: #8c8f94; | |
| font-size: 18px; | |
| width: 18px; | |
| height: 18px; | |
| flex-shrink: 0; | |
| transition: transform 0.15s ease; | |
| } | |
| .lr-hints-rule.is-collapsed .lr-rule-toggle { transform: rotate(-90deg); } | |
| /* ── Rule summary (visible when collapsed) ────────── */ | |
| .lr-rule-summary { | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| min-width: 0; | |
| } | |
| .rule-badge { | |
| font-size: 10px; | |
| font-weight: 600; | |
| padding: 1px 7px; | |
| border-radius: 10px; | |
| flex-shrink: 0; | |
| text-transform: uppercase; | |
| } | |
| .rule-badge.preload { background: #d7f0e2; color: #006f3c; } | |
| .rule-badge.prefetch { background: #dbeafe; color: #1a56db; } | |
| .rule-badge.preconnect { background: #fef3c7; color: #92400e; } | |
| .rule-as-badge { | |
| font-size: 10px; | |
| padding: 1px 7px; | |
| background: #f0f0f1; | |
| border-radius: 10px; | |
| color: #50575e; | |
| flex-shrink: 0; | |
| } | |
| .rule-url-preview { | |
| font-size: 11px; | |
| color: #646970; | |
| font-family: monospace; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .rule-media-preview { | |
| font-size: 10px; | |
| color: #8c8f94; | |
| flex-shrink: 0; | |
| white-space: nowrap; | |
| } | |
| /* ── Rule action buttons ──────────────────────────── */ | |
| .lr-rule-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| flex-shrink: 0; | |
| } | |
| .lr-rule-actions .button { | |
| height: 26px; | |
| line-height: 24px; | |
| padding: 0 8px; | |
| font-size: 12px; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 3px; | |
| } | |
| .lr-rule-actions .dashicons { font-size: 13px; width: 13px; height: 13px; } | |
| .button-link-delete { | |
| color: #d63638; | |
| text-decoration: none; | |
| font-size: 12px; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 3px; | |
| height: 26px; | |
| padding: 0 8px; | |
| } | |
| .button-link-delete .dashicons { font-size: 13px; width: 13px; height: 13px; } | |
| .button-link-delete:hover { color: #a10000; } | |
| /* ── Rule body (collapsible) ──────────────────────── */ | |
| .lr-hints-rule-body { | |
| padding: 14px 14px 16px; | |
| border-top: 1px solid #f0f0f1; | |
| } | |
| .lr-hints-rule.is-collapsed .lr-hints-rule-body { display: none; } | |
| /* ── Field rows ───────────────────────────────────── */ | |
| .lr-hints-row-1 { | |
| display: grid; | |
| grid-template-columns: 90px 130px 130px; | |
| gap: 10px; | |
| margin-bottom: 10px; | |
| } | |
| .lr-hints-row-2 { margin-bottom: 10px; } | |
| .lr-hints-row-3 { | |
| display: grid; | |
| grid-template-columns: 1fr 36px; | |
| gap: 10px; | |
| align-items: end; | |
| } | |
| .lr-hints-row-3 .select-media-wrap { display: flex; flex-direction: column; gap: 4px; } | |
| /* URL hint sits below the grid row so it never affects cell height */ | |
| .lr-url-hint { | |
| font-size: 11px; | |
| color: #8c8f94; | |
| margin-top: 4px; | |
| line-height: 1.4; | |
| } | |
| .lr-hints-field { display: flex; flex-direction: column; gap: 4px; } | |
| .lr-hints-field label { | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: #1d2327; | |
| text-transform: uppercase; | |
| letter-spacing: 0.3px; | |
| } | |
| .lr-hints-field input, | |
| .lr-hints-field select { width: 100%; height: 32px; font-size: 13px; } | |
| /* Inline validation */ | |
| .lr-hints-field input.has-error, | |
| .lr-hints-field select.has-error { | |
| border-color: #d63638; | |
| box-shadow: 0 0 0 1px #d63638; | |
| } | |
| .lr-field-error { font-size: 11px; color: #d63638; margin-top: 2px; } | |
| /* Post title lookup */ | |
| .lr-post-title-lookup { font-size: 11px; margin-top: 3px; min-height: 15px; } | |
| .lr-post-title-lookup.loading { color: #8c8f94; } | |
| .lr-post-title-lookup.found { color: #00a32a; } | |
| .lr-post-title-lookup.notfound { color: #d63638; } | |
| /* Breakpoint shortcuts */ | |
| .lr-hints-bp-shortcuts { | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| margin-top: 6px; | |
| font-size: 11px; | |
| color: #8c8f94; | |
| } | |
| .lr-hints-bp-shortcuts span { font-size: 11px; } | |
| .bp-shortcut { | |
| font-size: 10px; | |
| line-height: 1; | |
| padding: 2px 7px; | |
| color: #50575e; | |
| background: #f0f0f1; | |
| border: 1px solid #c3c4c7; | |
| border-radius: 2px; | |
| text-decoration: none; | |
| } | |
| .bp-shortcut:hover { background: #dcdcde; color: #1d2327; } | |
| /* Media picker button */ | |
| .select-media { width: 36px; height: 32px; padding: 0; display: flex; align-items: center; justify-content: center; } | |
| .select-media .dashicons { margin: 0; width: 18px; height: 18px; font-size: 18px; } | |
| /* ── Empty state ──────────────────────────────────── */ | |
| .lr-hints-empty { | |
| text-align: center; | |
| padding: 60px 20px; | |
| background: #fff; | |
| border: 2px dashed #c3c4c7; | |
| } | |
| .lr-hints-empty p { font-size: 14px; color: #646970; margin-bottom: 15px; } | |
| /* ── Preview ──────────────────────────────────────── */ | |
| .preview-page { margin-bottom: 20px; background: #fff; border: 1px solid #c3c4c7; padding: 16px; } | |
| .preview-page h3 { margin: 0 0 10px; font-size: 13px; font-weight: 600; border-bottom: 1px solid #dcdcde; padding-bottom: 8px; } | |
| .preview-page pre { background: #f6f7f7; padding: 12px; border: 1px solid #dcdcde; overflow-x: auto; font-size: 12px; margin: 0; line-height: 1.5; } | |
| /* ── Import / Export ──────────────────────────────── */ | |
| .lr-ie-section { background: #fff; border: 1px solid #c3c4c7; padding: 20px; margin-bottom: 20px; } | |
| .lr-ie-section h3 { margin-top: 0; } | |
| .lr-ie-section textarea { width: 100%; font-family: monospace; font-size: 12px; resize: vertical; margin-bottom: 8px; display: block; } | |
| .lr-ie-section .button { display: inline-flex; align-items: center; gap: 4px; } | |
| .lr-ie-section .dashicons { font-size: 16px; width: 16px; height: 16px; } | |
| /* ── Responsive ───────────────────────────────────── */ | |
| @media (max-width: 782px) { | |
| .lr-hints-row-1, | |
| .lr-hints-row-3 { grid-template-columns: 1fr; } | |
| .select-media { width: 100%; } | |
| } | |
| </style> | |
| <script> | |
| (function ($) { | |
| 'use strict'; | |
| const BP_DESKTOP = '<?php echo LR_HINTS_BP_DESKTOP; ?>'; | |
| const BP_MOBILE = '<?php echo LR_HINTS_BP_MOBILE; ?>'; | |
| const NONCE = '<?php echo wp_create_nonce('lr_hints_save'); ?>'; | |
| // Title cache: postId (string) → data object | null | |
| const titleCache = {}; | |
| /* ================================================================ | |
| * Utilities | |
| * ================================================================ */ | |
| // Builds the collapsed summary line for a rule | |
| function ruleSummaryHtml(rule) { | |
| const type = rule.type || '?'; | |
| const as = rule.as || ''; | |
| const url = rule.url || ''; | |
| const media = rule.media || ''; | |
| const filename = url ? url.split('/').pop().split('?')[0] : '—'; | |
| return ` | |
| <span class="rule-badge ${type}">${type}</span> | |
| ${as ? `<span class="rule-as-badge">${as}</span>` : ''} | |
| <span class="rule-url-preview" title="${url}">${filename || url}</span> | |
| ${media ? `<span class="rule-media-preview">${media}</span>` : ''} | |
| `; | |
| } | |
| // Simple debounce — no lodash needed | |
| function debounce(fn, delay) { | |
| let timer; | |
| return function () { | |
| const args = arguments; | |
| const ctx = this; | |
| clearTimeout(timer); | |
| timer = setTimeout(function () { fn.apply(ctx, args); }, delay); | |
| }; | |
| } | |
| /* ================================================================ | |
| * Post title lookup | |
| * ================================================================ */ | |
| function fetchPostTitle(postId, $indicator, $rule) { | |
| if (!postId) { | |
| $indicator.text('').removeClass('loading found notfound'); | |
| updateGroupHeader($rule, postId, null); | |
| return; | |
| } | |
| const cacheKey = String(postId); | |
| if (cacheKey in titleCache) { | |
| applyTitle(titleCache[cacheKey], $indicator, $rule, postId); | |
| return; | |
| } | |
| $indicator.text('Looking up…').removeClass('found notfound').addClass('loading'); | |
| $.get(ajaxurl, { | |
| action: 'lr_hints_get_post_title', | |
| nonce: NONCE, | |
| post_id: postId, | |
| }) | |
| .done(function (response) { | |
| if (response.success) { | |
| titleCache[cacheKey] = response.data; | |
| applyTitle(response.data, $indicator, $rule, postId); | |
| } else { | |
| titleCache[cacheKey] = null; | |
| $indicator.text('Post not found').removeClass('loading found').addClass('notfound'); | |
| updateGroupHeader($rule, postId, null); | |
| } | |
| }) | |
| .fail(function () { | |
| $indicator.text('Lookup failed').removeClass('loading found').addClass('notfound'); | |
| }); | |
| } | |
| function applyTitle(data, $indicator, $rule, postId) { | |
| if (!data) { | |
| return; | |
| } | |
| const editLink = data.edit_url | |
| ? ' — <a href="' + data.edit_url + '" target="_blank">edit</a>' | |
| : ''; | |
| $indicator.html(data.title + editLink).removeClass('loading notfound').addClass('found'); | |
| updateGroupHeader($rule, postId, data); | |
| } | |
| // Refreshes the page-group header once we have a title | |
| function updateGroupHeader($rule, postId, data) { | |
| const $group = $rule.closest('.lr-page-group'); | |
| if (!$group.length) { | |
| return; | |
| } | |
| const $title = $group.find('.page-title'); | |
| const $badge = $group.find('.page-type-badge'); | |
| const $link = $group.find('.page-edit-link'); | |
| if (data) { | |
| $title.text('#' + postId + ' — ' + data.title); | |
| $badge.text(data.type); | |
| if (data.edit_url) { | |
| $link.attr('href', data.edit_url).show(); | |
| } else { | |
| $link.hide(); | |
| } | |
| } else { | |
| $title.text('#' + postId); | |
| $badge.text(''); | |
| $link.hide(); | |
| } | |
| } | |
| // Contextual URL hints per asset type. | |
| // Shown below the URL field to remind what path format is expected. | |
| var URL_HINTS = { | |
| 'image': { placeholder: '/wp-content/uploads/2026/01/hero.webp', hint: 'Absolute or root-relative path to an uploaded image.' }, | |
| 'video': { placeholder: '/wp-content/uploads/2026/01/hero.mp4', hint: 'Absolute or root-relative path to an uploaded video.' }, | |
| 'font': { placeholder: '/wp-content/themes/my-theme/fonts/font.woff2', hint: 'Root-relative path to a font file in your theme directory.' }, | |
| 'script': { placeholder: '/wp-content/themes/my-theme/build/main.min.js', hint: 'Root-relative path to a JS file — match the exact enqueued URL.' }, | |
| 'style': { placeholder: '/wp-content/themes/my-theme/build/main.min.css',hint: 'Root-relative path to a CSS file — match the exact enqueued URL.' }, | |
| '': { placeholder: '/path/to/file.ext or https://…', hint: 'Select an asset type above for a path example.' }, | |
| }; | |
| function urlHintFor(as) { | |
| return URL_HINTS[as] || URL_HINTS['']; | |
| } | |
| const app = { | |
| rules: <?php echo json_encode($o['pages']); ?>, | |
| /* ── Bootstrap ───────────────────────────── */ | |
| init: function () { | |
| this.bindEvents(); | |
| this.render(); | |
| }, | |
| /* ── Events ──────────────────────────────── */ | |
| bindEvents: function () { | |
| var self = this; | |
| // Tabs | |
| $('.nav-tab').on('click', function (e) { | |
| e.preventDefault(); | |
| self.switchTab($(this).data('tab')); | |
| }); | |
| // Add rule | |
| $('#add-rule, #add-first-rule').on('click', function () { | |
| self.addRule(); | |
| }); | |
| // Save | |
| $('#save-rules').on('click', function () { | |
| self.save(); | |
| }); | |
| // Collapse / expand all | |
| $('#collapse-all').on('click', function () { | |
| $('.lr-hints-rule').addClass('is-collapsed'); | |
| }); | |
| $('#expand-all').on('click', function () { | |
| $('.lr-hints-rule').removeClass('is-collapsed'); | |
| }); | |
| // Toggle individual rule — click on header, not on buttons inside it | |
| $(document).on('click', '.lr-hints-rule-header', function (e) { | |
| if ($(e.target).closest('.lr-rule-actions').length) { | |
| return; | |
| } | |
| $(this).closest('.lr-hints-rule').toggleClass('is-collapsed'); | |
| }); | |
| // Delete | |
| $(document).on('click', '.delete-rule', function (e) { | |
| e.stopPropagation(); | |
| if (!confirm('Delete this resource hint?')) { | |
| return; | |
| } | |
| var $rule = $(this).closest('.lr-hints-rule'); | |
| var $group = $rule.closest('.lr-page-group'); | |
| $rule.remove(); | |
| if ($group.find('.lr-hints-rule').length === 0) { | |
| $group.remove(); | |
| } | |
| if ($('.lr-hints-rule').length === 0) { | |
| $('.lr-hints-empty').show(); | |
| } | |
| }); | |
| // Duplicate | |
| $(document).on('click', '.duplicate-rule', function (e) { | |
| e.stopPropagation(); | |
| self.duplicateRule($(this).closest('.lr-hints-rule')); | |
| }); | |
| // Media picker | |
| $(document).on('click', '.select-media', function (e) { | |
| e.preventDefault(); | |
| self.openMediaPicker($(this)); | |
| }); | |
| // Show / hide media query row when type or as changes | |
| $(document).on('change', '.rule-as, .rule-type', function (e) { | |
| var $rule = $(this).closest('.lr-hints-rule'); | |
| self.toggleMediaQueryRow($rule); | |
| self.updateSummary($rule); | |
| }); | |
| // Live summary update on URL / media input | |
| $(document).on('input change', '.rule-url, .rule-media', function () { | |
| self.updateSummary($(this).closest('.lr-hints-rule')); | |
| }); | |
| // Post ID lookup — debounced so we don't fire on every keystroke | |
| $(document).on('input', '.post-id-input', debounce(function (e) { | |
| var $input = $(e.target); | |
| var $rule = $input.closest('.lr-hints-rule'); | |
| var postId = parseInt($input.val(), 10); | |
| var $ind = $rule.find('.lr-post-title-lookup'); | |
| $input.removeClass('has-error'); | |
| $rule.find('.lr-field-error').remove(); | |
| fetchPostTitle(postId || 0, $ind, $rule); | |
| }, 500)); | |
| // Breakpoint shortcuts | |
| $(document).on('click', '.bp-shortcut', function (e) { | |
| e.preventDefault(); | |
| var $rule = $(this).closest('.lr-hints-rule'); | |
| $rule.find('.rule-media').val($(this).data('value')); | |
| self.updateSummary($rule); | |
| }); | |
| // Copy export | |
| $('#copy-export').on('click', function () { | |
| var $area = $('#export-area'); | |
| $area.select(); | |
| document.execCommand('copy'); | |
| $(this).text('Copied!'); | |
| setTimeout(function () { | |
| $('#copy-export').html('<span class="dashicons dashicons-clipboard"></span> Copy to Clipboard'); | |
| }, 2000); | |
| }); | |
| // Import | |
| $('#do-import').on('click', function () { | |
| self.doImport(); | |
| }); | |
| // Populate export when that tab opens | |
| $('.nav-tab[data-tab="importexport"]').on('click', function () { | |
| self.populateExport(); | |
| }); | |
| }, | |
| /* ── Tab switching ───────────────────────── */ | |
| switchTab: function (tab) { | |
| $('.nav-tab').removeClass('nav-tab-active'); | |
| $('.nav-tab[data-tab="' + tab + '"]').addClass('nav-tab-active'); | |
| $('.tab-content').removeClass('active'); | |
| $('#tab-' + tab).addClass('active'); | |
| if (tab === 'preview') { | |
| this.renderPreview(); | |
| } | |
| if (tab === 'importexport') { | |
| this.populateExport(); | |
| } | |
| }, | |
| /* ── Add / duplicate ─────────────────────── */ | |
| addRule: function (postId, type, as, url, media, collapsed) { | |
| postId = postId || ''; | |
| type = type || 'preload'; | |
| as = as || 'image'; | |
| url = url || ''; | |
| media = media || ''; | |
| collapsed = collapsed || false; | |
| var tempKey = postId || ('temp-' + Date.now()); | |
| var $group = $('#page-group-' + tempKey); | |
| if (!$group.length) { | |
| $group = this.renderPageGroup(tempKey, null); | |
| $('#rules-container').append($group); | |
| } | |
| var rule = { type: type, as: as, url: url, media: media }; | |
| var index = Date.now(); | |
| var $rule = this.renderRule(tempKey, rule, index, collapsed); | |
| $group.find('.lr-page-group-rules').append($rule); | |
| $('.lr-hints-empty').hide(); | |
| if (postId) { | |
| fetchPostTitle(parseInt(postId, 10), $rule.find('.lr-post-title-lookup'), $rule); | |
| } | |
| }, | |
| // Duplicate: clones everything except media (user will set the paired breakpoint) | |
| duplicateRule: function ($sourceRule) { | |
| var postId = $sourceRule.find('.post-id-input').val(); | |
| var type = $sourceRule.find('.rule-type').val(); | |
| var as = $sourceRule.find('.rule-as').val(); | |
| var url = $sourceRule.find('.rule-url').val(); | |
| this.addRule(postId, type, as, url, '', false); | |
| }, | |
| /* ── Render ──────────────────────────────── */ | |
| render: function () { | |
| var self = this; | |
| var $container = $('#rules-container').empty(); | |
| if (Object.keys(this.rules).length === 0) { | |
| $('.lr-hints-empty').show(); | |
| return; | |
| } | |
| $('.lr-hints-empty').hide(); | |
| $.each(this.rules, function (postId, rules) { | |
| var $group = self.renderPageGroup(postId, null); | |
| $container.append($group); | |
| $.each(rules, function (index, rule) { | |
| var $rule = self.renderRule(postId, rule, index, false); | |
| $group.find('.lr-page-group-rules').append($rule); | |
| fetchPostTitle( | |
| parseInt(postId, 10), | |
| $rule.find('.lr-post-title-lookup'), | |
| $rule | |
| ); | |
| }); | |
| }); | |
| }, | |
| renderPageGroup: function (postId, titleData) { | |
| var isTemp = String(postId).indexOf('temp-') === 0; | |
| var displayId = isTemp ? '…' : postId; | |
| var title = titleData ? ('#' + postId + ' — ' + titleData.title) : ('#' + displayId); | |
| var type = titleData ? titleData.type : ''; | |
| var editUrl = titleData ? (titleData.edit_url || '') : ''; | |
| return $([ | |
| '<div class="lr-page-group" id="page-group-' + postId + '">', | |
| '<div class="lr-page-group-header">', | |
| '<span class="page-title">' + title + '</span>', | |
| type ? '<span class="page-type-badge">' + type + '</span>' : '<span class="page-type-badge"></span>', | |
| '<a href="' + editUrl + '" target="_blank" class="page-edit-link"' + (editUrl ? '' : ' style="display:none"') + '>Edit post ↗</a>', | |
| '</div>', | |
| '<div class="lr-page-group-rules"></div>', | |
| '</div>' | |
| ].join('')); | |
| }, | |
| renderRule: function (postId, rule, index, collapsed) { | |
| var isTemp = String(postId).indexOf('temp-') === 0; | |
| var displayId = isTemp ? '' : postId; | |
| var isImage = rule.as === 'image' && rule.type === 'preload'; | |
| var showPicker = (rule.as === 'image' || rule.as === 'video'); | |
| var urlHint = urlHintFor(rule.as); | |
| var html = [ | |
| '<div class="lr-hints-rule ' + (collapsed ? 'is-collapsed' : '') + '"', | |
| ' data-post-id="' + postId + '" data-index="' + index + '">', | |
| // Header / toggle bar | |
| '<div class="lr-hints-rule-header">', | |
| '<span class="dashicons dashicons-arrow-down-alt2 lr-rule-toggle"></span>', | |
| '<div class="lr-rule-summary">' + ruleSummaryHtml(rule) + '</div>', | |
| '<div class="lr-rule-actions">', | |
| '<button type="button" class="button duplicate-rule" title="Duplicate rule">', | |
| '<span class="dashicons dashicons-admin-page"></span> Duplicate', | |
| '</button>', | |
| '<button type="button" class="button-link button-link-delete delete-rule">', | |
| '<span class="dashicons dashicons-trash"></span> Delete', | |
| '</button>', | |
| '</div>', | |
| '</div>', | |
| // Body | |
| '<div class="lr-hints-rule-body">', | |
| // Row 1 | |
| '<div class="lr-hints-row-1">', | |
| '<div class="lr-hints-field">', | |
| '<label>Post ID</label>', | |
| '<input type="number" class="post-id-input" value="' + displayId + '" placeholder="123" min="1">', | |
| '<span class="lr-post-title-lookup"></span>', | |
| '</div>', | |
| '<div class="lr-hints-field">', | |
| '<label>Type</label>', | |
| '<select class="rule-type">', | |
| '<option value="preload"' + (rule.type === 'preload' ? ' selected' : '') + '>preload</option>', | |
| '<option value="prefetch"' + (rule.type === 'prefetch' ? ' selected' : '') + '>prefetch</option>', | |
| '<option value="preconnect"' + (rule.type === 'preconnect' ? ' selected' : '') + '>preconnect</option>', | |
| '</select>', | |
| '</div>', | |
| '<div class="lr-hints-field">', | |
| '<label>As</label>', | |
| '<select class="rule-as">', | |
| '<option value="">—</option>', | |
| '<option value="video"' + (rule.as === 'video' ? ' selected' : '') + '>video</option>', | |
| '<option value="image"' + (rule.as === 'image' ? ' selected' : '') + '>image</option>', | |
| '<option value="font"' + (rule.as === 'font' ? ' selected' : '') + '>font</option>', | |
| '<option value="script"' + (rule.as === 'script' ? ' selected' : '') + '>script</option>', | |
| '<option value="style"' + (rule.as === 'style' ? ' selected' : '') + '>style</option>', | |
| '</select>', | |
| '</div>', | |
| '</div>', | |
| // Row 2 — media query (preload + image only) | |
| '<div class="lr-hints-row-2"' + (!isImage ? ' style="display:none"' : '') + '>', | |
| '<div class="lr-hints-field">', | |
| '<label>Media Query <span style="font-weight:400;text-transform:none;font-size:10px;color:#8c8f94;">— optional, leave empty for all viewports</span></label>', | |
| '<input type="text" class="rule-media" value="' + (rule.media || '') + '" placeholder="e.g. (min-width:51.3em)">', | |
| '</div>', | |
| '<div class="lr-hints-bp-shortcuts">', | |
| '<span>Shortcuts:</span>', | |
| '<a href="#" class="bp-shortcut" data-value="' + BP_DESKTOP + '">desktop</a>', | |
| '<a href="#" class="bp-shortcut" data-value="' + BP_MOBILE + '">mobile</a>', | |
| '<a href="#" class="bp-shortcut" data-value="">clear</a>', | |
| '</div>', | |
| '</div>', | |
| // Row 3 — URL | |
| // Media picker only for image/video (WP media library assets). | |
| // Fonts, scripts and styles live in the theme/build dir — enter path manually. | |
| '<div class="lr-hints-row-3">', | |
| '<div class="lr-hints-field">', | |
| '<label>URL</label>', | |
| '<input type="text" class="rule-url" value="' + (rule.url || '') + '" placeholder="' + urlHint.placeholder + '">', | |
| '</div>', | |
| '<div class="lr-hints-field select-media-wrap"' + (!showPicker ? ' style="display:none"' : '') + '>', | |
| '<label> </label>', | |
| '<button type="button" class="button select-media" title="Pick from Media Library">', | |
| '<span class="dashicons dashicons-admin-media"></span>', | |
| '</button>', | |
| '</div>', | |
| '</div>', | |
| // Hint lives outside the grid so it never affects button alignment | |
| '<span class="lr-url-hint">' + urlHint.hint + '</span>', | |
| '</div>', // /.lr-hints-rule-body | |
| '</div>' // /.lr-hints-rule | |
| ].join(''); | |
| return $(html); | |
| }, | |
| /* ── Per-rule helpers ────────────────────── */ | |
| toggleMediaQueryRow: function ($rule) { | |
| var type = $rule.find('.rule-type').val(); | |
| var as = $rule.find('.rule-as').val(); | |
| var $row = $rule.find('.lr-hints-row-2'); | |
| if (type === 'preload' && as === 'image') { | |
| $row.show(); | |
| } else { | |
| $row.hide(); | |
| $row.find('.rule-media').val(''); | |
| } | |
| // Media picker only makes sense for assets that live in the WP media library | |
| var showPicker = (as === 'image' || as === 'video'); | |
| $rule.find('.select-media-wrap').toggle(showPicker); | |
| // Update URL placeholder and hint text to match selected asset type | |
| var urlHint = urlHintFor(as); | |
| $rule.find('.rule-url').attr('placeholder', urlHint.placeholder); | |
| $rule.find('.lr-url-hint').text(urlHint.hint); | |
| }, | |
| updateSummary: function ($rule) { | |
| var rule = { | |
| type: $rule.find('.rule-type').val(), | |
| as: $rule.find('.rule-as').val(), | |
| url: $rule.find('.rule-url').val(), | |
| media: $rule.find('.rule-media').val(), | |
| }; | |
| $rule.find('.lr-rule-summary').html(ruleSummaryHtml(rule)); | |
| }, | |
| openMediaPicker: function ($button) { | |
| var self = this; | |
| var $rule = $button.closest('.lr-hints-rule'); | |
| var $urlInput = $rule.find('.rule-url'); | |
| var frame = wp.media({ | |
| title: 'Select Resource', | |
| multiple: false, | |
| library: { type: ['image', 'video'] } | |
| }); | |
| frame.on('select', function () { | |
| $urlInput.val(frame.state().get('selection').first().toJSON().url); | |
| self.updateSummary($rule); | |
| }); | |
| frame.open(); | |
| }, | |
| /* ── Collect & validate ──────────────────── */ | |
| collectRules: function () { | |
| var collected = {}; | |
| $('.lr-hints-rule').each(function () { | |
| var $rule = $(this); | |
| var postId = parseInt($rule.find('.post-id-input').val(), 10); | |
| var type = $rule.find('.rule-type').val(); | |
| var as = $rule.find('.rule-as').val(); | |
| var media = $rule.find('.rule-media').val().trim(); | |
| var url = $rule.find('.rule-url').val().trim(); | |
| if (!postId || !type || !url) { | |
| return; | |
| } | |
| if (!collected[postId]) { | |
| collected[postId] = []; | |
| } | |
| collected[postId].push({ type: type, as: as, url: url, media: media }); | |
| }); | |
| return collected; | |
| }, | |
| // Highlights invalid fields inline. Returns true if all valid. | |
| validate: function () { | |
| var valid = true; | |
| $('.has-error').removeClass('has-error'); | |
| $('.lr-field-error').remove(); | |
| $('.lr-hints-rule').each(function () { | |
| var $rule = $(this); | |
| var $postId = $rule.find('.post-id-input'); | |
| var $url = $rule.find('.rule-url'); | |
| if (!parseInt($postId.val(), 10)) { | |
| $postId.addClass('has-error').after('<span class="lr-field-error">Required</span>'); | |
| $rule.removeClass('is-collapsed'); | |
| valid = false; | |
| } | |
| if (!$url.val().trim()) { | |
| $url.addClass('has-error'); | |
| $url.closest('.lr-hints-field').append('<span class="lr-field-error">Required</span>'); | |
| $rule.removeClass('is-collapsed'); | |
| valid = false; | |
| } | |
| }); | |
| return valid; | |
| }, | |
| /* ── Save ────────────────────────────────── */ | |
| save: function () { | |
| var self = this; | |
| if (!this.validate()) { | |
| return; | |
| } | |
| var rules = this.collectRules(); | |
| var $status = $('.lr-hints-status'); | |
| $status.text('Saving…').removeClass('success error'); | |
| $.ajax({ | |
| url: ajaxurl, | |
| method: 'POST', | |
| data: { | |
| action: 'lr_hints_save', | |
| nonce: NONCE, | |
| rules: JSON.stringify(rules), | |
| }, | |
| success: function (response) { | |
| if (response.success) { | |
| $status.text(response.data.message).addClass('success'); | |
| self.rules = rules; | |
| } else { | |
| $status.text('Error: ' + response.data).addClass('error'); | |
| } | |
| setTimeout(function () { | |
| $status.text('').removeClass('success error'); | |
| }, 3000); | |
| }, | |
| error: function () { | |
| $status.text('Save failed').addClass('error'); | |
| } | |
| }); | |
| }, | |
| /* ── Preview ─────────────────────────────── */ | |
| renderPreview: function () { | |
| var $container = $('#preview-container').empty(); | |
| var currentRules = this.collectRules(); | |
| if (Object.keys(currentRules).length === 0) { | |
| $container.append('<p>No resource hints configured.</p>'); | |
| return; | |
| } | |
| $.each(currentRules, function (postId, rules) { | |
| var html = ''; | |
| $.each(rules, function (i, rule) { | |
| html += '<link rel="' + rule.type + '" href="' + rule.url + '"'; | |
| if (rule.as && rule.type !== 'preconnect') { | |
| html += ' as="' + rule.as + '"'; | |
| } | |
| if (rule.media) { | |
| html += ' media="' + rule.media + '"'; | |
| } | |
| if (rule.as === 'font' || rule.as === 'video') { | |
| html += ' crossorigin'; | |
| } | |
| if (rule.type === 'preload' && (rule.as === 'image' || rule.as === 'video' || rule.as === 'font')) { | |
| html += ' fetchpriority="high"'; | |
| } | |
| html += '>\n'; | |
| }); | |
| var cached = titleCache[String(postId)]; | |
| var heading = cached ? ('#' + postId + ' — ' + cached.title) : ('Post ID: ' + postId); | |
| $container.append( | |
| '<div class="preview-page">' + | |
| '<h3>' + heading + '</h3>' + | |
| '<pre><code>' + html + '</code></pre>' + | |
| '</div>' | |
| ); | |
| }); | |
| }, | |
| /* ── Import / Export ─────────────────────── */ | |
| populateExport: function () { | |
| var rules = this.collectRules(); | |
| $('#export-area').val(JSON.stringify(rules, null, 2)); | |
| }, | |
| doImport: function () { | |
| var self = this; | |
| var raw = $('#import-area').val().trim(); | |
| var $status = $('#ie-status'); | |
| if (!raw) { | |
| $status.text('Nothing to import.').addClass('error').removeClass('success'); | |
| return; | |
| } | |
| var parsed; | |
| try { | |
| parsed = JSON.parse(raw); | |
| } catch (e) { | |
| $status.text('Invalid JSON — ' + e.message).addClass('error').removeClass('success'); | |
| return; | |
| } | |
| if (typeof parsed !== 'object' || Array.isArray(parsed)) { | |
| $status.text('Expected a JSON object keyed by post ID.').addClass('error').removeClass('success'); | |
| return; | |
| } | |
| if (!confirm('This will replace ALL current rules. Continue?')) { | |
| return; | |
| } | |
| $status.text('Importing…').removeClass('success error'); | |
| $.ajax({ | |
| url: ajaxurl, | |
| method: 'POST', | |
| data: { | |
| action: 'lr_hints_save', | |
| nonce: NONCE, | |
| rules: JSON.stringify(parsed), | |
| }, | |
| success: function (response) { | |
| if (response.success) { | |
| $status.text('Imported & saved.').addClass('success').removeClass('error'); | |
| self.rules = parsed; | |
| self.render(); | |
| self.switchTab('visual'); | |
| } else { | |
| $status.text('Error: ' + response.data).addClass('error').removeClass('success'); | |
| } | |
| setTimeout(function () { | |
| $status.text('').removeClass('success error'); | |
| }, 4000); | |
| }, | |
| error: function () { | |
| $status.text('Import failed.').addClass('error').removeClass('success'); | |
| } | |
| }); | |
| } | |
| }; | |
| $(document).ready(function () { | |
| app.init(); | |
| }); | |
| }(jQuery)); | |
| </script> | |
| <?php | |
| } | |
| /* ========================================================= | |
| * Parser / sanitizer | |
| * ========================================================= */ | |
| /** | |
| * Sanitizes raw JSON rules coming from the AJAX save request. | |
| * | |
| * Each rule object may carry: | |
| * - type (required) — preload | prefetch | preconnect | |
| * - as (optional) — video | image | font | script | style | |
| * - url (required) — absolute or root-relative URL | |
| * - media (optional) — CSS media query string, e.g. "(min-width:51.3em)" | |
| * | |
| * @param array $raw Decoded JSON array keyed by post ID. | |
| * @return array Sanitized rules array. | |
| */ | |
| function lr_hints_parse_json_rules(array $raw) { | |
| $out = []; | |
| foreach ($raw as $postId => $rules) { | |
| $postId = (int) $postId; | |
| if (!$postId) { | |
| continue; | |
| } | |
| foreach ($rules as $rule) { | |
| $out[$postId][] = [ | |
| 'type' => sanitize_key($rule['type'] ?? ''), | |
| 'as' => sanitize_key($rule['as'] ?? ''), | |
| 'url' => esc_url_raw($rule['url'] ?? ''), | |
| 'media' => sanitize_text_field($rule['media'] ?? ''), | |
| ]; | |
| } | |
| } | |
| return $out; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment