Parcourir la source

feat(rss),fix(sass): add RSS support, fix sass refs

Craig Fletcher il y a 1 mois
Parent
commit
310b868bcf

+ 44 - 8
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, GPX routes, and generates favicons.
+handlebars (`.hbs`) files in the `templates/` directory. It also processes images, SVG icons, static files, GPX routes, and generates favicons, an RSS feed, and a sitemap.
 
 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.
@@ -140,6 +140,38 @@ Each task object should look something like this:
 }
 ```
 
+`compileSass` enables Sass package imports by default through Sass's official `pkg:` importer. Prefer `@use` rather than deprecated `@import`:
+
+```scss
+@use "pkg:@fontsource/overlock" as overlock;
+@use "pkg:@fontsource/atkinson-hyperlegible" as atkinson;
+@use "pkg:@fontsource/source-code-pro" as code;
+
+h1, h2, h3 {
+  font-family: "Overlock";
+}
+
+body {
+  font-family: "Atkinson Hyperlegible";
+}
+
+code, pre {
+  font-family: "Source Code Pro";
+}
+```
+
+When imported stylesheets emit relative `url(...)` references, `compileSass` copies those assets into the output tree and rewrites the CSS to site-rooted paths. The default asset routing sends font files to `static/fonts/`, and other referenced assets to `static/assets/`. You can override that per task:
+
+```javascript
+actionConfig: {
+  assetOutputDirsByExtension: {
+    ".woff2": "static/fonts/",
+    ".woff": "static/fonts/",
+    default: "static/assets/"
+  }
+}
+```
+
 **Task Properties:**
 - `key`: Task identifier used in state/resources (required)
 - `name`: Human-readable task label used in logs (optional, defaults to `key`)
@@ -242,6 +274,7 @@ The following actions are available from `actions`:
 - `copy`: Copies files without processing
 - `imageToWebP`: Converts images to WebP with multiple sizes for responsive images
 - `generateFavicons`: Generates favicon sets and web app manifests
+- `generateRss`: Generates an RSS feed 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
@@ -266,25 +299,28 @@ The default configuration includes:
    - `favicons`: Generates favicon sets from source images
 
 2. **Sequential tasks:**
-   - `blog-markdown`: Parses blog frontmatter metadata (no HTML rendering)
+   - `posts-markdown`: Parses post frontmatter metadata (no HTML rendering)
    - `markdown`: Parses general frontmatter metadata (no HTML rendering)
 
 3. **Parallel processing group:**
-   - `author-taxonomy`: Builds author groupings from blog entries
-   - `tag-taxonomy`: Builds tag groupings from blog entries
-   - `blog-latest`: Builds a sorted list of recent blog entries
+   - `author-taxonomy`: Builds author groupings from post entries
+   - `tag-taxonomy`: Builds tag groupings from post entries
+   - `posts-latest`: Builds a sorted list of recent posts
    - `includes`: Renders template includes to HTML
 
 4. **Parallel processing group:**
    - `render-pages`: Renders standard pages from markdown files
-   - `render-blog-pages`: Renders blog pages from blog markdown files
+   - `render-post-pages`: Renders post pages from posts markdown files
    - `render-author-indexes`: Renders author index pages and index list
    - `render-tag-indexes`: Renders tag index pages and index list
-   - `render-blog-home`: Renders the blog index page
+   - `render-posts-home`: Renders the posts index page
 
 5. **Sequential task:**
    - `sitemap`: Generates `sitemap.xml` from rendered page state entries
 
+6. **Sequential task:**
+   - `rss`: Generates `rss.xml` from the sorted posts metadata
+
 ## Route Blocks In Markdown
 
 Route blocks can be embedded in markdown by GPX basename:
@@ -295,7 +331,7 @@ Route blocks can be embedded in markdown by GPX basename:
 
 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.
+Route blocks render semantic HTML with stable `.route-block*` classes, but no injected CSS or inline styling. The consuming app is responsible for presentation.
 
 ## Logging
 

+ 1 - 0
package.json

@@ -5,6 +5,7 @@
   "main": "src/index.js",
   "scripts": {
     "generate": "node src/index.js",
+    "test": "node --test",
     "build": "nexe -i src/index.js -o dist/rhedyn --build --python=$(which python3)",
     "lint-fix": "eslint --fix src/**/*.js"
   },

+ 76 - 85
src/actions/_shared/markdown.js

@@ -1,123 +1,112 @@
-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("")
+import { marked } from "marked";
+import markedCodePreview from "marked-code-preview";
+import { escapeHtmlAttr, slugifyString } from "../../util/index.js";
 
 function normaliseRouteRef(routeName) {
   if (typeof routeName !== "string") {
-    return ""
+    return "";
   }
-  return slugifyString(routeName.replace(/\.gpx$/i, ""))
+  return slugifyString(routeName.replace(/\.gpx$/i, ""));
 }
 
 function getRouteResource(meta, routeName) {
-  const routes = meta.resources?.routes
+  const routes = meta.resources?.routes;
   if (!routes) {
-    return null
+    return null;
   }
-  const directRef = normaliseRouteRef(routeName)
+  const directRef = normaliseRouteRef(routeName);
   if (directRef && routes[directRef]) {
-    return routes[directRef]
+    return routes[directRef];
   }
-  const fallbackRef = slugifyString(routeName)
+  const fallbackRef = slugifyString(routeName);
   if (fallbackRef && routes[fallbackRef]) {
-    return routes[fallbackRef]
+    return routes[fallbackRef];
   }
-  return null
+  return null;
 }
 
 function renderRouteBlock(meta, rawRouteName) {
-  const routeName = String(rawRouteName || "").trim()
+  const routeName = String(rawRouteName || "").trim();
   if (!routeName) {
-    return ""
+    return "";
   }
-  const routeResource = getRouteResource(meta, routeName)
+  const routeResource = getRouteResource(meta, routeName);
   if (!routeResource) {
-    return `<aside class="route-block route-block--missing">Route not found: ${escapeHtmlAttr(routeName)}</aside>`
+    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
+  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>`
+    return `<aside class="route-block route-block--missing">Route not available: ${escapeHtmlAttr(
+      routeName
+    )}</aside>`;
   }
-  const attribution = typeof route.attribution === "string"
-    ? route.attribution.trim()
-    : ""
+  const attribution =
+    typeof route.attribution === "string" ? route.attribution.trim() : "";
 
-  const imageSrcSet = Array.isArray(route.srcSet) ? route.srcSet : []
+  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)}"`
-    : ""
+    .join(", ");
+  const defaultSrc = imageSrcSet[0]?.[0];
   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}>`
-    : ""
+    ? `<img class="route-block__image" alt="Route map for ${escapeHtmlAttr(
+        displayName
+      )}" src="${escapeHtmlAttr(defaultSrc)}" srcset="${escapeHtmlAttr(
+        srcSetString
+      )}" sizes="(min-width: 800px) 40vw, 100vw">`
+    : "";
 
   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("")
+    `<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
+          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)
+          const match = /^::route[ \t]+([^\n\r]+)[ \t]*(?:\r?\n|$)/.exec(src);
           if (!match) {
-            return undefined
+            return undefined;
           }
           return {
             type: "routeBlock",
             raw: match[0],
-            routeName: match[1].trim(),
-          }
+            routeName: match[1].trim()
+          };
         },
         renderer(token) {
-          return `${renderRouteStyles()}${renderRouteBlock(meta, token.routeName)}`
-        },
-      },
-    ],
-  }
+          return renderRouteBlock(meta, token.routeName);
+        }
+      }
+    ]
+  };
 }
 
 export function createMarkdownRenderer(meta) {
-  const allowHtml = meta?.opts?.markdown?.allowHtml === true
+  const allowHtml = meta?.opts?.markdown?.allowHtml === true;
   return marked
     .use({ gfm: true })
     .use(markedCodePreview)
@@ -125,34 +114,36 @@ export function createMarkdownRenderer(meta) {
     .use({
       renderer: {
         html(html) {
-          return allowHtml ? html : ""
+          return allowHtml ? html : "";
         },
         image({ href, title, text }) {
-          const attrs = [`alt="${escapeHtmlAttr(text)}"`]
+          const attrs = [`alt="${escapeHtmlAttr(text)}"`];
 
-          const foundSrcSet = meta.resources.images?.[slugifyString(href)]
+          const foundSrcSet = meta.resources.images?.[slugifyString(href)];
 
           if (foundSrcSet && foundSrcSet.detail.srcSet?.length > 0) {
             const srcSetString = foundSrcSet.detail.srcSet
               .map(src => src.join(" "))
-              .join(", ")
-            const defaultSrc = foundSrcSet.detail.srcSet[0][0]
-            attrs.push(`src="${escapeHtmlAttr(defaultSrc)}"`)
-            attrs.push(`srcset="${escapeHtmlAttr(srcSetString)}"`)
-            attrs.push("sizes=\"(min-width: 800px) 40vw, 100vw\"")
+              .join(", ");
+            const defaultSrc = foundSrcSet.detail.srcSet[0][0];
+            attrs.push(`src="${escapeHtmlAttr(defaultSrc)}"`);
+            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)}"`)
+            attrs.push(`src="${escapeHtmlAttr(href)}"`);
           }
 
           if (title) {
-            attrs.push(`title="${escapeHtmlAttr(title)}"`)
+            attrs.push(`title="${escapeHtmlAttr(title)}"`);
           }
 
-          return `<img ${attrs.join(" ")} >`
-        },
-      },
-    })
+          return `<img ${attrs.join(" ")} >`;
+        }
+      }
+    });
 }

+ 15 - 3
src/actions/compileSass/README.md

@@ -1,10 +1,14 @@
 # compileSass
 
-Compiles SCSS to compressed CSS.
+Compiles SCSS to compressed CSS, including official Sass `pkg:` package imports.
 
 ## actionConfig options
 - `filePath` (string, required): Input SCSS file path (computed from `jobConfig`).
 - `fileOutputPath` (string, required): Output CSS file path (computed from `jobConfig`).
+- `assetOutputDirsByExtension` (object, optional): Maps copied asset extensions to output dirs relative to `opts.outDir`.
+  Defaults to:
+  - fonts (`.woff2`, `.woff`, `.ttf`, `.otf`, `.eot`) -> `static/fonts/`
+  - `default` -> `static/assets/`
 
 ## Usage
 ```javascript
@@ -14,8 +18,16 @@ jobConfig: {
   outputDir: "static/styles/",
   outputFileExtension: ".css"
 },
-actionConfig: {}
+actionConfig: {
+  assetOutputDirsByExtension: {
+    ".woff2": "static/fonts/",
+    ".woff": "static/fonts/",
+    default: "static/assets/"
+  }
+}
 ```
 
 ## Notes
-- Dependency paths are captured from Sass `loadedUrls` for caching.
+- `compileSass` enables Sass `NodePackageImporter` automatically, so `@use "pkg:@fontsource/overlock" as overlock;` works out of the box.
+- Relative `url(...)` assets that resolve against loaded Sass or CSS files are copied into the configured output dirs and rewritten to site-rooted URLs such as `/static/fonts/example.woff2`.
+- Dependency paths include loaded Sass/CSS files and any copied asset source files for cache invalidation.

+ 260 - 6
src/actions/compileSass/index.js

@@ -1,19 +1,273 @@
+import fs from "node:fs/promises"
+import { constants as fsConstants } from "node:fs"
+import path from "node:path"
+import { fileURLToPath, pathToFileURL } from "node:url"
 import * as sass from "sass"
-import { slugifyString, writeFile } from "../../util/index.js"
+import { fileExists, slugifyString, writeFile } from "../../util/index.js"
+
+const DEFAULT_ASSET_OUTPUT_DIRS_BY_EXTENSION = {
+  ".woff2": "static/fonts/",
+  ".woff": "static/fonts/",
+  ".ttf": "static/fonts/",
+  ".otf": "static/fonts/",
+  ".eot": "static/fonts/",
+  default: "static/assets/",
+}
+
+function toPublicHref(filePath, outDir) {
+  const relativePath = path.relative(path.resolve(outDir), path.resolve(filePath))
+  if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+    throw new Error(`Output path escapes outDir: ${filePath}`)
+  }
+  return `/${relativePath.split(path.sep).join("/")}`
+}
+
+function buildAssetOutputPath(meta, outputDir, sourceFilePath) {
+  const outputPath = path.join(meta.opts.outDir, outputDir, path.basename(sourceFilePath))
+  const relativePath = path.relative(path.resolve(meta.opts.outDir), path.resolve(outputPath))
+  if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+    throw new Error(`Asset output path escapes outDir: ${outputPath}`)
+  }
+  return outputPath
+}
+
+function getLoadedFilePaths(loadedUrls) {
+  return loadedUrls
+    .filter(item => item?.protocol === "file:")
+    .map(item => fileURLToPath(item))
+}
+
+function getAssetOutputDir(sourceFilePath, actionConfig) {
+  const assetOutputDirsByExtension = {
+    ...DEFAULT_ASSET_OUTPUT_DIRS_BY_EXTENSION,
+    ...(actionConfig.assetOutputDirsByExtension || {}),
+  }
+  const extension = path.extname(sourceFilePath).toLowerCase()
+  const outputDir = assetOutputDirsByExtension[extension] ?? assetOutputDirsByExtension.default
+  if (outputDir == null) {
+    throw new Error(`No asset output dir configured for ${sourceFilePath} (${extension || "no extension"})`)
+  }
+  return outputDir
+}
+
+function isIgnoredCssUrl(urlValue) {
+  if (!urlValue) {
+    return true
+  }
+  return (
+    urlValue.startsWith("/") ||
+    urlValue.startsWith("#") ||
+    urlValue.startsWith("//") ||
+    /^[a-z][a-z0-9+.-]*:/i.test(urlValue)
+  )
+}
+
+function extractCssUrlTokens(css) {
+  const tokens = []
+  let index = 0
+
+  while (index < css.length) {
+    const start = css.indexOf("url(", index)
+    if (start === -1) {
+      break
+    }
+
+    let cursor = start + 4
+    while (cursor < css.length && /\s/.test(css[cursor])) {
+      cursor += 1
+    }
+
+    const quote = css[cursor] === '"' || css[cursor] === "'"
+      ? css[cursor]
+      : null
+    if (quote) {
+      cursor += 1
+    }
+
+    const valueStart = cursor
+    while (cursor < css.length) {
+      const currentChar = css[cursor]
+      if (quote) {
+        if (currentChar === quote && css[cursor - 1] !== "\\") {
+          break
+        }
+      } else if (currentChar === ")") {
+        break
+      }
+      cursor += 1
+    }
+
+    const rawValue = css.slice(valueStart, cursor)
+    if (quote && css[cursor] === quote) {
+      cursor += 1
+    }
+    while (cursor < css.length && /\s/.test(css[cursor])) {
+      cursor += 1
+    }
+    if (css[cursor] !== ")") {
+      index = start + 4
+      continue
+    }
+
+    tokens.push({
+      start,
+      end: cursor + 1,
+      value: rawValue.trim(),
+    })
+    index = cursor + 1
+  }
+
+  return tokens
+}
+
+async function resolveRelativeAssetUrl(assetUrl, loadedUrls) {
+  const candidates = new Map()
+
+  for (const stylesheetUrl of loadedUrls) {
+    if (stylesheetUrl?.protocol !== "file:") {
+      continue
+    }
+
+    const assetFileUrl = new URL(assetUrl, stylesheetUrl)
+    if (assetFileUrl.protocol !== "file:") {
+      continue
+    }
+
+    assetFileUrl.search = ""
+    assetFileUrl.hash = ""
+    const sourceFilePath = fileURLToPath(assetFileUrl)
+    if (!await fileExists(sourceFilePath)) {
+      continue
+    }
+
+    candidates.set(sourceFilePath, {
+      sourceFilePath,
+      stylesheetPath: fileURLToPath(stylesheetUrl),
+    })
+  }
+
+  if (candidates.size > 1) {
+    const matches = [...candidates.values()]
+      .map(item => `${item.sourceFilePath} via ${item.stylesheetPath}`)
+      .join(", ")
+    throw new Error(`Ambiguous asset URL "${assetUrl}" matched multiple files: ${matches}`)
+  }
+
+  return [...candidates.values()][0] || null
+}
+
+async function copyAssetFile(sourceFilePath, outputFilePath) {
+  await fs.mkdir(path.dirname(outputFilePath), { recursive: true })
+  try {
+    await fs.copyFile(sourceFilePath, outputFilePath, fsConstants.COPYFILE_EXCL)
+  } catch (err) {
+    if (err.code !== "EEXIST") {
+      throw err
+    }
+
+    const [sourceFile, existingFile] = await Promise.all([
+      fs.readFile(sourceFilePath),
+      fs.readFile(outputFilePath),
+    ])
+
+    if (!sourceFile.equals(existingFile)) {
+      throw new Error(`Asset collision for ${outputFilePath}: ${sourceFilePath} would overwrite a different file`)
+    }
+  }
+}
+
+async function rewriteCssAssetUrls({ css, loadedUrls, actionConfig, meta }) {
+  const tokens = extractCssUrlTokens(css)
+  if (!tokens.length) {
+    return {
+      css,
+      assetOutputPaths: [],
+      assetSourcePaths: [],
+    }
+  }
+
+  const assetOutputPaths = []
+  const assetSourcePaths = []
+  const copiedAssetsBySourcePath = new Map()
+  const sourcePathsByOutputPath = new Map()
+  let cursor = 0
+  let rewrittenCss = ""
+
+  for (const token of tokens) {
+    rewrittenCss += css.slice(cursor, token.start)
+    cursor = token.end
+
+    if (isIgnoredCssUrl(token.value)) {
+      rewrittenCss += css.slice(token.start, token.end)
+      continue
+    }
+
+    const resolvedAsset = await resolveRelativeAssetUrl(token.value, loadedUrls)
+    if (!resolvedAsset) {
+      rewrittenCss += css.slice(token.start, token.end)
+      continue
+    }
+
+    const outputDir = getAssetOutputDir(resolvedAsset.sourceFilePath, actionConfig)
+    const outputFilePath = buildAssetOutputPath(meta, outputDir, resolvedAsset.sourceFilePath)
+    const existingSourcePath = sourcePathsByOutputPath.get(outputFilePath)
+
+    if (existingSourcePath && existingSourcePath !== resolvedAsset.sourceFilePath) {
+      throw new Error(
+        `Asset collision for ${outputFilePath}: ${resolvedAsset.sourceFilePath} conflicts with ${existingSourcePath}`,
+      )
+    }
+
+    if (!copiedAssetsBySourcePath.has(resolvedAsset.sourceFilePath)) {
+      await copyAssetFile(resolvedAsset.sourceFilePath, outputFilePath)
+      copiedAssetsBySourcePath.set(resolvedAsset.sourceFilePath, outputFilePath)
+      sourcePathsByOutputPath.set(outputFilePath, resolvedAsset.sourceFilePath)
+      assetOutputPaths.push(outputFilePath)
+      assetSourcePaths.push(resolvedAsset.sourceFilePath)
+    }
+
+    const resolvedUrl = new URL(token.value, pathToFileURL(resolvedAsset.stylesheetPath))
+    const publicHref = toPublicHref(outputFilePath, meta.opts.outDir)
+    rewrittenCss += `url("${publicHref}${resolvedUrl.search}${resolvedUrl.hash}")`
+  }
+
+  rewrittenCss += css.slice(cursor)
+
+  return {
+    css: rewrittenCss,
+    assetOutputPaths,
+    assetSourcePaths,
+  }
+}
 
 export async function compileSass({ config: actionConfig, meta }) {
   const filePath = actionConfig.filePath
   const fileOutputPath = actionConfig.fileOutputPath
-  const result = await sass.compileAsync(filePath, { style: "compressed" })
-  await writeFile(fileOutputPath, result.css)
+  const runDir = path.resolve(meta.opts.runDir || process.cwd())
+  const result = await sass.compileAsync(filePath, {
+    style: "compressed",
+    importers: [new sass.NodePackageImporter(runDir)],
+  })
+  const {
+    css,
+    assetOutputPaths,
+    assetSourcePaths,
+  } = await rewriteCssAssetUrls({
+    css: result.css,
+    loadedUrls: result.loadedUrls,
+    actionConfig,
+    meta,
+  })
+  await writeFile(fileOutputPath, css)
+  const loadedFilePaths = getLoadedFilePaths(result.loadedUrls)
   return {
-    paths: [fileOutputPath],
+    paths: [fileOutputPath, ...assetOutputPaths],
     ref: slugifyString(fileOutputPath),
     detail: {
-      href: fileOutputPath.replace(meta.opts.outDir, "/"),
+      href: toPublicHref(fileOutputPath, meta.opts.outDir),
     },
     deps: {
-      paths: [...result.loadedUrls.map(item => item.pathname)],
+      paths: [...new Set([...loadedFilePaths, ...assetSourcePaths])],
     },
   }
 }

+ 33 - 0
src/actions/generateRss/README.md

@@ -0,0 +1,33 @@
+# generateRss
+
+Generates an RSS 2.0 feed from state inputs selected via `jobConfig.stateSelectors`.
+
+## actionConfig options
+- `outputFileName` (string, optional): Output filename in `opts.outDir`. Defaults to `rss.xml`.
+- `fileOutputPath` (string, optional): Explicit RSS output path override.
+- `title` (string, optional): Feed title. Defaults to `opts.site.name`.
+- `description` (string, optional): Feed description. Defaults to `opts.site.description`.
+- `language` (string, optional): Feed language. Defaults to `opts.site.language`.
+- `selfHref` (string, optional): Feed URL path used for the Atom self link. Defaults to `outputFileName`.
+- `generator` (string, optional): Generator string. Defaults to `Rhedyn`.
+- `maxItems` (number, optional): Limit the number of emitted feed items.
+- `lastBuildDate` (string|Date, optional): Override the channel `lastBuildDate`.
+
+## Usage
+```javascript
+action: generateRss,
+jobConfig: {
+  stateSelectors: ["resources.blog-latest.detail"],
+  expand: false,
+  skipCache: true
+},
+actionConfig: {
+  outputFileName: "rss.xml"
+}
+```
+
+## Notes
+- Requires `opts.site.url` to be set to a valid URL.
+- Emits one item per unique `href` found in inputs, preserving input order.
+- Uses `modified`, then `date`, then `pubDate`, then `published` for item dates.
+- Uses `description`, then `summary`, then `excerpt` for item descriptions.

+ 248 - 0
src/actions/generateRss/index.js

@@ -0,0 +1,248 @@
+import path from "node:path"
+import {
+  slugifyString,
+  writeFile,
+} from "../../util/index.js"
+
+function getSiteBaseUrl(siteUrl) {
+  if (typeof siteUrl !== "string" || !siteUrl.trim()) {
+    throw new Error("Cannot generate rss.xml without opts.site.url")
+  }
+  try {
+    return new URL(siteUrl)
+  } catch {
+    throw new Error(`Cannot generate rss.xml with invalid opts.site.url: ${siteUrl}`)
+  }
+}
+
+function escapeXml(value) {
+  return String(value)
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&apos;")
+}
+
+function normaliseHref(href) {
+  if (typeof href !== "string" || !href.trim()) {
+    return null
+  }
+  const trimmedHref = href.trim()
+  if (/^[a-z][a-z\d+\-.]*:\/\//i.test(trimmedHref)) {
+    return trimmedHref
+  }
+  if (trimmedHref === "/") {
+    return "/"
+  }
+  return trimmedHref.startsWith("/") ? trimmedHref : `/${trimmedHref}`
+}
+
+function normaliseDate(value) {
+  if (!value) {
+    return null
+  }
+  if (value instanceof Date) {
+    return Number.isNaN(value.getTime()) ? null : value
+  }
+  if (typeof value === "string") {
+    const trimmed = value.trim()
+    if (!trimmed) {
+      return null
+    }
+    if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
+      const date = new Date(`${trimmed}T00:00:00.000Z`)
+      return Number.isNaN(date.getTime()) ? null : date
+    }
+  }
+  const date = new Date(value)
+  return Number.isNaN(date.getTime()) ? null : date
+}
+
+function getEntryDate(entry) {
+  return (
+    normaliseDate(entry?.modified) ||
+    normaliseDate(entry?.date) ||
+    normaliseDate(entry?.pubDate) ||
+    normaliseDate(entry?.published)
+  )
+}
+
+function getEntryTitle(entry) {
+  if (typeof entry?.title === "string" && entry.title.trim()) {
+    return entry.title.trim()
+  }
+  if (typeof entry?.headline === "string" && entry.headline.trim()) {
+    return entry.headline.trim()
+  }
+  if (typeof entry?.name === "string" && entry.name.trim()) {
+    return entry.name.trim()
+  }
+  return null
+}
+
+function getEntryDescription(entry) {
+  const description = entry?.description || entry?.summary || entry?.excerpt
+  return typeof description === "string" && description.trim()
+    ? description.trim()
+    : null
+}
+
+function getEntryAuthors(entry) {
+  const source = entry?.author || entry?.authors
+  if (!source) {
+    return []
+  }
+  const authors = Array.isArray(source) ? source : [source]
+  return authors
+    .map(author => {
+      if (typeof author === "string" && author.trim()) {
+        return author.trim()
+      }
+      if (author && typeof author === "object" && typeof author.name === "string" && author.name.trim()) {
+        return author.name.trim()
+      }
+      return null
+    })
+    .filter(Boolean)
+}
+
+function getEntryCategories(entry) {
+  const source = entry?.tags || entry?.categories
+  if (!source) {
+    return []
+  }
+  const categories = Array.isArray(source) ? source : [source]
+  return categories
+    .map(category => {
+      if (typeof category !== "string") {
+        return null
+      }
+      const trimmed = category.trim()
+      return trimmed || null
+    })
+    .filter(Boolean)
+}
+
+function buildItemXml({ title, description, link, guid, pubDate, authors, categories }) {
+  return [
+    "    <item>",
+    `      <title>${escapeXml(title)}</title>`,
+    `      <link>${escapeXml(link)}</link>`,
+    guid === link
+      ? `      <guid isPermaLink="true">${escapeXml(guid)}</guid>`
+      : `      <guid>${escapeXml(guid)}</guid>`,
+    ...(pubDate ? [`      <pubDate>${escapeXml(pubDate)}</pubDate>`] : []),
+    ...(description ? [`      <description>${escapeXml(description)}</description>`] : []),
+    ...authors.map(author => `      <dc:creator>${escapeXml(author)}</dc:creator>`),
+    ...categories.map(category => `      <category>${escapeXml(category)}</category>`),
+    "    </item>",
+  ].join("\n")
+}
+
+export async function generateRss({ config: actionConfig, meta }) {
+  const outDir = meta.opts.outDir
+  const site = meta.opts.site || {}
+  const siteBaseUrl = getSiteBaseUrl(site.url)
+  const outputFileName = actionConfig.outputFileName || "rss.xml"
+  const fileOutputPath =
+    actionConfig.fileOutputPath || path.join(outDir, outputFileName)
+  const inputs = Array.isArray(actionConfig.inputs)
+    ? actionConfig.inputs
+    : []
+  const maxItems =
+    Number.isInteger(actionConfig.maxItems) && actionConfig.maxItems > 0
+      ? actionConfig.maxItems
+      : null
+  const title = actionConfig.title || site.name || siteBaseUrl.hostname
+  const description = actionConfig.description || site.description || title
+  const language = actionConfig.language || site.language
+  const generator = actionConfig.generator || "Rhedyn"
+  const selfHref = normaliseHref(actionConfig.selfHref || outputFileName)
+  const feedUrl = selfHref
+    ? new URL(selfHref, siteBaseUrl).toString()
+    : null
+
+  const seenLinks = new Set()
+  const items = []
+
+  for (const input of inputs) {
+    if (!input || typeof input !== "object") {
+      continue
+    }
+    const href = normaliseHref(input.href)
+    if (!href) {
+      continue
+    }
+    const link = new URL(href, siteBaseUrl).toString()
+    if (seenLinks.has(link)) {
+      continue
+    }
+    seenLinks.add(link)
+
+    const entryDate = getEntryDate(input)
+    const titleValue = getEntryTitle(input) || link
+    const guid = typeof input.guid === "string" && input.guid.trim()
+      ? input.guid.trim()
+      : link
+
+    items.push({
+      title: titleValue,
+      description: getEntryDescription(input),
+      link,
+      guid,
+      pubDate: entryDate ? entryDate.toUTCString() : null,
+      authors: getEntryAuthors(input),
+      categories: getEntryCategories(input),
+      entryDate,
+    })
+  }
+
+  const feedItems = maxItems == null
+    ? items
+    : items.slice(0, maxItems)
+  const latestItemDate = feedItems.reduce(
+    (latest, item) => {
+      if (!item.entryDate) {
+        return latest
+      }
+      if (!latest || item.entryDate > latest) {
+        return item.entryDate
+      }
+      return latest
+    },
+    null,
+  )
+  const lastBuildDate = normaliseDate(actionConfig.lastBuildDate || latestItemDate)
+
+  const rss = [
+    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
+    "<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">",
+    "  <channel>",
+    `    <title>${escapeXml(title)}</title>`,
+    `    <link>${escapeXml(siteBaseUrl.toString())}</link>`,
+    `    <description>${escapeXml(description)}</description>`,
+    ...(language ? [`    <language>${escapeXml(language)}</language>`] : []),
+    ...(feedUrl
+      ? [`    <atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />`]
+      : []),
+    `    <generator>${escapeXml(generator)}</generator>`,
+    ...(lastBuildDate
+      ? [`    <lastBuildDate>${escapeXml(lastBuildDate.toUTCString())}</lastBuildDate>`]
+      : []),
+    ...feedItems.map(buildItemXml),
+    "  </channel>",
+    "</rss>",
+    "",
+  ].join("\n")
+
+  await writeFile(fileOutputPath, rss)
+
+  return {
+    detail: {
+      itemCount: feedItems.length,
+    },
+    paths: [fileOutputPath],
+    ref: slugifyString(fileOutputPath),
+  }
+}

+ 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 { generateRss } from "./generateRss/index.js"
 export { generateRouteAssets } from "./generateRouteAssets/index.js"
 export { generateSitemap } from "./generateSitemap/index.js"
 export { generateTaxonomy } from "./generateTaxonomy/index.js"

+ 1 - 1
src/actions/renderMarkdownToHtml/README.md

@@ -21,5 +21,5 @@ actionConfig: {}
 - 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.
+- Route blocks render with stable `.route-block*` classes but without injected CSS or inline styling; the consuming app is responsible for presentation.
 - Respects `opts.markdown.allowHtml` to enable or strip raw HTML in markdown.

+ 1 - 1
src/actions/renderMarkdownWithTemplate/README.md

@@ -30,7 +30,7 @@ actionConfig: {
 - 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.
+- Route blocks render with stable `.route-block*` classes but without injected CSS or inline styling; the consuming app is responsible for presentation.
 - 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.

+ 132 - 98
src/defaults.js

@@ -2,6 +2,7 @@ import {
   compileSass,
   copy,
   generateFavicons,
+  generateRss,
   generateRouteAssets,
   generateSitemap,
   imageToWebP,
@@ -10,8 +11,8 @@ import {
   renderIndex,
   renderMarkdownToMeta,
   renderMarkdownWithTemplate,
-  generateTaxonomy,
-} from "./actions/index.js"
+  generateTaxonomy
+} from "./actions/index.js";
 
 export const tasks = [
   [
@@ -23,7 +24,7 @@ export const tasks = [
         inputFiles: [{ pattern: "images/content/*.jpg" }],
         stripPaths: ["images/content/"],
         outputDir: "images/",
-        outputFileExtension: ".webp",
+        outputFileExtension: ".webp"
       },
       actionConfig: {
         imageSizes: [
@@ -33,10 +34,10 @@ export const tasks = [
           "1366w",
           "1600w",
           "1920w",
-          "2560w",
+          "2560w"
         ],
-        quality: 80,
-      },
+        quality: 80
+      }
     },
     {
       key: "styles",
@@ -46,9 +47,9 @@ export const tasks = [
         inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
         stripPaths: ["styles/"],
         outputDir: "static/styles/",
-        outputFileExtension: ".css",
+        outputFileExtension: ".css"
       },
-      actionConfig: {},
+      actionConfig: {}
     },
     {
       key: "icons",
@@ -58,9 +59,9 @@ export const tasks = [
         inputFiles: [{ pattern: "images/icons/*.svg" }],
         stripPaths: ["images/"],
         outputDir: "static/",
-        outputFileExtension: ".svg",
+        outputFileExtension: ".svg"
       },
-      actionConfig: {},
+      actionConfig: {}
     },
     {
       key: "static-files",
@@ -68,9 +69,9 @@ export const tasks = [
       action: copy,
       jobConfig: {
         inputFiles: [{ pattern: "static/*" }],
-        stripPaths: ["static/"],
+        stripPaths: ["static/"]
       },
-      actionConfig: {},
+      actionConfig: {}
     },
     {
       key: "favicons",
@@ -79,9 +80,9 @@ export const tasks = [
       jobConfig: {
         inputFiles: [{ pattern: "images/favicon/*" }],
         stripPaths: ["images/favicon/"],
-        outputDir: "static/meta/",
+        outputDir: "static/meta/"
       },
-      actionConfig: {},
+      actionConfig: {}
     },
     {
       key: "routes",
@@ -91,7 +92,7 @@ export const tasks = [
         inputFiles: [{ pattern: "gpx/**/*.gpx" }],
         stripPaths: ["gpx/"],
         outputDir: "routes/",
-        outputFileExtension: ".gpx",
+        outputFileExtension: ".gpx"
       },
       actionConfig: {
         imageSizes: [
@@ -101,22 +102,22 @@ export const tasks = [
           "1366w",
           "1600w",
           "1920w",
-          "2560w",
+          "2560w"
         ],
-        quality: 80,
-      },
-    },
+        quality: 80
+      }
+    }
   ],
   {
-    key: "blog-markdown",
-    name: "Blog Markdown",
+    key: "posts-markdown",
+    name: "Posts Markdown",
     action: renderMarkdownToMeta,
     jobConfig: {
-      inputFiles: [{ pattern: "markdown/blog/*.md" }],
+      inputFiles: [{ pattern: "markdown/posts/*.md" }],
       stripPaths: ["markdown/"],
-      outputFileExtension: ".html",
+      outputFileExtension: ".html"
     },
-    actionConfig: {},
+    actionConfig: {}
   },
   {
     key: "markdown",
@@ -125,9 +126,9 @@ export const tasks = [
     jobConfig: {
       inputFiles: [{ pattern: "markdown/*.md" }],
       stripPaths: ["markdown/"],
-      outputFileExtension: ".html",
+      outputFileExtension: ".html"
     },
-    actionConfig: {},
+    actionConfig: {}
   },
   [
     {
@@ -135,53 +136,71 @@ export const tasks = [
       name: "Author Taxonomy",
       action: generateTaxonomy,
       jobConfig: {
-        stateSelectors: ["resources.blog-markdown"],
+        stateSelectors: ["resources.posts-markdown"],
         expand: false,
-        skipCache: true,
+        skipCache: true
       },
       actionConfig: {
         indexOn: "author",
         orderBy: "date",
         properties: [
-          "title", "href", "date", "modified", "author", "tags", "description",
+          "title",
+          "href",
+          "date",
+          "modified",
+          "author",
+          "tags",
+          "description"
         ],
-        sortAscending: false,
-      },
+        sortAscending: false
+      }
     },
     {
       key: "tag-taxonomy",
       name: "Tag Taxonomy",
       action: generateTaxonomy,
       jobConfig: {
-        stateSelectors: ["resources.blog-markdown"],
+        stateSelectors: ["resources.posts-markdown"],
         expand: false,
-        skipCache: true,
+        skipCache: true
       },
       actionConfig: {
         indexOn: "tags",
         orderBy: "date",
         properties: [
-          "title", "href", "date", "modified", "author", "tags", "description",
+          "title",
+          "href",
+          "date",
+          "modified",
+          "author",
+          "tags",
+          "description"
         ],
-        sortAscending: false,
-      },
+        sortAscending: false
+      }
     },
     {
-      key: "blog-latest",
-      name: "Blog Latest",
+      key: "posts-latest",
+      name: "Posts Latest",
       action: generateTaxonomy,
       jobConfig: {
-        stateSelectors: ["resources.blog-markdown"],
+        stateSelectors: ["resources.posts-markdown"],
         expand: false,
-        skipCache: true,
+        skipCache: true
       },
       actionConfig: {
         orderBy: "date",
         properties: [
-          "title", "href", "date", "modified", "author", "tags", "description",
+          "title",
+          "href",
+          "date",
+          "modified",
+          "author",
+          "tags",
+          "description"
         ],
-        sortAscending: false,
-      },
+        sortAscending: false
+      }
     },
     {
       key: "includes",
@@ -190,10 +209,10 @@ export const tasks = [
       jobConfig: {
         inputFiles: [{ pattern: "includes/*.hbs" }],
         stripPaths: ["includes/"],
-        outputFileExtension: ".html",
+        outputFileExtension: ".html"
       },
-      actionConfig: {},
-    },
+      actionConfig: {}
+    }
   ],
   [
     {
@@ -203,28 +222,28 @@ export const tasks = [
       jobConfig: {
         inputFiles: [{ pattern: "markdown/*.md" }],
         stripPaths: ["markdown/"],
-        outputFileExtension: ".html",
+        outputFileExtension: ".html"
       },
       actionConfig: {
         templateDirs: ["templates/", "~/.rhedyn/templates/"],
         partialDirs: ["partials/", "~/.rhedyn/partials/"],
-        defaultTemplate: "page",
-      },
+        defaultTemplate: "page"
+      }
     },
     {
-      key: "render-blog-pages",
-      name: "Render Blog Pages",
+      key: "render-post-pages",
+      name: "Render Post Pages",
       action: renderMarkdownWithTemplate,
       jobConfig: {
-        inputFiles: [{ pattern: "markdown/blog/*.md" }],
+        inputFiles: [{ pattern: "markdown/posts/*.md" }],
         stripPaths: ["markdown/"],
-        outputFileExtension: ".html",
+        outputFileExtension: ".html"
       },
       actionConfig: {
         templateDirs: ["templates/", "~/.rhedyn/templates/"],
         partialDirs: ["partials/", "~/.rhedyn/partials/"],
-        defaultTemplate: "article",
-      },
+        defaultTemplate: "article"
+      }
     },
     {
       key: "render-author-indexes",
@@ -233,15 +252,15 @@ export const tasks = [
       jobConfig: {
         stateSelectors: ["resources.author-taxonomy.detail"],
         outputFileExtension: ".html",
-        outputDir: "blog/by-author/",
+        outputDir: "posts/by-author/",
         buildFilePath: true,
-        itemsPerPage: 10,
+        itemsPerPage: 10
       },
       actionConfig: {
         writeOut: true,
         templateDirs: ["templates/", "~/.rhedyn/templates/"],
-        partialDirs: ["partials/", "~/.rhedyn/partials/"],
-      },
+        partialDirs: ["partials/", "~/.rhedyn/partials/"]
+      }
     },
     {
       key: "render-tag-indexes",
@@ -250,64 +269,79 @@ export const tasks = [
       jobConfig: {
         stateSelectors: ["resources.tag-taxonomy.detail"],
         outputFileExtension: ".html",
-        outputDir: "blog/by-tag/",
+        outputDir: "posts/by-tag/",
         buildFilePath: true,
-        itemsPerPage: 10,
+        itemsPerPage: 10
       },
       actionConfig: {
         writeOut: true,
         templateDirs: ["templates/", "~/.rhedyn/templates/"],
-        partialDirs: ["partials/", "~/.rhedyn/partials/"],
-      },
+        partialDirs: ["partials/", "~/.rhedyn/partials/"]
+      }
     },
     {
-      key: "render-blog-home",
-      name: "Render Blog Home",
+      key: "render-posts-home",
+      name: "Render Posts Home",
       action: renderIndex,
       jobConfig: {
-        stateSelectors: ["resources.blog-latest.detail"],
+        stateSelectors: ["resources.posts-latest.detail"],
         outputFileExtension: ".html",
-        outputDir: "blog/",
+        outputDir: "posts/",
         expand: false,
         outputFileName: "index",
         buildFilePath: true,
-        itemsPerPage: 10,
+        itemsPerPage: 10
       },
       actionConfig: {
         writeOut: true,
         templateDirs: ["templates/", "~/.rhedyn/templates/"],
         partialDirs: ["partials/", "~/.rhedyn/partials/"],
-        title: "Blog",
-      },
-    },
+        title: "Posts"
+      }
+    }
   ],
-  {
-    key: "sitemap",
-    name: "Sitemap",
-    action: generateSitemap,
-    jobConfig: {
-      stateSelectors: [
-        "resources.render-pages",
-        "resources.render-blog-pages",
-        "resources.render-author-indexes",
-        "resources.render-tag-indexes",
-        "resources.render-blog-home",
-      ],
-      expand: false,
-      skipCache: true,
-    },
-    actionConfig: {
-      outputFileName: "sitemap.xml",
+  [
+    {
+      key: "sitemap",
+      name: "Sitemap",
+      action: generateSitemap,
+      jobConfig: {
+        stateSelectors: [
+          "resources.render-pages",
+          "resources.render-post-pages",
+          "resources.render-author-indexes",
+          "resources.render-tag-indexes",
+          "resources.render-posts-home"
+        ],
+        expand: false,
+        skipCache: true
+      },
+      actionConfig: {
+        outputFileName: "sitemap.xml"
+      }
     },
-  },
-]
+    {
+      key: "rss",
+      name: "RSS Feed",
+      action: generateRss,
+      jobConfig: {
+        stateSelectors: ["resources.posts-latest.detail"],
+        expand: false,
+        skipCache: true
+      },
+      actionConfig: {
+        outputFileName: "rss.xml"
+      }
+    }
+  ]
+];
 
 export const opts = {
   outDir: "dist/",
   runDir: process.cwd(),
   cacheDir: ".cache",
   include: {
-    styles: [{ pattern: "~/.rhedyn/styles/*.scss" }],
+    styles: [{ pattern: "~/.rhedyn/styles/*.scss" }]
   },
   clean: true,
   ignoreExisting: false,
@@ -315,7 +349,7 @@ export const opts = {
   includeStateValues: true,
   itemsPerPage: 25,
   markdown: {
-    allowHtml: false,
+    allowHtml: false
   },
   site: {
     name: "Website generated by Rhedyn",
@@ -325,13 +359,13 @@ export const opts = {
     url: "https://www.leakypixel.net",
     language: "en-GB",
     backgroundColor: "#22242c",
-    themeColor: "#f00",
-  },
-}
+    themeColor: "#f00"
+  }
+};
 
 const defaults = {
   opts,
-  tasks,
-}
+  tasks
+};
 
-export default defaults
+export default defaults;

+ 48 - 0
test/markdownRoutes.test.js

@@ -0,0 +1,48 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { createMarkdownRenderer } from "../src/actions/_shared/markdown.js";
+
+test("route blocks render classes without injected CSS or inline styles", () => {
+  const renderer = createMarkdownRenderer({
+    resources: {
+      routes: {
+        "mountain-loop": {
+          detail: {
+            name: "mountain-loop",
+            gpxHref: "/routes/mountain-loop.gpx",
+            srcSet: [
+              ["/routes/mountain-loop-640.webp", "640w"],
+              ["/routes/mountain-loop-1024.webp", "1024w"],
+            ],
+            aspectRatio: 1.6,
+            attribution: "© OpenStreetMap contributors",
+          },
+        },
+      },
+    },
+  });
+
+  const html = renderer("::route mountain-loop");
+
+  assert.equal(html.includes("<style"), false);
+  assert.equal(html.includes(" style="), false);
+  assert.equal(html.includes('class="route-block"'), true);
+  assert.equal(html.includes('class="route-block__image"'), true);
+  assert.equal(html.includes('class="route-block__caption"'), true);
+  assert.equal(html.includes('class="route-block__download"'), true);
+  assert.equal(html.includes('class="route-block__attribution"'), true);
+  assert.equal(html.includes('src="/routes/mountain-loop-640.webp"'), true);
+  assert.equal(html.includes('href="/routes/mountain-loop.gpx"'), true);
+  assert.equal(html.includes("© OpenStreetMap contributors"), true);
+});
+
+test("missing route blocks retain classes without inline styles", () => {
+  const renderer = createMarkdownRenderer({ resources: {} });
+
+  const html = renderer("::route missing-route");
+
+  assert.equal(
+    html,
+    '<aside class="route-block route-block--missing">Route not found: missing-route</aside>',
+  );
+});