Publish Maps from Obsidian Map View to Hugo
I sorted how to publish my Obsidian notes with an embedded Obsidian Map View map to a Hugo website, using Obsidian as my site CMS. Here’s an example on my travel blog.
Requirements
- Environments: locally, I use Obsidian as my text editor and CMS, with a local dev environment to view work in progress; production deployment using Hugo as a static site generator, built and hosted at Netlify using Netlify’s Build and Deploy pipelines.
- Use Obsidian Map View with inline location URLs for location data, including inline tags for categorization and setting marker types.
- Publish process and build cycle must be uni-directional, from Obsidian markdown and Map View extension content to the Hugo website. Source markdown in Obsidian must never be altered.
- Website mapping service must be open source and ideally free; compatible with all major desktop and mobile browsers.
Overview
- Have an Obsidian markdown page with Map View inline location URLs and a rendered map.
- Build a map.geojason support file by parsing Map View’s geocoded links, using Python.
- Replace Map View’s markdown code block with appropriate map rendering code in HTML/JS, using Hugo code block overrides.
- Embed map Javascript into page headers conditionally, for posts that need it.
- Hide Map View tags in the generated web pages, with Javascript.
Details
Environment setup
On the Obsidian side, install and configure Map View. Create a note with inline location URLs, inline tags, and a generated map. Happily, this can be anywhere in the text you like.
On the Hugo website side, I decided on Leaflet, an open-source Javascript library for mobile-friendly interactive maps. After much trial and error, I chose CARTO basemap styles for a presentation layer, based on OpenMapTiles, which uses OpenStreetMap for its tile layer.
My major insight was realizing I could drive a Leaflet map with a map.geojson file. So the major lift is to parse the markdown, find the geoencoded links, and built it. I chose Python to do this.
Build a map.geojason in Python
#!/usr/bin/env python3
import os
import re
import json
# Regex pattern to match geo-location markdown links with optional tags
# Format: [name](geo:lat,lon) tag:tagname
GEO_LINK_RE = re.compile(
r'\[([^\]]+)\]\(geo:([0-9\.\-]+),([0-9\.\-]+)\)(?:\s+tag:([a-zA-Z0-9_-]+))?', re.IGNORECASE
)
def extract_features_from_md(md_path):
"""Extract geographic features from markdown file and convert to GeoJSON format"""
features = []
with open(md_path, encoding='utf-8') as f:
for line in f:
# Find all geo-location links in each line
for match in GEO_LINK_RE.finditer(line):
name = match.group(1).strip() # Extract location name from brackets
lat = float(match.group(2)) # Extract latitude coordinate
lon = float(match.group(3)) # Extract longitude coordinate
tag = match.group(4).strip() if match.group(4) else "" # Extract optional tag
# Create GeoJSON feature object for each location
features.append({
"type": "Feature",
"properties": {
"name": name,
"iconType": tag # Use tag as iconType for map visualization
},
"geometry": {
"type": "Point",
"coordinates": [lon, lat] # GeoJSON uses [longitude, latitude] order
}
})
return features
def main():
"""Process all markdown files in blog directory and generate GeoJSON maps"""
# Set the root directory to process your markdown content folder
content_root = os.path.join(os.path.dirname(__file__), "YOUR-FOLDER-HERE")
# Walk through all directories and subdirectories
for dirpath, _, filenames in os.walk(content_root):
all_features = []
# Process each markdown file in current directory
for fname in filenames:
if fname.endswith('.md'):
md_path = os.path.join(dirpath, fname)
features = extract_features_from_md(md_path) # Extract geo features
all_features.extend(features) # Add to collection
# If any geo features found, create GeoJSON file
if all_features:
# Create complete GeoJSON structure
geojson = {
"type": "FeatureCollection",
"features": all_features
}
# Write GeoJSON file to same directory as markdown files
geojson_path = os.path.join(dirpath, "map.geojson")
with open(geojson_path, "w", encoding="utf-8") as f:
json.dump(geojson, f, indent=2) # Pretty-print with indentation
print(f"Wrote {len(all_features)} features to {geojson_path}")
# Run main function if script executed directly
if __name__ == "__main__":
main()This will parse through the content directory and generate a map.geojson file for any file that has geo-located links. It will save the map.geojson in the same directory. I use Hugo Page bundles to associate resources to a page as leafs. I save this as build_geojson.py in my Hugo root directory.
Render Map View’s markdown code block
Map View embeds its map in markdown using fenced code blocks, something like this:
```mapview
{"name":"Default","mapZoom":8,"centerLat":64.9841821,"centerLng":-18.1059013,"query":"","chosenMapSource":0,"autoFit":false,"lock":false,"showLinks":false,"linkColor":"red","markerLabels":"off","embeddedHeight":300}
```In Hugo, we’ll need to replace this with the appropriate HTML to render the Leaflet map instead. Happily, Hugo offers code block render hooks which allow us to override the mapview code block.
In _/layouts/_default/markup/render-codeblock-mapview.html, we’ll drop everything needed to render the map — loading the generated map.geojson for data:
<div id="map"></div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<!-- Leaflet.ExtraMarkers JS -->
<script src="https://unpkg.com/leaflet-extra-markers@1.2.1/dist/js/leaflet.extra-markers.min.js"></script>
<script>
// Initialize the map WITHOUT setView - will auto-fit to data later
var map = L.map('map');
// Add CartoDB Positron tiles for a clean, minimal map appearance
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd', // Use subdomains a,b,c,d for load balancing
maxZoom: 19
}).addTo(map);
// Function to return custom icons based on location type/tag
function getIcon(iconType) {
if (iconType === 'city') {
// Blue city icon for urban locations
return L.ExtraMarkers.icon({
icon: 'fa-city',
markerColor: 'blue',
shape: 'circle',
prefix: 'fa'
});
} else if (iconType === 'country') {
// Red flag icon for country-level locations
return L.ExtraMarkers.icon({
icon: 'fa-flag',
markerColor: 'red',
shape: 'circle',
prefix: 'fa'
});
} else if (iconType === 'swim') {
// Green water icon for swimming locations
return L.ExtraMarkers.icon({
icon: 'fa-water-ladder',
markerColor: 'green',
shape: 'circle',
prefix: 'fa'
});
} else if (iconType === 'hiking') {
// Orange hiking icon for trail locations
return L.ExtraMarkers.icon({
icon: 'fa-hiking',
markerColor: 'orange',
shape: 'circle',
prefix: 'fa'
});
} else {
// Default blue marker for unspecified location types
return L.ExtraMarkers.icon({
icon: 'fa-map-marker-alt',
markerColor: 'blue',
shape: 'circle',
prefix: 'fa'
});
}
}
// Load GeoJSON data from local file and add markers to map
fetch('map.geojson')
.then(response => response.json()) // Parse JSON response
.then(data => {
// Create GeoJSON layer with custom marker styling
const geoLayer = L.geoJSON(data, {
pointToLayer: function(feature, latlng) {
let icon;
try {
// Get appropriate icon based on feature's iconType property
icon = getIcon(feature.properties.iconType);
} catch (e) {
// Fallback to default icon if error occurs
icon = getIcon();
}
// Create marker with custom icon and popup showing location name
return L.marker(latlng, {
icon: icon
}).bindPopup(feature.properties.name || '');
}
}).addTo(map);
// Auto-fit map view to show all loaded markers with padding
if (geoLayer.getLayers().length > 0) {
map.fitBounds(geoLayer.getBounds(), { padding: [20, 20] });
}
});
</script>Integrate Leaflet into the header
Now, we need some header support to load some resources. Depending on your Hugo theme, there is likely some override for /layouts/ to insert code into the header:
<!-- dynamic mapping -->
{{- if isset .Params "locations" }}
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<!-- Leaflet.ExtraMarkers CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet-extra-markers@1.2.1/dist/css/leaflet.extra-markers.min.css" />
<!-- Font Awesome CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<style>
#map {
width: 80vw;
height: 60vh;
margin-top: 20px;
}
</style>Hide Map View tags
Surprisingly, there’s no way in CSS to dynamically hide the Map View tags in markdown, which are rendered as plain text, like this:
[Reykjavik](geo:64.145981,-21.9422367) tag:cityBut it can be done in Javascript. So I add this to the header, too:
<!-- parse out tags, if mapping -->
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('li, p').forEach(function(el) {
// Hide tag:xxx after <a href="geo:..."> pattern, with optional punctuation
el.innerHTML = el.innerHTML.replace(
/(<a\s+href="geo:[^"]+">[^<]+<\/a>)(\s+tag:[\w-]+)([.,;:]?)/g,
function(match, link, tag, punct) {
return link + '<span style="display:none">' + tag + '</span>' + (punct || '');
}
);
});
});
</script>Update Netlify’s build cycle
For the last bit, then, I want Netlify to run build_geojson.py each time I publish — each time I push to the repository. It’s easy to manage these in a netlify.toml file, also in the root directory:
[build]
publish = "public"
command = "python3 build_geojson.py && hugo --gc --minify"
[build.environment]
HUGO_VERSION = "0.143.1"And that’s it! A working system from Map View markdown to a Hugo website.
