diff --git a/README.md b/README.md index 06219a8..c297ffd 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,6 @@ A [WordPress plugin](https://wordpress.org/plugins) that renders [OpenFreeMap](h ## Installation 1. Upload the plugin to your `/wp-plugins/` directory 2. Activate the plugin through the 'Plugins' menu in WordPress -3. Go to "Settings > OpenFreemap" to configure the plugin - ## Basic Usage Once activated you can render [OpenFreeMap](https://openfreemap.org) map widgets using the shortcode @@ -21,9 +19,13 @@ The options are as follows - `zoom` Initial zoom of the map (default: `16.5`) - `color` - Marker color in CSS notation (default: `#ff0000`) + Marker color in hex notation, e.g. `#ff0000` (default: `#ff0000`) - `height` Height of the map widget in CSS notation (default: `450px`) +- `popup_text` + HTML content shown in a popup attached to the marker (default: none) +- `style` + Map style, one of `bright`, `dark`, `fiord`, `liberty`, `positron` (default: `bright`) ## Links - https://openfreemap.org diff --git a/assets/loader.js b/assets/loader.js new file mode 100644 index 0000000..2841800 --- /dev/null +++ b/assets/loader.js @@ -0,0 +1,18 @@ +var openfreemapModuleUrl; +var openfreemapStyleUrl; + +if (document.querySelector('.openfreemap') && typeof openfreemapModuleUrl === 'string') { + if (typeof openfreemapStyleUrl === 'string') { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = openfreemapStyleUrl; + link.onerror = function () { + console.error('[OpenFreeMap] Failed to load the map stylesheet.'); + }; + document.head.appendChild(link); + } + + import(openfreemapModuleUrl).catch(function (error) { + console.error('[OpenFreeMap] Failed to load the map library.', error); + }); +} diff --git a/assets/openfreemap.js b/assets/openfreemap.js index a7e4eca..08e9a5f 100644 --- a/assets/openfreemap.js +++ b/assets/openfreemap.js @@ -1,23 +1,45 @@ import * as maplibregl from '../vendor/maplibre-gl/maplibre-gl.mjs'; +const NO_WEBGL_MESSAGE = + 'The map could not be displayed. Please enable WebGL or use a different browser.'; + +function readNumber(value, fallback) { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + document.querySelectorAll('.openfreemap').forEach((container) => { const center = [ - parseFloat(container.dataset.lon), - parseFloat(container.dataset.lat) + readNumber(container.dataset.lon, 12.3453356), + readNumber(container.dataset.lat, 47.1092664) ]; - const zoom = parseFloat(container.dataset.zoom); - const map = new maplibregl.Map({ - style: container.dataset.style, - center: center, - zoom: zoom, - container: container + const zoom = readNumber(container.dataset.zoom, 16.5); + const popupText = container.dataset.popup; + + let map; + try { + map = new maplibregl.Map({ + style: container.dataset.style, + center: center, + zoom: zoom, + container: container + }); + } catch (error) { + // MapLibre throws when WebGL is unavailable, leaving an empty box behind. + console.error('[OpenFreeMap] Could not initialise the map.', error); + container.textContent = NO_WEBGL_MESSAGE; + return; + } + + map.on('error', (event) => { + console.warn('[OpenFreeMap]', event.error); }); + const marker = new maplibregl.Marker({ color: container.dataset.color }) .setLngLat(center) .addTo(map); - const popupText = container.dataset.popup; if (popupText) { const popup = new maplibregl.Popup({ closeOnClick: false @@ -27,4 +49,3 @@ document.querySelectorAll('.openfreemap').forEach((container) => { } map.addControl(new maplibregl.NavigationControl()); }); - diff --git a/openfreemap.php b/openfreemap.php index 46eda40..8194259 100644 --- a/openfreemap.php +++ b/openfreemap.php @@ -2,9 +2,13 @@ /* Plugin Name: OpenFreeMap Description: Add an OpenFreeMap widget via shortcode [openfreemap] -Version: 0.2.0 +Version: 0.3.0 Author: Erwin Nindl Author URI: https://nindl.net +License: MIT +Requires at least: 5.9 +Requires PHP: 7.4 +Text Domain: openfreemap */ if (!defined('ABSPATH')) @@ -12,6 +16,79 @@ if (!defined('ABSPATH')) exit; // Exit if accessed directly } + +// Clamp a numeric shortcode attribute, falling back to $default when it is not a number +function openfreemap_clamp_float($value, $default, $min, $max) +{ + if (!is_numeric($value)) { + return $default; + } + return max($min, min($max, (float) $value)); +} + + +// Allow only plain CSS lengths so the value can be interpolated into a style attribute +function openfreemap_sanitize_css_length($value, $default) +{ + $value = trim((string) $value); + if (preg_match('/^\d+(?:\.\d+)?(?:px|pt|em|rem|ch|%|vh|vw|vmin|vmax)$/i', $value)) { + return $value; + } + return $default; +} + + +// Read the release version from the plugin header, keeping the header authoritative +function openfreemap_plugin_version() +{ + static $version = null; + if (null === $version) { + $header = get_file_data(__FILE__, array('Version' => 'Version'), 'plugin'); + $version = isset($header['Version']) && $header['Version'] ? $header['Version'] : null; + } + return $version; +} + + +// Append a cache busting query argument to a bundled asset URL. Every bundled +// asset (vendored or not) shares the plugin's Version header, so bumping that +// header on release is what invalidates caches -- including for vendored +// files (e.g. a new MapLibre or OpenFreeMap style release) that don't carry +// their own version number. +function openfreemap_versioned_url($relative_path) +{ + $url = plugin_dir_url(__FILE__) . $relative_path; + $version = openfreemap_plugin_version(); + return $version ? add_query_arg('ver', $version, $url) : $url; +} + + +// URL of a bundled OpenFreeMap style, versioned like every other shipped asset +function openfreemap_style_url($style) +{ + return openfreemap_versioned_url('vendor/openfreemap/styles/' . sanitize_key($style)); +} + + +// Styles bundled under vendor/openfreemap/styles/. Kept as an explicit allow-list +// so the shortcode can't be used to probe for arbitrary files on disk. +function openfreemap_available_styles() +{ + return array('bright', 'dark', 'fiord', 'liberty', 'positron'); +} + + +// Restrict the shortcode's style attribute to a known, bundled style +function openfreemap_sanitize_style($value, $default) +{ + $value = sanitize_key($value); + if (in_array($value, openfreemap_available_styles(), true)) { + return $value; + } + return $default; +} + + // Register the shortcode function openfreemap_handler($atts) { @@ -21,16 +98,19 @@ function openfreemap_handler($atts) 'popup_text' => '', 'color' => '#ff0000', 'height' => '450px', - 'zoom' => '16.5' + 'zoom' => '16.5', + 'style' => 'bright' ), $atts, 'openfreemap'); - $lat = is_numeric($atts['lat']) ? (float) $atts['lat'] : 47.1092664; - $lon = is_numeric($atts['lon']) ? (float) $atts['lon'] : 12.3453356; - $zoom = is_numeric($atts['zoom']) ? (float) $atts['zoom'] : 16.5; + $lat = openfreemap_clamp_float($atts['lat'], 47.1092664, -90, 90); + $lon = openfreemap_clamp_float($atts['lon'], 12.3453356, -180, 180); + $zoom = openfreemap_clamp_float($atts['zoom'], 16.5, 0, 22); $color = sanitize_hex_color($atts['color']); if (!$color) { $color = '#ff0000'; } - $height = sanitize_text_field($atts['height']); + $height = openfreemap_sanitize_css_length($atts['height'], '450px'); + $popup_text = wp_kses_post($atts['popup_text']); + $style = openfreemap_sanitize_style($atts['style'], 'bright'); $map_id = wp_unique_id('openfreemap-'); ob_start(); @@ -43,8 +123,8 @@ function openfreemap_handler($atts) data-lon="" data-zoom="" data-color="" - data-popup="" - data-style="" + data-popup="" + data-style="" > post_content, 'openfreemap')) { - add_action('wp_enqueue_scripts', 'openfreemap_enqueue_shortcode_assets'); - break; // Only need to enqueue once + + // Skip entirely on singular posts/pages that have neither the shortcode + // in their content nor a shortcode block. Widgets, template parts, and + // secondary loops aren't covered by this check, so it's a partial + // optimization, not a guarantee. + if (is_singular()) { + $post = get_post(); + if ($post && !has_shortcode($post->post_content, 'openfreemap') && !has_block('core/shortcode', $post)) { + return; } } - return $posts; -} -add_filter('the_posts', 'openfreemap_detect_shortcode'); + $enqueued = true; -// Enqueue scripts and styles only if shortcode is used -function openfreemap_enqueue_shortcode_assets() -{ - $plugin_url = plugin_dir_url(__FILE__); - wp_enqueue_style( - 'openfreemap-style', - $plugin_url . 'vendor/maplibre-gl/maplibre-gl.css' - ); - $load_in_footer = false; + // The loader is a classic script: it conditionally dynamic-imports the ES + // module below and injects the stylesheet, so both the ~1 MB of MapLibre JS + // and its CSS are only fetched on pages that actually render a map. + // Detection happens in the DOM rather than in post content, which also + // covers widgets, blocks, template parts and secondary loops. The + // is_singular() check above is just a cheap best-effort early exit that + // skips even the tiny loader request on obviously map-free singular pages; + // it is not relied on for correctness. wp_enqueue_script( - 'openfreemap-script', - $plugin_url . 'assets/openfreemap.js', + 'openfreemap-loader', + plugin_dir_url(__FILE__) . 'assets/loader.js', array(), - null, - $load_in_footer + openfreemap_plugin_version(), + true ); - add_filter('script_loader_tag', function ($tag, $handle, $src) { - if ($handle === 'openfreemap-script') { - return ''; - } - return $tag; - }, 10, 3); -} -add_action('wp_enqueue_scripts', 'openfreemap_enqueue_shortcode_assets'); + $module_url = openfreemap_versioned_url('assets/openfreemap.js'); + $style_href = openfreemap_versioned_url('vendor/maplibre-gl/maplibre-gl.css'); + + // JSON_HEX_TAG keeps the values from terminating the inline script early. + wp_add_inline_script( + 'openfreemap-loader', + 'var openfreemapModuleUrl = ' . wp_json_encode($module_url, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) . ';' . + 'var openfreemapStyleUrl = ' . wp_json_encode($style_href, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) . ';', + 'before' + ); +} +add_action('wp_enqueue_scripts', 'openfreemap_enqueue_assets');