Skip to content

Instantly share code, notes, and snippets.

@arenagroove
Created February 22, 2026 06:17
Show Gist options
  • Select an option

  • Save arenagroove/f4c9b882db170f20756cedb23342d218 to your computer and use it in GitHub Desktop.

Select an option

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).
<?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 &lt;head&gt; 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 &amp; 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>&nbsp;</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 += '&lt;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 += '&gt;\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