feat(map): add open-source map block (MapLibre GL + OpenFreeMap)

Single-location + multi-marker, side-by-side and map-only layouts.
Zero-config — no API key. MapLibre is vendored via vite and loaded
conditionally when a page contains a map block. Provider resolution
is a single switchable function so other tile providers can be added
later without changing the block contract.
This commit is contained in:
George Cushen committed 2026-05-17 22:45:49 +01:00
1 parent f107371dc2
commit 4de0ad0af9
12 files changed
+518 -2

No files matched your search

+1
View File
@@ -0,0 +1 @@
.maplibregl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.maplibregl-canvas{left:0;position:absolute;top:0}.maplibregl-map:fullscreen{height:100%;width:100%}.maplibregl-ctrl-group button.maplibregl-ctrl-compass{touch-action:none}.maplibregl-canvas-container.maplibregl-interactive,.maplibregl-ctrl-group button.maplibregl-ctrl-compass{cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none}.maplibregl-canvas-container.maplibregl-interactive.maplibregl-track-pointer{cursor:pointer}.maplibregl-canvas-container.maplibregl-interactive:active,.maplibregl-ctrl-group button.maplibregl-ctrl-compass:active{cursor:grabbing}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-canvas-container.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:pinch-zoom}.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan,.maplibregl-canvas-container.maplibregl-touch-zoom-rotate.maplibregl-touch-drag-pan .maplibregl-canvas{touch-action:none}.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures,.maplibregl-canvas-container.maplibregl-touch-drag-pan.maplibregl-cooperative-gestures .maplibregl-canvas{touch-action:pan-x pan-y}.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top:0}.maplibregl-ctrl-bottom-left{bottom:0;left:0}.maplibregl-ctrl-bottom-right{bottom:0;right:0}.maplibregl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.maplibregl-ctrl-top-left .maplibregl-ctrl{float:left;margin:10px 0 0 10px}.maplibregl-ctrl-top-right .maplibregl-ctrl{float:right;margin:10px 10px 0 0}.maplibregl-ctrl-bottom-left .maplibregl-ctrl{float:left;margin:0 0 10px 10px}.maplibregl-ctrl-bottom-right .maplibregl-ctrl{float:right;margin:0 10px 10px 0}.maplibregl-ctrl-group{background:#fff;border-radius:4px}.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (forced-colors:active){.maplibregl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.maplibregl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;padding:0;width:29px}.maplibregl-ctrl-group button+button{border-top:1px solid #ddd}.maplibregl-ctrl button .maplibregl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (forced-colors:active){.maplibregl-ctrl-icon{background-color:transparent}.maplibregl-ctrl-group button+button{border-top:1px solid ButtonText}}.maplibregl-ctrl button::-moz-focus-inner{border:0;padding:0}.maplibregl-ctrl-attrib-button:focus,.maplibregl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl button:disabled{cursor:not-allowed}.maplibregl-ctrl button:disabled .maplibregl-ctrl-icon{opacity:.25}.maplibregl-ctrl button:not(:disabled):hover{background-color:rgb(0 0 0/5%)}.maplibregl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.maplibregl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.maplibregl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.maplibregl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.maplibregl-ctrl-group button:focus:only-child{border-radius:inherit}.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5'/%3E%3C/svg%3E")}@media (forced-colors:active){.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13z'/%3E%3C/svg%3E")}.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23fff' viewLine truncated
+59
View File
@@ -0,0 +1,59 @@
/**
* MapLibre GL JS
* @license 3-Clause BSD. Full text of license: https://github.com/maplibre/maplibre-gl-js/blob/v4.7.1/LICENSE.txt
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.maplibregl = factory());
})(this, (function () { 'use strict';
/* eslint-disable */
var maplibregl = {};
var modules = {};
function define(moduleName, _dependencies, moduleFactory) {
modules[moduleName] = moduleFactory;
// to get the list of modules see generated dist/maplibre-gl-dev.js file (look for `define(` calls)
if (moduleName !== 'index') {
return;
}
// we assume that when an index module is initializing then other modules are loaded already
var workerBundleString = 'var sharedModule = {}; (' + modules.shared + ')(sharedModule); (' + modules.worker + ')(sharedModule);'
var sharedModule = {};
// the order of arguments of a module factory depends on rollup (it decides who is whose dependency)
// to check the correct order, see dist/maplibre-gl-dev.js file (look for `define(` calls)
// we assume that for our 3 chunks it will generate 3 modules and their order is predefined like the following
modules.shared(sharedModule);
modules.index(maplibregl, sharedModule);
if (typeof window !== 'undefined') {
maplibregl.setWorkerUrl(window.URL.createObjectURL(new Blob([workerBundleString], { type: 'text/javascript' })));
}
return maplibregl;
};
define("shared",["exports"],(function(t){"use strict";function e(t,e,r,n){return new(r||(r=Promise))((function(i,s){function a(t){try{l(n.next(t));}catch(t){s(t);}}function o(t){try{l(n.throw(t));}catch(t){s(t);}}function l(t){var e;t.done?i(t.value):(e=t.value,e instanceof r?e:new r((function(t){t(e);}))).then(a,o);}l((n=n.apply(t,e||[])).next());}))}function r(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}"function"==typeof SuppressedError&&SuppressedError;var n=i;function i(t,e){this.x=t,this.y=e;}i.prototype={clone:function(){return new i(this.x,this.y)},add:function(t){return this.clone()._add(t)},sub:function(t){return this.clone()._sub(t)},multByPoint:function(t){return this.clone()._multByPoint(t)},divByPoint:function(t){return this.clone()._divByPoint(t)},mult:function(t){return this.clone()._mult(t)},div:function(t){return this.clone()._div(t)},rotate:function(t){return this.clone()._rotate(t)},rotateAround:function(t,e){return this.clone()._rotateAround(t,e)},matMult:function(t){return this.clone()._matMult(t)},unit:function(){return this.clone()._unit()},perp:function(){return this.clone()._perp()},round:function(){return this.clone()._round()},mag:function(){return Math.sqrt(this.x*this.x+this.y*this.y)},equals:function(t){return this.x===t.x&&this.y===t.y},dist:function(t){return Math.sqrt(this.distSqr(t))},distSqr:function(t){var e=t.x-this.x,r=t.y-this.y;return e*e+r*r},angle:function(){return Math.atan2(this.y,this.x)},angleTo:function(t){return Math.atan2(this.y-t.y,this.x-t.x)},angleWith:function(t){return this.angleWithSep(t.x,t.y)},angleWithSep:function(t,e){return Math.atan2(this.x*e-this.y*t,this.x*t+this.y*e)},_matMult:function(t){var e=t[2]*this.x+t[3]*this.y;return this.x=t[0]*this.x+t[1]*this.y,this.y=e,this},_add:function(t){return this.x+=t.x,this.y+=t.y,this},_sub:function(t){return this.x-=t.x,this.y-=t.y,this},_mult:function(t){return this.x*=t,this.y*=t,this},_div:function(t){return this.x/=t,this.y/=t,this},_multByPoint:function(t){return this.x*=t.x,this.y*=t.y,this},_divByPoint:function(t){return this.x/=t.x,this.y/=t.y,this},_unit:function(){return this._div(this.mag()),this},_perp:function(){var t=this.y;return this.y=this.x,this.x=-t,this},_rotate:function(t){var e=Math.cos(t),r=Math.sin(t),n=r*this.x+e*this.y;return this.x=e*this.x-r*this.y,this.y=n,this},_rotateAround:function(t,e){var r=Math.cos(t),n=Math.sin(t),i=e.y+n*(this.x-e.x)+r*(this.y-e.y);return this.x=e.x+r*(this.x-e.x)-n*(this.y-e.y),this.y=i,this},_round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this}},i.convert=function(t){return t instanceof i?t:Array.isArray(t)?new i(t[0],t[1]):t};var s=r(n),a=o;function o(t,e,r,n){this.cx=3*t,this.bx=3*(r-t)-this.cx,this.ax=1-this.cx-this.bx,this.cy=3*e,this.by=3*(n-e)-this.cy,this.ay=1-this.cy-this.by,this.p1x=t,this.p1y=e,this.p2x=r,this.p2y=n;}o.prototype={sampleCurveX:function(t){return ((this.ax*t+this.bx)*t+this.cx)*t},sampleCurveY:function(t){return ((this.ay*t+this.by)*t+this.cy)*t},sampleCurveDerivativeX:function(t){return (3*this.ax*t+2*this.bx)*t+this.cx},solveCurveX:function(t,e){if(void 0===e&&(e=1e-6),t<0)return 0;if(t>1)return 1;for(var r=t,n=0;n<8;n++){var i=this.sampleCurveX(r)-t;if(Math.abs(i)<e)return r;var s=this.sampleCurveDerivativeX(r);if(Math.abs(s)<1e-6)break;r-=i/s;}var a=0,o=1;for(r=t,n=0;n<20&&(i=this.sampleCurveX(r),!(Math.abs(i-t)<e));n++)t>i?a=r:o=r,r=.5*(o-a)+a;return r},solve:function(t,e){return this.sampleCurveY(this.solveCurveX(t,e))}};var l=r(a);let u,c;function h(){return null==u&&(u="undefined"!=typeof OffscreenCanvas&&new OffscreenCanvas(1,1).getContext("2d")&&"function"==typeof createImageBitmap),u}function p(){if(null==c&&(c=!1,h())){const t=5,e=new OffscreenCanvas(t,t).getContext("2d",{willReadFrequently:!0});if(e){for(let r=0;r<t*t;r++){const n=4*r;e.fillStyle=`rgb(${n},${n+1},${n+2})`,e.fillRect(r%t,Math.floor(r/t),1,1);}const r=e.getImageData(0,0,t,t).data;for(let e=0;e<t*t*4;e++)if(e%4!=3&&r[e]!==e){c=!0;break}}}return c||!1}function f(t,e,r,n){const i=new l(t,e,r,n);return t=>i.solve(t)}const d=f(.25,.1,.25,1);function y(t,e,r){return Math.min(r,Math.max(e,t))}function m(t,e,r){const n=r-e,i=((t-e)%n+n)%n+e;return i===e?r:i}function g(t,...e){for(const r of e)for(const e in r)t[e]=r[e];return t}let x=1;function v(t,e,r){const n={};for(const r in t)n[r]=e.call(this,t[r],r,t);return n}function b(t,e,r){const n={};for(const r in t)e.call(this,t[r],r,t)&&(n[r]=t[r]);return n}function w(t){return Array.isArray(t)?t.map(w):"object"==typeof t&&t?v(t,w):t}const _={};function A(t){_[t]||("undefined"!=typeof console&&console.warn(t),_[t]=!0);}function S(t,e,r){return (r.y-t.y)*(e.x-t.x)>(e.y-t.y)*(r.x-t.x)}function k(t){return "undefined"!=typeof WorkerGlobalScope&&void 0!==t&&t instanceof WorkerGlobalScope}let M=null;function I(t){return "undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap}const z="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCLine truncated
define("worker",["./shared"],(function(e){"use strict";class t{constructor(e){this.keyCache={},e&&this.replace(e);}replace(e){this._layerConfigs={},this._layers={},this.update(e,[]);}update(t,o){for(const o of t){this._layerConfigs[o.id]=o;const t=this._layers[o.id]=e.aA(o);t._featureFilter=e.a7(t.filter),this.keyCache[o.id]&&delete this.keyCache[o.id];}for(const e of o)delete this.keyCache[e],delete this._layerConfigs[e],delete this._layers[e];this.familiesBySource={};const i=e.bk(Object.values(this._layerConfigs),this.keyCache);for(const e of i){const t=e.map((e=>this._layers[e.id])),o=t[0];if("none"===o.visibility)continue;const i=o.source||"";let s=this.familiesBySource[i];s||(s=this.familiesBySource[i]={});const r=o.sourceLayer||"_geojsonTileLayer";let n=s[r];n||(n=s[r]=[]),n.push(t);}}}class o{constructor(t){const o={},i=[];for(const e in t){const s=t[e],r=o[e]={};for(const e in s){const t=s[+e];if(!t||0===t.bitmap.width||0===t.bitmap.height)continue;const o={x:0,y:0,w:t.bitmap.width+2,h:t.bitmap.height+2};i.push(o),r[e]={rect:o,metrics:t.metrics};}}const{w:s,h:r}=e.p(i),n=new e.o({width:s||1,height:r||1});for(const i in t){const s=t[i];for(const t in s){const r=s[+t];if(!r||0===r.bitmap.width||0===r.bitmap.height)continue;const a=o[i][t].rect;e.o.copy(r.bitmap,n,{x:0,y:0},{x:a.x+1,y:a.y+1},r.bitmap);}}this.image=n,this.positions=o;}}e.bl("GlyphAtlas",o);class i{constructor(t){this.tileID=new e.S(t.tileID.overscaledZ,t.tileID.wrap,t.tileID.canonical.z,t.tileID.canonical.x,t.tileID.canonical.y),this.uid=t.uid,this.zoom=t.zoom,this.pixelRatio=t.pixelRatio,this.tileSize=t.tileSize,this.source=t.source,this.overscaling=this.tileID.overscaleFactor(),this.showCollisionBoxes=t.showCollisionBoxes,this.collectResourceTiming=!!t.collectResourceTiming,this.returnDependencies=!!t.returnDependencies,this.promoteId=t.promoteId,this.inFlightDependencies=[];}parse(t,i,r,n){return e._(this,void 0,void 0,(function*(){this.status="parsing",this.data=t,this.collisionBoxArray=new e.a5;const a=new e.bm(Object.keys(t.layers).sort()),l=new e.bn(this.tileID,this.promoteId);l.bucketLayerIDs=[];const c={},u={featureIndex:l,iconDependencies:{},patternDependencies:{},glyphDependencies:{},availableImages:r},h=i.familiesBySource[this.source];for(const o in h){const i=t.layers[o];if(!i)continue;1===i.version&&e.w(`Vector tile source "${this.source}" layer "${o}" does not use vector tile spec v2 and therefore may have some rendering errors.`);const n=a.encode(o),d=[];for(let e=0;e<i.length;e++){const t=i.feature(e),s=l.getId(t,o);d.push({feature:t,id:s,index:e,sourceLayerIndex:n});}for(const t of h[o]){const o=t[0];o.source!==this.source&&e.w(`layer.source = ${o.source} does not equal this.source = ${this.source}`),o.minzoom&&this.zoom<Math.floor(o.minzoom)||o.maxzoom&&this.zoom>=o.maxzoom||"none"!==o.visibility&&(s(t,this.zoom,r),(c[o.id]=o.createBucket({index:l.bucketLayerIDs.length,layers:t,zoom:this.zoom,pixelRatio:this.pixelRatio,overscaling:this.overscaling,collisionBoxArray:this.collisionBoxArray,sourceLayerIndex:n,sourceID:this.source})).populate(d,u,this.tileID.canonical),l.bucketLayerIDs.push(t.map((e=>e.id))));}}const d=e.aF(u.glyphDependencies,(e=>Object.keys(e).map(Number)));this.inFlightDependencies.forEach((e=>null==e?void 0:e.abort())),this.inFlightDependencies=[];let f=Promise.resolve({});if(Object.keys(d).length){const e=new AbortController;this.inFlightDependencies.push(e),f=n.sendAsync({type:"GG",data:{stacks:d,source:this.source,tileID:this.tileID,type:"glyphs"}},e);}const g=Object.keys(u.iconDependencies);let p=Promise.resolve({});if(g.length){const e=new AbortController;this.inFlightDependencies.push(e),p=n.sendAsync({type:"GI",data:{icons:g,source:this.source,tileID:this.tileID,type:"icons"}},e);}const m=Object.keys(u.patternDependencies);let y=Promise.resolve({});if(m.length){const e=new AbortController;this.inFlightDependencies.push(e),y=n.sendAsync({type:"GI",data:{icons:m,source:this.source,tileID:this.tileID,type:"patterns"}},e);}const[v,w,x]=yield Promise.all([f,p,y]),b=new o(v),S=new e.bo(w,x);for(const t in c){const o=c[t];o instanceof e.a6?(s(o.layers,this.zoom,r),e.bp({bucket:o,glyphMap:v,glyphPositions:b.positions,imageMap:w,imagePositions:S.iconPositions,showCollisionBoxes:this.showCollisionBoxes,canonical:this.tileID.canonical})):o.hasPattern&&(o instanceof e.bq||o instanceof e.br||o instanceof e.bs)&&(s(o.layers,this.zoom,r),o.addFeatures(u,this.tileID.canonical,S.patternPositions));}return this.status="done",{buckets:Object.values(c).filter((e=>!e.isEmpty())),featureIndex:l,collisionBoxArray:this.collisionBoxArray,glyphAtlasImage:b.image,imageAtlas:S,glyphMap:this.returnDependencies?v:null,iconMap:this.returnDependencies?w:null,glyphPositions:this.returnDependencies?b.positions:null}}))}}function s(t,o,i){const s=new e.z(o);for(const e of t)e.recalculate(s,i);}class r{constructor(e,t,o){this.actor=e,this.layerIndex=t,this.availableImages=o,this.fetching={},this.loading={},this.loaded={};}loadVeLine truncated
define("index",["exports","./shared"],(function(t,e){"use strict";var i="4.7.1";let a,s;const o={now:"undefined"!=typeof performance&&performance&&performance.now?performance.now.bind(performance):Date.now.bind(Date),frameAsync:t=>new Promise(((i,a)=>{const s=requestAnimationFrame(i);t.signal.addEventListener("abort",(()=>{cancelAnimationFrame(s),a(e.c());}));})),getImageData(t,e=0){return this.getImageCanvasContext(t).getImageData(-e,-e,t.width+2*e,t.height+2*e)},getImageCanvasContext(t){const e=window.document.createElement("canvas"),i=e.getContext("2d",{willReadFrequently:!0});if(!i)throw new Error("failed to create canvas 2d context");return e.width=t.width,e.height=t.height,i.drawImage(t,0,0,t.width,t.height),i},resolveURL:t=>(a||(a=document.createElement("a")),a.href=t,a.href),hardwareConcurrency:"undefined"!=typeof navigator&&navigator.hardwareConcurrency||4,get prefersReducedMotion(){return !!matchMedia&&(null==s&&(s=matchMedia("(prefers-reduced-motion: reduce)")),s.matches)}};class r{static testProp(t){if(!r.docStyle)return t[0];for(let e=0;e<t.length;e++)if(t[e]in r.docStyle)return t[e];return t[0]}static create(t,e,i){const a=window.document.createElement(t);return void 0!==e&&(a.className=e),i&&i.appendChild(a),a}static createNS(t,e){return window.document.createElementNS(t,e)}static disableDrag(){r.docStyle&&r.selectProp&&(r.userSelect=r.docStyle[r.selectProp],r.docStyle[r.selectProp]="none");}static enableDrag(){r.docStyle&&r.selectProp&&(r.docStyle[r.selectProp]=r.userSelect);}static setTransform(t,e){t.style[r.transformProp]=e;}static addEventListener(t,e,i,a={}){t.addEventListener(e,i,"passive"in a?a:a.capture);}static removeEventListener(t,e,i,a={}){t.removeEventListener(e,i,"passive"in a?a:a.capture);}static suppressClickInternal(t){t.preventDefault(),t.stopPropagation(),window.removeEventListener("click",r.suppressClickInternal,!0);}static suppressClick(){window.addEventListener("click",r.suppressClickInternal,!0),window.setTimeout((()=>{window.removeEventListener("click",r.suppressClickInternal,!0);}),0);}static getScale(t){const e=t.getBoundingClientRect();return {x:e.width/t.offsetWidth||1,y:e.height/t.offsetHeight||1,boundingClientRect:e}}static getPoint(t,i,a){const s=i.boundingClientRect;return new e.P((a.clientX-s.left)/i.x-t.clientLeft,(a.clientY-s.top)/i.y-t.clientTop)}static mousePos(t,e){const i=r.getScale(t);return r.getPoint(t,i,e)}static touchPos(t,e){const i=[],a=r.getScale(t);for(let s=0;s<e.length;s++)i.push(r.getPoint(t,a,e[s]));return i}static mouseButton(t){return t.button}static remove(t){t.parentNode&&t.parentNode.removeChild(t);}}r.docStyle="undefined"!=typeof window&&window.document&&window.document.documentElement.style,r.selectProp=r.testProp(["userSelect","MozUserSelect","WebkitUserSelect","msUserSelect"]),r.transformProp=r.testProp(["transform","WebkitTransform"]);const n={supported:!1,testSupport:function(t){!c&&h&&(u?d(t):l=t);}};let l,h,c=!1,u=!1;function d(t){const e=t.createTexture();t.bindTexture(t.TEXTURE_2D,e);try{if(t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,h),t.isContextLost())return;n.supported=!0;}catch(t){}t.deleteTexture(e),c=!0;}var _;"undefined"!=typeof document&&(h=document.createElement("img"),h.onload=()=>{l&&d(l),l=null,u=!0;},h.onerror=()=>{c=!0,l=null;},h.src="data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAQAAAAfQ//73v/+BiOh/AAA="),function(t){let i,a,s,o;t.resetRequestQueue=()=>{i=[],a=0,s=0,o={};},t.addThrottleControl=t=>{const e=s++;return o[e]=t,e},t.removeThrottleControl=t=>{delete o[t],l();},t.getImage=(t,a,s=!0)=>new Promise(((o,r)=>{n.supported&&(t.headers||(t.headers={}),t.headers.accept="image/webp,*/*"),e.e(t,{type:"image"}),i.push({abortController:a,requestParameters:t,supportImageRefresh:s,state:"queued",onError:t=>{r(t);},onSuccess:t=>{o(t);}}),l();}));const r=t=>e._(this,void 0,void 0,(function*(){t.state="running";const{requestParameters:i,supportImageRefresh:s,onError:o,onSuccess:r,abortController:n}=t,c=!1===s&&!e.i(self)&&!e.g(i.url)&&(!i.headers||Object.keys(i.headers).reduce(((t,e)=>t&&"accept"===e),!0));a++;const u=c?h(i,n):e.m(i,n);try{const i=yield u;delete t.abortController,t.state="completed",i.data instanceof HTMLImageElement||e.b(i.data)?r(i):i.data&&r({data:yield(d=i.data,"function"==typeof createImageBitmap?e.d(d):e.f(d)),cacheControl:i.cacheControl,expires:i.expires});}catch(e){delete t.abortController,o(e);}finally{a--,l();}var d;})),l=()=>{const t=(()=>{for(const t of Object.keys(o))if(o[t]())return !0;return !1})()?e.a.MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME:e.a.MAX_PARALLEL_IMAGE_REQUESTS;for(let e=a;e<t&&i.length>0;e++){const t=i.shift();t.abortController.signal.aborted?e--:r(t);}},h=(t,i)=>new Promise(((a,s)=>{const o=new Image,r=t.url,n=t.credentials;n&&"include"===n?o.crossOrigin="use-credentials":(n&&"same-origin"===n||!e.s(r))&&(o.crossOrigin="anonymous"),i.signal.addEventListener("abort",(()=>{o.src="",s(e.c());})),o.fetchPriority="high",o.onload=()=>{o.onerror=o.onload=null,a(Line truncated
//
// Our custom intro provides a specialized "define()" function, called by the
// AMD modules below, that sets up the worker blob URL and then executes the
// main module, storing its exported value as 'maplibregl'
var maplibregl$1 = maplibregl;
return maplibregl$1;
}));
//# sourceMappingURL=maplibre-gl.js.map
+15
View File
@@ -0,0 +1,15 @@
import {render} from "preact";
import {MapBlock} from "./component.jsx";
function renderMapBlocks() {
const blocks = document.querySelectorAll('[data-block-type="map"]');
blocks.forEach((block) => {
const propsData = block.dataset.props;
if (propsData) {
const props = JSON.parse(propsData);
render(<MapBlock {...props} />, block);
}
});
}
renderMapBlocks();
+313
View File
@@ -0,0 +1,313 @@
import {useEffect, useRef} from "preact/hooks";
/*
* Map block — open-source interactive maps for HugoBlox.
*
* Default provider stack: MapLibre GL (BSD) + OpenFreeMap (free OSM-based vector
* tiles, no API key, no rate limit). Provider resolution is intentionally a
* single switchable function so additional providers (MapTiler, Mapbox, Google,
* Stadia) can be added later without changing the component contract.
*/
const OPENFREEMAP_STYLE_URLS = {
streets: "https://tiles.openfreemap.org/styles/liberty",
light: "https://tiles.openfreemap.org/styles/positron",
dark: "https://tiles.openfreemap.org/styles/dark",
bright: "https://tiles.openfreemap.org/styles/bright",
};
function resolveStyleUrl(provider, styleName) {
const name = (provider && provider.name) || "openfreemap";
const style = styleName || (provider && provider.style) || "streets";
switch (name) {
case "openfreemap":
return OPENFREEMAP_STYLE_URLS[style] || OPENFREEMAP_STYLE_URLS.streets;
default:
return OPENFREEMAP_STYLE_URLS.streets;
}
}
const HEIGHT_CLASS = {
sm: "h-72 md:h-80",
md: "h-96 md:h-[28rem]",
lg: "h-[28rem] md:h-[36rem]",
full: "h-[calc(100vh-4rem)]",
};
function heightClass(h) {
return HEIGHT_CLASS[h] || HEIGHT_CLASS.md;
}
function renderInline(text) {
if (!text) return "";
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>");
}
function escapeHtml(text) {
return String(text ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function directionsUrl(location, override) {
if (override) return override;
if (location?.lat == null || location?.lng == null) return null;
return `https://www.openstreetmap.org/directions?to=${location.lat}%2C${location.lng}`;
}
function MapCanvas({location, zoom, markers, provider, style, interactive, attribution, design}) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
if (typeof window === "undefined") return;
let map;
let resizeObserver;
let cancelled = false;
let attempts = 0;
function init() {
if (cancelled) return;
const lib = window.maplibregl;
if (!lib) {
if (attempts++ < 60) {
setTimeout(init, 50);
} else {
console.warn("[hugoblox/map] MapLibre GL did not load in time");
}
return;
}
const pins =
markers && markers.length > 0
? markers.filter((m) => m.lat != null && m.lng != null)
: location?.lat != null && location?.lng != null
? [{lat: location.lat, lng: location.lng, title: location.address}]
: [];
const center =
pins.length > 0
? [pins[0].lng, pins[0].lat]
: location?.lat != null && location?.lng != null
? [location.lng, location.lat]
: [0, 0];
map = new lib.Map({
container: ref.current,
style: resolveStyleUrl(provider, style),
center,
zoom: zoom || 14,
attributionControl: attribution === false ? false : {compact: true},
interactive: interactive !== false,
cooperativeGestures: design?.cooperative_gestures === true,
});
if (interactive !== false) {
map.addControl(new lib.NavigationControl({showCompass: false}), "top-right");
}
if (pins.length > 1) {
const bounds = new lib.LngLatBounds();
pins.forEach((m) => bounds.extend([m.lng, m.lat]));
map.once("load", () => map.fitBounds(bounds, {padding: 60, maxZoom: 15, duration: 0}));
}
pins.forEach((m) => {
const marker = new lib.Marker({color: "var(--map-pin-color, #6366f1)"}).setLngLat([m.lng, m.lat]);
const popupHtml = [
m.title ? `<strong>${escapeHtml(m.title)}</strong>` : "",
m.description ? `<div class="mt-1 text-sm">${escapeHtml(m.description)}</div>` : "",
m.url ? `<div class="mt-2"><a class="text-primary-600 underline" href="${escapeHtml(m.url)}">View details →</a></div>` : "",
]
.filter(Boolean)
.join("");
if (popupHtml) {
marker.setPopup(new lib.Popup({offset: 24, closeButton: true}).setHTML(popupHtml));
}
marker.addTo(map);
});
// MapLibre measures the container at construction. In grid/flex layouts the
// resolved size may not be final on the first frame, so the GL canvas ends
// up smaller than the visible container and tiles paint into nothing.
// Observe size changes and forward to map.resize().
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
if (map) map.resize();
});
resizeObserver.observe(ref.current);
}
}
init();
return () => {
cancelled = true;
if (resizeObserver) resizeObserver.disconnect();
if (map) map.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location?.lat, location?.lng, zoom, JSON.stringify(markers), style, interactive, attribution]);
return <div ref={ref} class="w-full h-full bg-gray-100 dark:bg-gray-800" aria-label="Map" role="img" />;
}
function AddressCard({content, location, ctaUrl, dark}) {
const {title, subtitle, cta = {}} = content;
const addressLines = (location?.address || "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const hasItems = addressLines.length > 0 || cta.phone || cta.email;
return (
<div class={`flex flex-col justify-center p-6 sm:p-8 ${dark ? "bg-gray-900 text-white" : "bg-white dark:bg-gray-800"}`}>
{title && (
<h2
class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white tracking-tight mb-2"
dangerouslySetInnerHTML={{__html: renderInline(title)}}
/>
)}
{subtitle && (
<p class="text-base text-gray-600 dark:text-gray-400 mb-6" dangerouslySetInnerHTML={{__html: renderInline(subtitle)}} />
)}
{hasItems && (
<ul class="space-y-3 mb-6">
{addressLines.length > 0 && (
<li class="flex items-start gap-3 text-sm leading-6 text-gray-700 dark:text-gray-300">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-primary-600 dark:text-primary-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003zM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5z"
clip-rule="evenodd"
/>
</svg>
<span>
{addressLines.map((line, i) => (
<span key={i} class="block">
{line}
</span>
))}
</span>
</li>
)}
{cta.phone && (
<li class="flex items-start gap-3 text-sm leading-6 text-gray-700 dark:text-gray-300">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-primary-600 dark:text-primary-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" />
</svg>
<a href={`tel:${cta.phone}`} class="hover:text-primary-600 dark:hover:text-primary-400">
{cta.phone}
</a>
</li>
)}
{cta.email && (
<li class="flex items-start gap-3 text-sm leading-6 text-gray-700 dark:text-gray-300">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-primary-600 dark:text-primary-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
<a href={`mailto:${cta.email}`} class="hover:text-primary-600 dark:hover:text-primary-400 break-all">
{cta.email}
</a>
</li>
)}
</ul>
)}
{ctaUrl && (
<a
href={ctaUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-2 self-start rounded-xl bg-gradient-to-r from-primary-600 to-secondary-600 px-5 py-3 text-sm font-semibold text-white shadow-sm hover:from-primary-700 hover:to-secondary-700 hover:shadow-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
{(content.cta && content.cta.directions && content.cta.directions.text) || "Get directions"}
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
clip-rule="evenodd"
/>
</svg>
</a>
)}
</div>
);
}
export const MapBlock = ({content = {}, design = {}, provider}) => {
const {location = {}, zoom, markers, cta = {}} = content;
const layout = design.layout || "side-by-side";
const heightCls = heightClass(design.height);
const radius = design.border === "none" ? "" : "rounded-3xl";
const borderShadow = design.border === "none" ? "" : "ring-1 ring-gray-200 dark:ring-gray-700 shadow-lg";
const ctaUrl = directionsUrl(location, cta.directions?.url);
if (layout === "map-only") {
return (
<div class="py-12 sm:py-16 px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto">
{(content.title || content.subtitle) && (
<div class="text-center max-w-3xl mx-auto mb-8">
{content.title && (
<h2
class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white tracking-tight mb-3"
dangerouslySetInnerHTML={{__html: renderInline(content.title)}}
/>
)}
{content.subtitle && (
<p class="text-lg text-gray-600 dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderInline(content.subtitle)}} />
)}
</div>
)}
<div class={`relative overflow-hidden ${heightCls} ${radius} ${borderShadow}`}>
<MapCanvas
location={location}
zoom={zoom}
markers={markers}
provider={provider}
style={design.style}
interactive={design.interactive}
attribution={design.attribution}
design={design}
/>
</div>
</div>
</div>
);
}
// side-by-side (default)
return (
<div class="py-12 sm:py-16 px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto">
<div class={`grid grid-cols-1 lg:grid-cols-2 overflow-hidden ${radius} ${borderShadow}`}>
<AddressCard content={content} location={location} ctaUrl={ctaUrl} />
<div class={`relative ${heightCls} order-first lg:order-last`}>
<MapCanvas
location={location}
zoom={zoom}
markers={markers}
provider={provider}
style={design.style}
interactive={design.interactive}
attribution={design.attribution}
design={design}
/>
</div>
</div>
</div>
</div>
);
};
+18
View File
@@ -0,0 +1,18 @@
{
"id": "map",
"name": "Map",
"version": "1.0.0",
"license": "MIT",
"category": "location",
"intent": ["inform", "navigate"],
"verticals": ["academic-cv", "agency", "event", "startup", "local-business", "podcaster"],
"tags": ["map", "location", "address", "directions", "openstreetmap", "openfreemap", "maplibre"],
"description": "Interactive open-source map showing a single location or a set of markers. Powered by MapLibre GL and OpenFreeMap — zero-config, no API key, fully open. Renders an address card alongside the map by default, with optional CTAs for directions, phone, and email.",
"author": "Hugo Blox",
"homepage": "https://hugoblox.com/blocks/",
"repository": "https://github.com/HugoBlox/kit",
"keywords": ["hugo", "map", "maplibre", "openfreemap", "openstreetmap", "location", "directions"],
"dependencies": {
"maplibre-gl": "^4.7.1"
}
}
+40
View File
@@ -0,0 +1,40 @@
/* Map block — MapLibre popup theming */
.maplibregl-popup-content {
border-radius: 0.75rem;
padding: 0.75rem 1rem;
box-shadow: 0 4px 20px rgb(0 0 0 / 0.12);
color: #111827;
font-size: 0.875rem;
line-height: 1.5;
}
.maplibregl-popup-content a {
color: var(--color-primary-600, #4f46e5);
text-decoration: underline;
}
.maplibregl-popup-close-button {
padding: 0 0.375rem;
font-size: 1rem;
color: #6b7280;
}
.dark .maplibregl-popup-content {
background: #1f2937;
color: #f3f4f6;
}
.dark .maplibregl-popup-close-button {
color: #d1d5db;
}
/* The popup tip is rendered as a triangle via a single non-transparent border
side; MapLibre sets each anchor variant individually. Overriding all four
sides is safe — the other three remain border:none. */
.dark .maplibregl-popup-tip {
border-top-color: #1f2937 !important;
border-bottom-color: #1f2937 !important;
border-left-color: #1f2937 !important;
border-right-color: #1f2937 !important;
}
+5
View File
@@ -107,6 +107,11 @@
{{ end }}
{{ end }}
{{/* Detect map blocks so libraries.html can load MapLibre GL */}}
{{ if eq $block_type "map" }}
{{ $page.Store.Set "has_map" true }}
{{ end }}
{{/* Detect FAQ blocks for JSON-LD structured data (avoid race conditions) */}}
{{ if eq $block_type "faq" }}
{{ if $block.content.items }}
@@ -1,3 +1,11 @@
{{/* MapLibre GL — load before Preact block scripts so window.maplibregl is available when the map block hydrates */}}
{{ if .Page.Store.Get "has_map" }}
{{ $maplibre_css := resources.Get "dist/lib/maplibre/maplibre-gl.css" | resources.Fingerprint "sha256" }}
<link rel="stylesheet" href="{{ $maplibre_css.RelPermalink }}" integrity="{{ $maplibre_css.Data.Integrity }}" />
{{ $maplibre_js := resources.Get "dist/lib/maplibre/maplibre-gl.js" | resources.Fingerprint "sha256" }}
<script defer src="{{ $maplibre_js.RelPermalink }}" integrity="{{ $maplibre_js.Data.Integrity }}"></script>
{{ end }}
{{/* Load Preact for component-based blocks */}}
{{/* Check if any blocks on this page use Preact (set by init.html) */}}
{{ if .Page.Store.Get "needs_preact" }}
+2 -1
View File
@@ -16,6 +16,7 @@
"dependencies": {
"alpinejs": "^3.15.0",
"katex": "^0.16.22",
"maplibre-gl": "^4.7.1",
"markmap-autoloader": "^0.18.12",
"medium-zoom": "^1.0.8",
"mermaid": "^11.12.0",
@@ -40,7 +41,7 @@
"update:starters": "poetry run python ./scripts/update.py",
"vendor:libs": "vite build --config vite.config.js",
"vendor:libs:watch": "vite build --config vite.config.js --watch",
"vendor:update-and-build": "pnpm up katex mermaid markmap-autoloader alpinejs plotly.js preact --latest && pnpm vendor:libs",
"vendor:update-and-build": "pnpm up katex mermaid markmap-autoloader alpinejs plotly.js preact maplibre-gl --latest && pnpm vendor:libs",
"test:releaser": "poetry run python scripts/test_release_modules.py",
"check:template-deps": "node scripts/check-template-deps.mjs"
},
+3
View File
@@ -14,6 +14,9 @@ importers:
katex:
specifier: ^0.16.22
version: 0.16.22
maplibre-gl:
specifier: ^4.7.1
version: 4.7.1
markmap-autoloader:
specifier: ^0.18.12
version: 0.18.12(markmap-common@0.18.9)
+43
View File
@@ -47,6 +47,49 @@ sections:
design:
columns: "3"
- block: map
content:
title: "Visit the Lab"
subtitle: "We're based on the Stanford campus — drop in any time during office hours."
location:
address: |
353 Jane Stanford Way
Stanford, CA 94305
United States
lat: 37.4275
lng: -122.1697
zoom: 15
cta:
directions:
text: "Get directions"
phone: "+1-650-555-0142"
email: "hello@example.com"
design:
layout: side-by-side
height: md
style: streets
- block: map
content:
title: "Where to find us"
markers:
- lat: 37.4275
lng: -122.1697
title: "Stanford AI Lab"
description: "Main campus building"
- lat: 37.4419
lng: -122.1430
title: "Palo Alto Office"
description: "Downtown Palo Alto"
- lat: 37.7749
lng: -122.4194
title: "San Francisco HQ"
description: "Mission District"
design:
layout: map-only
height: lg
style: light
- block: cta-card
content:
title: "Ready to get started?"
+11 -1
View File
@@ -26,7 +26,7 @@ export default defineConfig({
},
rollupOptions: {
// Don't bundle these libraries, just copy them
external: ["mermaid", "plotly.js", "katex", "markmap-autoloader", "alpinejs"],
external: ["mermaid", "plotly.js", "katex", "markmap-autoloader", "alpinejs", "maplibre-gl"],
},
},
@@ -75,6 +75,16 @@ export default defineConfig({
dest: "alpinejs/",
},
// MapLibre GL - Open-source interactive vector maps (map block)
{
src: "node_modules/maplibre-gl/dist/maplibre-gl.js",
dest: "maplibre/",
},
{
src: "node_modules/maplibre-gl/dist/maplibre-gl.css",
dest: "maplibre/",
},
// Preact for interactive components
{
src: "node_modules/preact/dist/preact.min.js",