Browse Source

Route referencing + rendering

Craig Fletcher 2 weeks ago
parent
commit
2856df424c

+ 2 - 1
CONTEXT.md

@@ -39,7 +39,8 @@ with caching based on file hashes and state access.
 - `src/actions/` contains built-ins:
   - `compileSass`, `renderMarkdownToHtml`, `renderTemplate`,
     `renderMarkdownWithTemplate`, `renderIndex`, `optimiseSvg`, `copy`,
-    `imageToWebP`, `generateFavicons`, `generateTaxonomy`, `generateSitemap`.
+    `imageToWebP`, `generateFavicons`, `generateTaxonomy`, `generateRouteAssets`,
+    `generateSitemap`.
 - Actions receive `{ config, jobConfig, meta }` and return
   `{ detail, paths, deps, ref }`. The `config` value maps to `actionConfig`.
 - Returned `ref` results are exposed via `meta.resources` for later tasks.

+ 15 - 1
README.md

@@ -14,7 +14,7 @@ path/to/config.js`.
 ## Installation and usage
 
 The default config will look for `.md` files in the `markdown/` directory, `.scss` files in the `styles/` directory, and
-handlebars (`.hbs`) files in the `templates/` directory. It also processes images, SVG icons, static files, and generates favicons.
+handlebars (`.hbs`) files in the `templates/` directory. It also processes images, SVG icons, static files, GPX routes, and generates favicons.
 
 The styles will be compiled using `sass` and output to a matching path in the `dist/` directory with a `.css` file
 extension. These file paths are made available to the template renderer, for inclusion as whatever tags you see fit.
@@ -243,6 +243,7 @@ The following actions are available from `actions`:
 - `imageToWebP`: Converts images to WebP with multiple sizes for responsive images
 - `generateFavicons`: Generates favicon sets and web app manifests
 - `generateTaxonomy`: Builds grouped/sorted lists from state inputs
+- `generateRouteAssets`: Sanitizes GPX files and renders responsive route preview images
 - `generateSitemap`: Generates `sitemap.xml` from rendered page state entries
 
 **Resources and Cross-Task References:**
@@ -258,6 +259,7 @@ The default configuration includes:
 
 1. **Parallel processing group:**
    - `images`: Converts JPG images to WebP with multiple sizes
+   - `routes`: Sanitizes GPX files and renders responsive route previews over OpenStreetMap tiles
    - `styles`: Compiles SCSS files to CSS
    - `icons`: Optimizes SVG icons
    - `static-files`: Copies static files
@@ -283,6 +285,18 @@ The default configuration includes:
 5. **Sequential task:**
    - `sitemap`: Generates `sitemap.xml` from rendered page state entries
 
+## Route Blocks In Markdown
+
+Route blocks can be embedded in markdown by GPX basename:
+
+```markdown
+::route mountain-loop
+```
+
+This renders a block containing the route preview image and a link to download the sanitized GPX generated from `gpx/mountain-loop.gpx`.
+
+By default the route block also injects a small CSS block (once per rendered markdown page) for `.route-block*` classes. Set `opts.markdown.includeRouteStyles: false` if you want to style those classes yourself.
+
 ## Logging
 
 Rhedyn includes comprehensive logging with configurable levels:

+ 116 - 1
src/actions/_shared/markdown.js

@@ -2,11 +2,126 @@ import { marked } from "marked"
 import markedCodePreview from "marked-code-preview"
 import { escapeHtmlAttr, slugifyString } from "../../util/index.js"
 
+const ROUTE_BLOCK_STYLE_TAG = [
+  "<style data-rhedyn-route-block>",
+  ".route-block { margin: 2rem 0; border: 1px solid #d1d5db; border-radius: 0.75rem; overflow: hidden; background: #f8fafc; }",
+  ".route-block__image-link { display: block; text-decoration: none; }",
+  ".route-block__image { display: block; width: 100%; height: auto; object-fit: cover; background: #e5e7eb; }",
+  ".route-block__caption { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; padding: 0.75rem 1rem; font-size: 0.95rem; }",
+  ".route-block__download { color: #065f46; font-weight: 600; text-decoration: none; }",
+  ".route-block__download:hover, .route-block__download:focus-visible { text-decoration: underline; }",
+  ".route-block__attribution { color: #4b5563; font-size: 0.85rem; }",
+  ".route-block--missing { margin: 1rem 0; padding: 0.75rem 1rem; border: 1px dashed #f59e0b; border-radius: 0.5rem; background: #fff7ed; color: #7c2d12; }",
+  "</style>",
+].join("")
+
+function normaliseRouteRef(routeName) {
+  if (typeof routeName !== "string") {
+    return ""
+  }
+  return slugifyString(routeName.replace(/\.gpx$/i, ""))
+}
+
+function getRouteResource(meta, routeName) {
+  const routes = meta.resources?.routes
+  if (!routes) {
+    return null
+  }
+  const directRef = normaliseRouteRef(routeName)
+  if (directRef && routes[directRef]) {
+    return routes[directRef]
+  }
+  const fallbackRef = slugifyString(routeName)
+  if (fallbackRef && routes[fallbackRef]) {
+    return routes[fallbackRef]
+  }
+  return null
+}
+
+function renderRouteBlock(meta, rawRouteName) {
+  const routeName = String(rawRouteName || "").trim()
+  if (!routeName) {
+    return ""
+  }
+  const routeResource = getRouteResource(meta, routeName)
+  if (!routeResource) {
+    return `<aside class="route-block route-block--missing">Route not found: ${escapeHtmlAttr(routeName)}</aside>`
+  }
+  const route = routeResource.detail || {}
+  const displayName = route.name || routeName.replace(/\.gpx$/i, "")
+  const gpxHref = route.gpxHref
+  if (!gpxHref) {
+    return `<aside class="route-block route-block--missing">Route not available: ${escapeHtmlAttr(routeName)}</aside>`
+  }
+  const attribution = typeof route.attribution === "string"
+    ? route.attribution.trim()
+    : ""
+
+  const imageSrcSet = Array.isArray(route.srcSet) ? route.srcSet : []
+  const srcSetString = imageSrcSet
+    .map(([src, size]) => `${src} ${size}`)
+    .join(", ")
+  const defaultSrc = imageSrcSet[0]?.[0]
+  const routeImageStyle = route.aspectRatio
+    ? ` style="aspect-ratio: ${escapeHtmlAttr(route.aspectRatio)}"`
+    : ""
+  const image = defaultSrc
+    ? `<img class="route-block__image" alt="Route map for ${escapeHtmlAttr(displayName)}" src="${escapeHtmlAttr(defaultSrc)}" srcset="${escapeHtmlAttr(srcSetString)}" sizes="(min-width: 800px) 40vw, 100vw"${routeImageStyle}>`
+    : ""
+
+  return [
+    `<figure class="route-block" data-route="${escapeHtmlAttr(displayName)}">`,
+    image,
+    `<figcaption class="route-block__caption"><a class="route-block__download" href="${escapeHtmlAttr(gpxHref)}" download>Download GPX</a>${attribution ? `<span class="route-block__attribution">${escapeHtmlAttr(attribution)}</span>` : ""}</figcaption>`,
+    "</figure>",
+  ].join("")
+}
+
+function createRouteExtension(meta) {
+  const includeRouteStyles = meta?.opts?.markdown?.includeRouteStyles !== false
+  let hasRenderedRouteStyles = false
+  function renderRouteStyles() {
+    if (!includeRouteStyles || hasRenderedRouteStyles) {
+      return ""
+    }
+    hasRenderedRouteStyles = true
+    return ROUTE_BLOCK_STYLE_TAG
+  }
+
+  return {
+    extensions: [
+      {
+        name: "routeBlock",
+        level: "block",
+        start(src) {
+          const match = src.match(/::route[ \t]+/m)
+          return match ? match.index : undefined
+        },
+        tokenizer(src) {
+          const match = /^::route[ \t]+([^\n\r]+)[ \t]*(?:\r?\n|$)/.exec(src)
+          if (!match) {
+            return undefined
+          }
+          return {
+            type: "routeBlock",
+            raw: match[0],
+            routeName: match[1].trim(),
+          }
+        },
+        renderer(token) {
+          return `${renderRouteStyles()}${renderRouteBlock(meta, token.routeName)}`
+        },
+      },
+    ],
+  }
+}
+
 export function createMarkdownRenderer(meta) {
   const allowHtml = meta?.opts?.markdown?.allowHtml === true
   return marked
     .use({ gfm: true })
     .use(markedCodePreview)
+    .use(createRouteExtension(meta))
     .use({
       renderer: {
         html(html) {
@@ -26,7 +141,7 @@ export function createMarkdownRenderer(meta) {
             attrs.push(`srcset="${escapeHtmlAttr(srcSetString)}"`)
             attrs.push("sizes=\"(min-width: 800px) 40vw, 100vw\"")
             attrs.push(
-              `style=\"aspect-ratio: ${escapeHtmlAttr(foundSrcSet.detail.aspectRatio)}\"`,
+              `style="aspect-ratio: ${escapeHtmlAttr(foundSrcSet.detail.aspectRatio)}"`,
             )
           } else {
             attrs.push(`src="${escapeHtmlAttr(href)}"`)

+ 46 - 0
src/actions/generateRouteAssets/README.md

@@ -0,0 +1,46 @@
+# generateRouteAssets
+
+Sanitises GPX tracks and renders responsive WebP route preview images over an OpenStreetMap basemap.
+
+## actionConfig options
+- `filePath` (string, required): Source GPX file path (computed from `jobConfig`).
+- `fileOutputPath` (string, required): Sanitised GPX output path (computed from `jobConfig`).
+- `fileOutputDir` (string, required): Output directory (computed from `jobConfig`).
+- `imageSizes` (string[]|number[], required): Sizes to generate (e.g. `"640w"`, `1024`).
+- `quality` (number, optional): WebP quality (default follows task config).
+- `renderWidth` (number, optional): Base render width for generated route art (default: `1600`).
+- `renderHeight` (number, optional): Base render height (default: `1000`).
+- `renderPadding` (number, optional): Inner padding used for route fit.
+- `minZoom` (number, optional): Minimum map zoom when fitting route (default: `3`).
+- `maxZoom` (number, optional): Maximum map zoom when fitting route (default: `15`).
+- `strokeColor` (string, optional): Route stroke color.
+- `strokeWidth` (number, optional): Route stroke width in rendered pixels.
+- `backgroundColor` (string, optional): Background color.
+- `startMarkerColor` (string, optional): Start marker color.
+- `endMarkerColor` (string, optional): End marker color.
+- `tileUrlTemplate` (string, optional): Tile URL pattern (default: `https://tile.openstreetmap.org/{z}/{x}/{y}.png`).
+- `userAgent` (string, optional): User-Agent header for tile requests.
+- `tileRequestTimeoutMs` (number, optional): Tile request timeout in ms.
+- `attribution` (string, optional): Attribution text exposed as `detail.attribution` (default: `© OpenStreetMap contributors`).
+- `fetchImpl` (function, optional): Custom `fetch` implementation for tests/custom runtime.
+
+## Usage
+```javascript
+action: generateRouteAssets,
+jobConfig: {
+  inputFiles: [{ pattern: "gpx/**/*.gpx" }],
+  stripPaths: ["gpx/"],
+  outputDir: "routes/",
+  outputFileExtension: ".gpx"
+},
+actionConfig: {
+  imageSizes: ["640w", "1024w", "1600w"],
+  quality: 80
+}
+```
+
+## Notes
+- Rebuilds GPX output with only track point geometry to remove source metadata.
+- Produces responsive route previews (`.webp`) and exposes `detail.gpxHref`, `detail.srcSet`, and `detail.aspectRatio`.
+- Basemap tiles default to OpenStreetMap and route metadata includes attribution for template/markdown rendering.
+- Uses `ref` from the original GPX basename (slugified), so markdown route references can use the source GPX name.

+ 409 - 0
src/actions/generateRouteAssets/index.js

@@ -0,0 +1,409 @@
+import fs from "fs/promises"
+import path from "path"
+import sharp from "sharp"
+import {
+  getCleanPath,
+  slugifyString,
+  writeFile,
+} from "../../util/index.js"
+
+const WEB_MERCATOR_MAX_LAT = 85.05112878
+
+function escapeXmlText(value) {
+  return String(value)
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&apos;")
+}
+
+function parsePointsFromTag(gpxContent, tagName) {
+  const points = []
+  const regex = new RegExp(`<${tagName}\\b([^>]*)>`, "gi")
+  for (const match of gpxContent.matchAll(regex)) {
+    const attrs = match[1] || ""
+    const latMatch = attrs.match(/\blat\s*=\s*["']([^"']+)["']/i)
+    const lonMatch = attrs.match(/\blon\s*=\s*["']([^"']+)["']/i)
+    if (!latMatch || !lonMatch) {
+      continue
+    }
+    const lat = Number.parseFloat(latMatch[1])
+    const lon = Number.parseFloat(lonMatch[1])
+    if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
+      continue
+    }
+    if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
+      continue
+    }
+    points.push({ lat, lon })
+  }
+  return points
+}
+
+function parseGpxPoints(gpxContent) {
+  const trackPoints = parsePointsFromTag(gpxContent, "trkpt")
+  if (trackPoints.length > 0) {
+    return trackPoints
+  }
+  return parsePointsFromTag(gpxContent, "rtept")
+}
+
+function clamp(value, min, max) {
+  return Math.max(min, Math.min(max, value))
+}
+
+function modulo(value, modulus) {
+  return ((value % modulus) + modulus) % modulus
+}
+
+function lonToWorldX(lon, zoom) {
+  const worldSize = 256 * (2 ** zoom)
+  return ((lon + 180) / 360) * worldSize
+}
+
+function latToWorldY(lat, zoom) {
+  const clampedLat = clamp(lat, -WEB_MERCATOR_MAX_LAT, WEB_MERCATOR_MAX_LAT)
+  const latRadians = (clampedLat * Math.PI) / 180
+  const worldSize = 256 * (2 ** zoom)
+  const mercator = Math.log(Math.tan((Math.PI / 4) + (latRadians / 2)))
+  return (worldSize / 2) - (worldSize * mercator) / (2 * Math.PI)
+}
+
+function unwrapProjectedPoints(projectedPoints, worldSize) {
+  if (projectedPoints.length === 0) {
+    return []
+  }
+  const unwrapped = [{ ...projectedPoints[0] }]
+  for (let index = 1; index < projectedPoints.length; index++) {
+    const point = projectedPoints[index]
+    const previous = unwrapped[index - 1]
+    let x = point.x
+    while (x - previous.x > worldSize / 2) {
+      x -= worldSize
+    }
+    while (previous.x - x > worldSize / 2) {
+      x += worldSize
+    }
+    unwrapped.push({ x, y: point.y })
+  }
+  return unwrapped
+}
+
+function getBounds(points) {
+  return points.reduce(
+    (bounds, point) => ({
+      minX: Math.min(bounds.minX, point.x),
+      maxX: Math.max(bounds.maxX, point.x),
+      minY: Math.min(bounds.minY, point.y),
+      maxY: Math.max(bounds.maxY, point.y),
+    }),
+    {
+      minX: Infinity,
+      maxX: -Infinity,
+      minY: Infinity,
+      maxY: -Infinity,
+    },
+  )
+}
+
+function getProjectedRoute(points, zoom) {
+  const worldSize = 256 * (2 ** zoom)
+  const projected = points.map(point => ({
+    x: lonToWorldX(point.lon, zoom),
+    y: latToWorldY(point.lat, zoom),
+  }))
+  const unwrapped = unwrapProjectedPoints(projected, worldSize)
+  const bounds = getBounds(unwrapped)
+  return {
+    points: unwrapped,
+    bounds,
+    worldSize,
+  }
+}
+
+function buildSanitisedGpx(points, name) {
+  const pointsXml = points
+    .map(({ lat, lon }) => {
+      return `      <trkpt lat="${lat.toFixed(6)}" lon="${lon.toFixed(6)}"></trkpt>`
+    })
+    .join("\n")
+
+  return [
+    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
+    "<gpx version=\"1.1\" creator=\"Rhedyn\" xmlns=\"http://www.topografix.com/GPX/1/1\">",
+    "  <trk>",
+    `    <name>${escapeXmlText(name)}</name>`,
+    "    <trkseg>",
+    pointsXml,
+    "    </trkseg>",
+    "  </trk>",
+    "</gpx>",
+    "",
+  ].join("\n")
+}
+
+function chooseZoom(points, actionConfig, width, height, padding) {
+  const minZoom = actionConfig.minZoom ?? 3
+  const maxZoom = actionConfig.maxZoom ?? 15
+  const drawableWidth = Math.max(1, width - (padding * 2))
+  const drawableHeight = Math.max(1, height - (padding * 2))
+  for (let zoom = maxZoom; zoom >= minZoom; zoom--) {
+    const projected = getProjectedRoute(points, zoom)
+    const routeWidth = projected.bounds.maxX - projected.bounds.minX
+    const routeHeight = projected.bounds.maxY - projected.bounds.minY
+    if (routeWidth <= drawableWidth && routeHeight <= drawableHeight) {
+      return { zoom, projected }
+    }
+  }
+  return {
+    zoom: minZoom,
+    projected: getProjectedRoute(points, minZoom),
+  }
+}
+
+function toTileUrl(template, zoom, x, y) {
+  return template
+    .replace("{z}", String(zoom))
+    .replace("{x}", String(x))
+    .replace("{y}", String(y))
+}
+
+async function fetchTileBuffer({
+  zoom,
+  x,
+  y,
+  actionConfig,
+  fetchImpl,
+}) {
+  const tileUrlTemplate = actionConfig.tileUrlTemplate || "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
+  const userAgent = actionConfig.userAgent || "rhedyn-route-generator/1.0"
+  const timeoutMs = actionConfig.tileRequestTimeoutMs || 12000
+  const controller = new AbortController()
+  const timeout = setTimeout(() => controller.abort(), timeoutMs)
+  try {
+    const response = await fetchImpl(toTileUrl(tileUrlTemplate, zoom, x, y), {
+      headers: {
+        "User-Agent": userAgent,
+      },
+      signal: controller.signal,
+    })
+    if (!response.ok) {
+      return null
+    }
+    const arrayBuffer = await response.arrayBuffer()
+    return Buffer.from(arrayBuffer)
+  } catch {
+    return null
+  } finally {
+    clearTimeout(timeout)
+  }
+}
+
+async function buildBasemap({
+  width,
+  height,
+  zoom,
+  topLeftX,
+  topLeftY,
+  actionConfig,
+}) {
+  const tileSize = 256
+  const tileCount = 2 ** zoom
+  const fetchImpl = actionConfig.fetchImpl || globalThis.fetch
+  if (typeof fetchImpl !== "function") {
+    throw new Error("No fetch implementation available for OpenStreetMap tile requests.")
+  }
+  const minTileX = Math.floor(topLeftX / tileSize)
+  const maxTileX = Math.floor((topLeftX + width) / tileSize)
+  const minTileY = Math.floor(topLeftY / tileSize)
+  const maxTileY = Math.floor((topLeftY + height) / tileSize)
+
+  const tileDescriptors = []
+  for (let tileY = minTileY; tileY <= maxTileY; tileY++) {
+    if (tileY < 0 || tileY >= tileCount) {
+      continue
+    }
+    for (let tileX = minTileX; tileX <= maxTileX; tileX++) {
+      tileDescriptors.push({
+        tileX,
+        tileY,
+        tileXWrapped: modulo(tileX, tileCount),
+      })
+    }
+  }
+
+  const fetchedTiles = await Promise.all(
+    tileDescriptors.map(async tile => {
+      const buffer = await fetchTileBuffer({
+        zoom,
+        x: tile.tileXWrapped,
+        y: tile.tileY,
+        actionConfig,
+        fetchImpl,
+      })
+      if (!buffer) {
+        return null
+      }
+      return {
+        input: buffer,
+        left: Math.round((tile.tileX * tileSize) - topLeftX),
+        top: Math.round((tile.tileY * tileSize) - topLeftY),
+      }
+    }),
+  )
+  const composites = fetchedTiles.filter(Boolean)
+  if (composites.length === 0) {
+    throw new Error(
+      "Could not load OpenStreetMap basemap tiles. Check network access or provide actionConfig.tileUrlTemplate.",
+    )
+  }
+
+  const fallbackColor = actionConfig.backgroundColor || "#e5e7eb"
+  return sharp({
+    create: {
+      width,
+      height,
+      channels: 4,
+      background: fallbackColor,
+    },
+  })
+    .composite(composites)
+    .png()
+    .toBuffer()
+}
+
+function buildRouteOverlay({
+  pointCoords,
+  width,
+  height,
+  actionConfig,
+}) {
+  const strokeWidth = actionConfig.strokeWidth || Math.max(4, Math.round(width * 0.0075))
+  const lineColor = actionConfig.strokeColor || "#0f766e"
+  const startColor = actionConfig.startMarkerColor || "#16a34a"
+  const endColor = actionConfig.endMarkerColor || "#dc2626"
+  const markerRadius = Math.max(6, Math.round(strokeWidth * 1.2))
+  const polylinePoints = pointCoords
+    .map(({ x, y }) => `${x.toFixed(2)},${y.toFixed(2)}`)
+    .join(" ")
+  const startPoint = pointCoords[0]
+  const endPoint = pointCoords[pointCoords.length - 1]
+  return [
+    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Route map">`,
+    "  <defs>",
+    "    <filter id=\"route-shadow\" x=\"-20%\" y=\"-20%\" width=\"140%\" height=\"140%\">",
+    "      <feDropShadow dx=\"0\" dy=\"1\" stdDeviation=\"2\" flood-opacity=\"0.5\"/>",
+    "    </filter>",
+    "  </defs>",
+    "  <g filter=\"url(#route-shadow)\">",
+    `  <polyline points="${polylinePoints}" fill="none" stroke="${lineColor}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" />`,
+    "  </g>",
+    `  <circle cx="${startPoint.x.toFixed(2)}" cy="${startPoint.y.toFixed(2)}" r="${markerRadius}" fill="${startColor}" />`,
+    `  <circle cx="${endPoint.x.toFixed(2)}" cy="${endPoint.y.toFixed(2)}" r="${markerRadius}" fill="${endColor}" />`,
+    "</svg>",
+  ].join("\n")
+}
+
+async function renderRouteMap(points, actionConfig) {
+  const width = actionConfig.renderWidth || 1600
+  const height = actionConfig.renderHeight || 1000
+  const padding = actionConfig.renderPadding || Math.round(width * 0.08)
+  const { zoom, projected } = chooseZoom(points, actionConfig, width, height, padding)
+  const routeWidth = projected.bounds.maxX - projected.bounds.minX
+  const routeHeight = projected.bounds.maxY - projected.bounds.minY
+  const centerX = projected.bounds.minX + routeWidth / 2
+  const centerY = projected.bounds.minY + routeHeight / 2
+  const topLeftX = centerX - (width / 2)
+  const maxTopLeftY = Math.max(0, projected.worldSize - height)
+  const topLeftY = clamp(centerY - (height / 2), 0, maxTopLeftY)
+
+  const baseMapBuffer = await buildBasemap({
+    width,
+    height,
+    zoom,
+    topLeftX,
+    topLeftY,
+    actionConfig,
+  })
+  const pointCoords = projected.points.map(point => ({
+    x: point.x - topLeftX,
+    y: point.y - topLeftY,
+  }))
+  const routeOverlay = buildRouteOverlay({
+    pointCoords,
+    width,
+    height,
+    actionConfig,
+  })
+  const mapBuffer = await sharp(baseMapBuffer)
+    .composite([{ input: Buffer.from(routeOverlay) }])
+    .png()
+    .toBuffer()
+
+  return {
+    mapBuffer,
+    width,
+    height,
+  }
+}
+
+function parseSize(size) {
+  const sizeString = typeof size === "string" ? size : String(size)
+  const width = Number.parseInt(sizeString.replace("w", ""), 10)
+  if (!Number.isFinite(width) || width <= 0) {
+    throw new Error(`Invalid image size: ${sizeString}`)
+  }
+  return { width, sizeString }
+}
+
+export async function generateRouteAssets({ meta, config: actionConfig }) {
+  const filePath = actionConfig.filePath
+  const routeName = path.basename(filePath, path.extname(filePath))
+  const fileOutputPath = actionConfig.fileOutputPath
+  const fileOutputDir = actionConfig.fileOutputDir
+  await fs.mkdir(fileOutputDir, { recursive: true })
+
+  const gpxContent = await fs.readFile(filePath, "utf8")
+  const points = parseGpxPoints(gpxContent)
+  if (points.length < 2) {
+    throw new Error(`Could not find enough track points in GPX file: ${filePath}`)
+  }
+
+  const sanitisedGpx = buildSanitisedGpx(points, routeName)
+  await writeFile(fileOutputPath, sanitisedGpx)
+
+  const imageSizes = Array.isArray(actionConfig.imageSizes) ? actionConfig.imageSizes : []
+  if (imageSizes.length === 0) {
+    throw new Error(`generateRouteAssets: imageSizes must be a non-empty array for ${filePath}`)
+  }
+  const { mapBuffer, width, height } = await renderRouteMap(points, actionConfig)
+  const outputFiles = [fileOutputPath]
+  const srcSet = await Promise.all(
+    imageSizes.map(async size => {
+      const { width: imageWidth, sizeString } = parseSize(size)
+      const imageOutputPath = path.join(fileOutputDir, `${routeName}-${imageWidth}.webp`)
+      await sharp(mapBuffer)
+        .resize(imageWidth)
+        .webp({ quality: actionConfig.quality })
+        .toFile(imageOutputPath)
+      outputFiles.push(imageOutputPath)
+      return [getCleanPath(imageOutputPath, meta), sizeString]
+    }),
+  )
+
+  return {
+    paths: outputFiles,
+    detail: {
+      name: routeName,
+      gpxHref: getCleanPath(fileOutputPath, meta),
+      srcSet,
+      aspectRatio: width / height,
+      attribution: actionConfig.attribution || "© OpenStreetMap contributors",
+    },
+    deps: {
+      paths: [filePath],
+    },
+    ref: slugifyString(routeName),
+  }
+}

+ 1 - 0
src/actions/index.js

@@ -3,6 +3,7 @@ export { clearTemplateCache } from "./_shared/template-cache.js"
 export { compileSass } from "./compileSass/index.js"
 export { copy } from "./copy/index.js"
 export { generateFavicons } from "./generateFavicons/index.js"
+export { generateRouteAssets } from "./generateRouteAssets/index.js"
 export { generateSitemap } from "./generateSitemap/index.js"
 export { generateTaxonomy } from "./generateTaxonomy/index.js"
 export { imageToWebP } from "./imageToWebP/index.js"

+ 3 - 0
src/actions/renderMarkdownToHtml/README.md

@@ -19,4 +19,7 @@ actionConfig: {}
 ## Notes
 - Emits `detail` containing frontmatter, `href`, `content` (HTML), and `fileOutputPath`.
 - Uses `meta.resources.images` to attach responsive image srcsets when available.
+- Supports custom route blocks in markdown using `::route <gpx-name>`, sourced from `resources.routes`.
+- Route blocks include OpenStreetMap attribution when provided by route assets.
+- Route block CSS is injected once per markdown render by default; set `opts.markdown.includeRouteStyles: false` to disable.
 - Respects `opts.markdown.allowHtml` to enable or strip raw HTML in markdown.

+ 4 - 0
src/actions/renderMarkdownWithTemplate/README.md

@@ -27,6 +27,10 @@ actionConfig: {
 - Uses frontmatter `template` when present, otherwise `defaultTemplate`.
 - Writes minified HTML to `fileOutputPath` and records the template as a dependency.
 - Respects `opts.markdown.allowHtml` to enable or strip raw HTML in markdown.
+- Supports custom route blocks in markdown using `::route <gpx-name>` (for example: `::route mountain-loop`).
+- Route blocks render an image preview + GPX download link from `resources.routes`.
+- Route blocks include OpenStreetMap attribution when provided by route assets.
+- Route block CSS is injected once per markdown render by default; set `opts.markdown.includeRouteStyles: false` to disable.
 - When rendering with template `article`, it auto-injects `BlogPosting` JSON-LD.
 - JSON-LD frontmatter sources: `title`, `description`, `date`/`published`, `modified`/`updated`, `author`, `image`/`images`/`heroImage`/`coverImage`.
 - Set frontmatter `includeArticleJsonLd: false` to disable auto-injection for a page.

+ 24 - 0
src/defaults.js

@@ -2,6 +2,7 @@ import {
   compileSass,
   copy,
   generateFavicons,
+  generateRouteAssets,
   generateSitemap,
   imageToWebP,
   optimiseSvg,
@@ -82,6 +83,29 @@ export const tasks = [
       },
       actionConfig: {},
     },
+    {
+      key: "routes",
+      name: "Routes",
+      action: generateRouteAssets,
+      jobConfig: {
+        inputFiles: [{ pattern: "gpx/**/*.gpx" }],
+        stripPaths: ["gpx/"],
+        outputDir: "routes/",
+        outputFileExtension: ".gpx",
+      },
+      actionConfig: {
+        imageSizes: [
+          "640w",
+          "768w",
+          "1024w",
+          "1366w",
+          "1600w",
+          "1920w",
+          "2560w",
+        ],
+        quality: 80,
+      },
+    },
   ],
   {
     key: "blog-markdown",