Răsfoiți Sursa

Route referencing + rendering

Craig Fletcher 2 săptămâni în urmă
părinte
comite
2856df424c

+ 2 - 1
CONTEXT.md

@@ -39,7 +39,8 @@ with caching based on file hashes and state access.
 - `src/actions/` contains built-ins:
 - `src/actions/` contains built-ins:
   - `compileSass`, `renderMarkdownToHtml`, `renderTemplate`,
   - `compileSass`, `renderMarkdownToHtml`, `renderTemplate`,
     `renderMarkdownWithTemplate`, `renderIndex`, `optimiseSvg`, `copy`,
     `renderMarkdownWithTemplate`, `renderIndex`, `optimiseSvg`, `copy`,
-    `imageToWebP`, `generateFavicons`, `generateTaxonomy`, `generateSitemap`.
+    `imageToWebP`, `generateFavicons`, `generateTaxonomy`, `generateRouteAssets`,
+    `generateSitemap`.
 - Actions receive `{ config, jobConfig, meta }` and return
 - Actions receive `{ config, jobConfig, meta }` and return
   `{ detail, paths, deps, ref }`. The `config` value maps to `actionConfig`.
   `{ detail, paths, deps, ref }`. The `config` value maps to `actionConfig`.
 - Returned `ref` results are exposed via `meta.resources` for later tasks.
 - 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
 ## Installation and usage
 
 
 The default config will look for `.md` files in the `markdown/` directory, `.scss` files in the `styles/` directory, and
 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
 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.
 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
 - `imageToWebP`: Converts images to WebP with multiple sizes for responsive images
 - `generateFavicons`: Generates favicon sets and web app manifests
 - `generateFavicons`: Generates favicon sets and web app manifests
 - `generateTaxonomy`: Builds grouped/sorted lists from state inputs
 - `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
 - `generateSitemap`: Generates `sitemap.xml` from rendered page state entries
 
 
 **Resources and Cross-Task References:**
 **Resources and Cross-Task References:**
@@ -258,6 +259,7 @@ The default configuration includes:
 
 
 1. **Parallel processing group:**
 1. **Parallel processing group:**
    - `images`: Converts JPG images to WebP with multiple sizes
    - `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
    - `styles`: Compiles SCSS files to CSS
    - `icons`: Optimizes SVG icons
    - `icons`: Optimizes SVG icons
    - `static-files`: Copies static files
    - `static-files`: Copies static files
@@ -283,6 +285,18 @@ The default configuration includes:
 5. **Sequential task:**
 5. **Sequential task:**
    - `sitemap`: Generates `sitemap.xml` from rendered page state entries
    - `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
 ## Logging
 
 
 Rhedyn includes comprehensive logging with configurable levels:
 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 markedCodePreview from "marked-code-preview"
 import { escapeHtmlAttr, slugifyString } from "../../util/index.js"
 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) {
 export function createMarkdownRenderer(meta) {
   const allowHtml = meta?.opts?.markdown?.allowHtml === true
   const allowHtml = meta?.opts?.markdown?.allowHtml === true
   return marked
   return marked
     .use({ gfm: true })
     .use({ gfm: true })
     .use(markedCodePreview)
     .use(markedCodePreview)
+    .use(createRouteExtension(meta))
     .use({
     .use({
       renderer: {
       renderer: {
         html(html) {
         html(html) {
@@ -26,7 +141,7 @@ export function createMarkdownRenderer(meta) {
             attrs.push(`srcset="${escapeHtmlAttr(srcSetString)}"`)
             attrs.push(`srcset="${escapeHtmlAttr(srcSetString)}"`)
             attrs.push("sizes=\"(min-width: 800px) 40vw, 100vw\"")
             attrs.push("sizes=\"(min-width: 800px) 40vw, 100vw\"")
             attrs.push(
             attrs.push(
-              `style=\"aspect-ratio: ${escapeHtmlAttr(foundSrcSet.detail.aspectRatio)}\"`,
+              `style="aspect-ratio: ${escapeHtmlAttr(foundSrcSet.detail.aspectRatio)}"`,
             )
             )
           } else {
           } else {
             attrs.push(`src="${escapeHtmlAttr(href)}"`)
             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 { compileSass } from "./compileSass/index.js"
 export { copy } from "./copy/index.js"
 export { copy } from "./copy/index.js"
 export { generateFavicons } from "./generateFavicons/index.js"
 export { generateFavicons } from "./generateFavicons/index.js"
+export { generateRouteAssets } from "./generateRouteAssets/index.js"
 export { generateSitemap } from "./generateSitemap/index.js"
 export { generateSitemap } from "./generateSitemap/index.js"
 export { generateTaxonomy } from "./generateTaxonomy/index.js"
 export { generateTaxonomy } from "./generateTaxonomy/index.js"
 export { imageToWebP } from "./imageToWebP/index.js"
 export { imageToWebP } from "./imageToWebP/index.js"

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

@@ -19,4 +19,7 @@ actionConfig: {}
 ## Notes
 ## Notes
 - Emits `detail` containing frontmatter, `href`, `content` (HTML), and `fileOutputPath`.
 - Emits `detail` containing frontmatter, `href`, `content` (HTML), and `fileOutputPath`.
 - Uses `meta.resources.images` to attach responsive image srcsets when available.
 - 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.
 - 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`.
 - Uses frontmatter `template` when present, otherwise `defaultTemplate`.
 - Writes minified HTML to `fileOutputPath` and records the template as a dependency.
 - Writes minified HTML to `fileOutputPath` and records the template as a dependency.
 - Respects `opts.markdown.allowHtml` to enable or strip raw HTML in markdown.
 - 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.
 - 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`.
 - 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.
 - Set frontmatter `includeArticleJsonLd: false` to disable auto-injection for a page.

+ 24 - 0
src/defaults.js

@@ -2,6 +2,7 @@ import {
   compileSass,
   compileSass,
   copy,
   copy,
   generateFavicons,
   generateFavicons,
+  generateRouteAssets,
   generateSitemap,
   generateSitemap,
   imageToWebP,
   imageToWebP,
   optimiseSvg,
   optimiseSvg,
@@ -82,6 +83,29 @@ export const tasks = [
       },
       },
       actionConfig: {},
       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",
     key: "blog-markdown",