瀏覽代碼

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

Craig Fletcher 1 月之前
父節點
當前提交
310b868bcf

+ 44 - 8
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, 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
 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.
@@ -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:**
 **Task Properties:**
 - `key`: Task identifier used in state/resources (required)
 - `key`: Task identifier used in state/resources (required)
 - `name`: Human-readable task label used in logs (optional, defaults to `key`)
 - `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
 - `copy`: Copies files without processing
 - `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
+- `generateRss`: Generates an RSS feed from state inputs
 - `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
 - `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
@@ -266,25 +299,28 @@ The default configuration includes:
    - `favicons`: Generates favicon sets from source images
    - `favicons`: Generates favicon sets from source images
 
 
 2. **Sequential tasks:**
 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)
    - `markdown`: Parses general frontmatter metadata (no HTML rendering)
 
 
 3. **Parallel processing group:**
 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
    - `includes`: Renders template includes to HTML
 
 
 4. **Parallel processing group:**
 4. **Parallel processing group:**
    - `render-pages`: Renders standard pages from markdown files
    - `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-author-indexes`: Renders author index pages and index list
    - `render-tag-indexes`: Renders tag 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:**
 5. **Sequential task:**
    - `sitemap`: Generates `sitemap.xml` from rendered page state entries
    - `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 In Markdown
 
 
 Route blocks can be embedded in markdown by GPX basename:
 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`.
 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
 ## Logging
 
 

+ 1 - 0
package.json

@@ -5,6 +5,7 @@
   "main": "src/index.js",
   "main": "src/index.js",
   "scripts": {
   "scripts": {
     "generate": "node src/index.js",
     "generate": "node src/index.js",
+    "test": "node --test",
     "build": "nexe -i src/index.js -o dist/rhedyn --build --python=$(which python3)",
     "build": "nexe -i src/index.js -o dist/rhedyn --build --python=$(which python3)",
     "lint-fix": "eslint --fix src/**/*.js"
     "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) {
 function normaliseRouteRef(routeName) {
   if (typeof routeName !== "string") {
   if (typeof routeName !== "string") {
-    return ""
+    return "";
   }
   }
-  return slugifyString(routeName.replace(/\.gpx$/i, ""))
+  return slugifyString(routeName.replace(/\.gpx$/i, ""));
 }
 }
 
 
 function getRouteResource(meta, routeName) {
 function getRouteResource(meta, routeName) {
-  const routes = meta.resources?.routes
+  const routes = meta.resources?.routes;
   if (!routes) {
   if (!routes) {
-    return null
+    return null;
   }
   }
-  const directRef = normaliseRouteRef(routeName)
+  const directRef = normaliseRouteRef(routeName);
   if (directRef && routes[directRef]) {
   if (directRef && routes[directRef]) {
-    return routes[directRef]
+    return routes[directRef];
   }
   }
-  const fallbackRef = slugifyString(routeName)
+  const fallbackRef = slugifyString(routeName);
   if (fallbackRef && routes[fallbackRef]) {
   if (fallbackRef && routes[fallbackRef]) {
-    return routes[fallbackRef]
+    return routes[fallbackRef];
   }
   }
-  return null
+  return null;
 }
 }
 
 
 function renderRouteBlock(meta, rawRouteName) {
 function renderRouteBlock(meta, rawRouteName) {
-  const routeName = String(rawRouteName || "").trim()
+  const routeName = String(rawRouteName || "").trim();
   if (!routeName) {
   if (!routeName) {
-    return ""
+    return "";
   }
   }
-  const routeResource = getRouteResource(meta, routeName)
+  const routeResource = getRouteResource(meta, routeName);
   if (!routeResource) {
   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) {
   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
   const srcSetString = imageSrcSet
     .map(([src, size]) => `${src} ${size}`)
     .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
   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 [
   return [
     `<figure class="route-block" data-route="${escapeHtmlAttr(displayName)}">`,
     `<figure class="route-block" data-route="${escapeHtmlAttr(displayName)}">`,
     image,
     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) {
 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 {
   return {
     extensions: [
     extensions: [
       {
       {
         name: "routeBlock",
         name: "routeBlock",
         level: "block",
         level: "block",
         start(src) {
         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) {
         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) {
           if (!match) {
-            return undefined
+            return undefined;
           }
           }
           return {
           return {
             type: "routeBlock",
             type: "routeBlock",
             raw: match[0],
             raw: match[0],
-            routeName: match[1].trim(),
-          }
+            routeName: match[1].trim()
+          };
         },
         },
         renderer(token) {
         renderer(token) {
-          return `${renderRouteStyles()}${renderRouteBlock(meta, token.routeName)}`
-        },
-      },
-    ],
-  }
+          return 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)
@@ -125,34 +114,36 @@ export function createMarkdownRenderer(meta) {
     .use({
     .use({
       renderer: {
       renderer: {
         html(html) {
         html(html) {
-          return allowHtml ? html : ""
+          return allowHtml ? html : "";
         },
         },
         image({ href, title, text }) {
         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) {
           if (foundSrcSet && foundSrcSet.detail.srcSet?.length > 0) {
             const srcSetString = foundSrcSet.detail.srcSet
             const srcSetString = foundSrcSet.detail.srcSet
               .map(src => src.join(" "))
               .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(
             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)}"`);
           }
           }
 
 
           if (title) {
           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
 # compileSass
 
 
-Compiles SCSS to compressed CSS.
+Compiles SCSS to compressed CSS, including official Sass `pkg:` package imports.
 
 
 ## actionConfig options
 ## actionConfig options
 - `filePath` (string, required): Input SCSS file path (computed from `jobConfig`).
 - `filePath` (string, required): Input SCSS file path (computed from `jobConfig`).
 - `fileOutputPath` (string, required): Output CSS 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
 ## Usage
 ```javascript
 ```javascript
@@ -14,8 +18,16 @@ jobConfig: {
   outputDir: "static/styles/",
   outputDir: "static/styles/",
   outputFileExtension: ".css"
   outputFileExtension: ".css"
 },
 },
-actionConfig: {}
+actionConfig: {
+  assetOutputDirsByExtension: {
+    ".woff2": "static/fonts/",
+    ".woff": "static/fonts/",
+    default: "static/assets/"
+  }
+}
 ```
 ```
 
 
 ## Notes
 ## 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 * 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 }) {
 export async function compileSass({ config: actionConfig, meta }) {
   const filePath = actionConfig.filePath
   const filePath = actionConfig.filePath
   const fileOutputPath = actionConfig.fileOutputPath
   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 {
   return {
-    paths: [fileOutputPath],
+    paths: [fileOutputPath, ...assetOutputPaths],
     ref: slugifyString(fileOutputPath),
     ref: slugifyString(fileOutputPath),
     detail: {
     detail: {
-      href: fileOutputPath.replace(meta.opts.outDir, "/"),
+      href: toPublicHref(fileOutputPath, meta.opts.outDir),
     },
     },
     deps: {
     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 { 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 { generateRss } from "./generateRss/index.js"
 export { generateRouteAssets } from "./generateRouteAssets/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"

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

@@ -21,5 +21,5 @@ actionConfig: {}
 - 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`.
 - 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 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.
 - 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`).
 - 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 render an image preview + GPX download link from `resources.routes`.
 - Route blocks include OpenStreetMap attribution when provided by route assets.
 - 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.
 - 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.

+ 132 - 98
src/defaults.js

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