fix(articles): load Leaflet maps in article editor preview

The admin article preview iframe was missing Leaflet CSS/JS and had
scripts blocked by the sandbox policy, so map shortcodes rendered as
empty divs.

- Extract inline map script to static/js/article-maps.js (shared
  between article_detail.html and admin preview)
- Replace f-string preview doc with a proper Jinja template that
  includes Leaflet assets
- Add allow-scripts to iframe sandbox on both initial load and HTMX
  preview updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-05 22:03:50 +01:00
parent 2e42245ad5
commit 4ee80603ef
6 changed files with 139 additions and 116 deletions

View File

@@ -2736,13 +2736,13 @@ async def article_edit(article_id: int):
body = raw[m.end():].lstrip("\n") if m else raw body = raw[m.end():].lstrip("\n") if m else raw
body_html = mistune.html(body) if body else "" body_html = mistune.html(body) if body else ""
css_url = url_for("static", filename="css/output.css")
preview_doc = ( preview_doc = (
f"<!doctype html><html><head>" await render_template(
f"<link rel='stylesheet' href='{css_url}'>" "admin/partials/article_preview_doc.html", body_html=body_html
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>" )
f"</head><body><div class='article-body'>{body_html}</div></body></html>" if body_html
) if body_html else "" else ""
)
data = {**dict(article), "body": body} data = {**dict(article), "body": body}
return await render_template( return await render_template(
@@ -2764,13 +2764,13 @@ async def article_preview():
m = _FRONTMATTER_RE.match(body) m = _FRONTMATTER_RE.match(body)
body = body[m.end():].lstrip("\n") if m else body body = body[m.end():].lstrip("\n") if m else body
body_html = mistune.html(body) if body else "" body_html = mistune.html(body) if body else ""
css_url = url_for("static", filename="css/output.css")
preview_doc = ( preview_doc = (
f"<!doctype html><html><head>" await render_template(
f"<link rel='stylesheet' href='{css_url}'>" "admin/partials/article_preview_doc.html", body_html=body_html
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>" )
f"</head><body><div class='article-body'>{body_html}</div></body></html>" if body_html
) if body_html else "" else ""
)
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc) return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)

View File

@@ -384,7 +384,7 @@
<iframe <iframe
srcdoc="{{ preview_doc | e }}" srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;" style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin" sandbox="allow-same-origin allow-scripts"
title="Article preview" title="Article preview"
></iframe> ></iframe>
{% else %} {% else %}

View File

@@ -4,7 +4,7 @@
<iframe <iframe
srcdoc="{{ preview_doc | e }}" srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;" style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin" sandbox="allow-same-origin allow-scripts"
title="Article preview" title="Article preview"
></iframe> ></iframe>
{% else %} {% else %}

View File

@@ -0,0 +1,15 @@
{# Standalone HTML document used as iframe srcdoc for the article editor preview.
Includes Leaflet so map shortcodes render correctly. #}
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
<style>html,body{margin:0;padding:0}body{padding:2rem 2.5rem}</style>
</head>
<body>
<div class="article-body">{{ body_html | safe }}</div>
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
</body>
</html>

View File

@@ -60,106 +60,6 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script>
(function() { <script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
var countryMapEl = document.getElementById('country-map');
var cityMapEl = document.getElementById('city-map');
if (!countryMapEl && !cityMapEl) return;
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>';
function scoreColor(score) {
if (score >= 60) return '#16A34A';
if (score >= 30) return '#D97706';
return '#DC2626';
}
function makeIcon(size, color) {
var s = Math.round(size);
return L.divIcon({
className: '',
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.82;"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
function initCountryMap(el) {
var slug = el.dataset.countrySlug;
var map = L.map(el, {scrollWheelZoom: false});
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
var lang = document.documentElement.lang || 'en';
fetch('/api/markets/' + slug + '/cities.json')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.length) return;
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
var bounds = [];
data.forEach(function(c) {
if (!c.lat || !c.lon) return;
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
var color = scoreColor(c.market_score);
var pop = c.population >= 1000000
? (c.population / 1000000).toFixed(1) + 'M'
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
var tip = '<strong>' + c.city_name + '</strong><br>'
+ (c.padel_venue_count || 0) + ' venues'
+ (pop ? ' · ' + pop : '') + '<br>'
+ '<span style="color:' + color + ';font-weight:600;">Score ' + Math.round(c.market_score) + '/100</span>';
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; })
.addTo(map);
bounds.push([c.lat, c.lon]);
});
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
});
}
var VENUE_ICON = L.divIcon({
className: '',
html: '<div class="pn-venue"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
});
function initCityMap(el) {
var countrySlug = el.dataset.countrySlug;
var citySlug = el.dataset.citySlug;
var lat = parseFloat(el.dataset.lat);
var lon = parseFloat(el.dataset.lon);
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
.then(function(r) { return r.json(); })
.then(function(data) {
data.forEach(function(v) {
if (!v.lat || !v.lon) return;
var indoor = v.indoor_court_count || 0;
var outdoor = v.outdoor_court_count || 0;
var total = v.court_count || (indoor + outdoor);
var courtLine = total
? total + ' court' + (total > 1 ? 's' : '')
+ (indoor || outdoor
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
: '')
: '';
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
L.marker([v.lat, v.lon], { icon: VENUE_ICON })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
.addTo(map);
});
});
}
var script = document.createElement('script');
script.src = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
script.onload = function() {
if (countryMapEl) initCountryMap(countryMapEl);
if (cityMapEl) initCityMap(cityMapEl);
};
document.head.appendChild(script);
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,108 @@
/**
* Leaflet map initialisation for article pages (country + city maps).
*
* Looks for #country-map and #city-map elements. If neither exists, does nothing.
* Expects data-* attributes on the map elements and a global LEAFLET_JS_URL
* variable pointing to the Leaflet JS bundle.
*/
(function() {
var countryMapEl = document.getElementById('country-map');
var cityMapEl = document.getElementById('city-map');
if (!countryMapEl && !cityMapEl) return;
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>';
function scoreColor(score) {
if (score >= 60) return '#16A34A';
if (score >= 30) return '#D97706';
return '#DC2626';
}
function makeIcon(size, color) {
var s = Math.round(size);
return L.divIcon({
className: '',
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.82;"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
function initCountryMap(el) {
var slug = el.dataset.countrySlug;
var map = L.map(el, {scrollWheelZoom: false});
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
var lang = document.documentElement.lang || 'en';
fetch('/api/markets/' + slug + '/cities.json')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.length) return;
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
var bounds = [];
data.forEach(function(c) {
if (!c.lat || !c.lon) return;
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
var color = scoreColor(c.market_score);
var pop = c.population >= 1000000
? (c.population / 1000000).toFixed(1) + 'M'
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
var tip = '<strong>' + c.city_name + '</strong><br>'
+ (c.padel_venue_count || 0) + ' venues'
+ (pop ? ' · ' + pop : '') + '<br>'
+ '<span style="color:' + color + ';font-weight:600;">Score ' + Math.round(c.market_score) + '/100</span>';
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; })
.addTo(map);
bounds.push([c.lat, c.lon]);
});
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
});
}
var VENUE_ICON = L.divIcon({
className: '',
html: '<div class="pn-venue"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
});
function initCityMap(el) {
var countrySlug = el.dataset.countrySlug;
var citySlug = el.dataset.citySlug;
var lat = parseFloat(el.dataset.lat);
var lon = parseFloat(el.dataset.lon);
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
.then(function(r) { return r.json(); })
.then(function(data) {
data.forEach(function(v) {
if (!v.lat || !v.lon) return;
var indoor = v.indoor_court_count || 0;
var outdoor = v.outdoor_court_count || 0;
var total = v.court_count || (indoor + outdoor);
var courtLine = total
? total + ' court' + (total > 1 ? 's' : '')
+ (indoor || outdoor
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
: '')
: '';
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
L.marker([v.lat, v.lon], { icon: VENUE_ICON })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
.addTo(map);
});
});
}
/* Dynamically load Leaflet JS then init maps */
var script = document.createElement('script');
script.src = window.LEAFLET_JS_URL || '/static/vendor/leaflet/leaflet.min.js';
script.onload = function() {
if (countryMapEl) initCountryMap(countryMapEl);
if (cityMapEl) initCityMap(cityMapEl);
};
document.head.appendChild(script);
})();