Publish Maps from Obsidian Map View to Hugo

Publish Maps from Obsidian Map View to Hugo

October 9, 2025

Image of Map with Placemarkers

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

  1. Have an Obsidian markdown page with Map View inline location URLs and a rendered map.
  2. Build a map.geojason support file by parsing Map View’s geocoded links, using Python.
  3. Replace Map View’s markdown code block with appropriate map rendering code in HTML/JS, using Hugo code block overrides.
  4. Embed map Javascript into page headers conditionally, for posts that need it.
  5. 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: '&copy; OpenStreetMap contributors &copy; 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:city

But 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.

Updated on