瀏覽代碼

Refactoring and documenting processors -> actions

Craig Fletcher 2 周之前
父節點
當前提交
dfe0a14412

+ 48 - 0
CONTEXT.md

@@ -0,0 +1,48 @@
+# Codebase Context
+
+## Purpose
+Rhedyn is a small static site generator/asset pipeline. It reads a config file,
+runs tasks (optionally in parallel per step), and writes outputs to `opts.outDir`
+with caching based on file hashes and state access.
+
+## Entry Point and Flow
+- `src/index.js` is the CLI entrypoint. It loads config (user or defaults),
+  then iterates through steps in `tasks`, running each task group in parallel.
+- `src/lib.js` owns config loading, task expansion (file/state), caching, and
+  the task runner that calls action functions.
+
+## Configuration (current shape)
+- `src/defaults.js` exports `tasks` and `opts` used by `src/index.js`.
+- Tasks are objects (or arrays of objects for parallelism) with:
+  - `jobConfig` for file/state selection, expansion, output paths, and caching
+  - `actionConfig` for action-specific options
+  - `action` function and a `name`
+- Global config under `opts` controls output/cache dirs, logging, and site meta.
+
+## Task Expansion
+- File-based tasks expand input files via glob patterns (`readFilesByGlob`),
+  driven by `jobConfig.inputFiles`.
+- State-based tasks expand via selectors into `meta.resources`, with optional
+  pagination and auto-built output paths from `jobConfig` settings.
+- Tasks can opt out of expansion (`expand: false`) to run once with aggregated
+  inputs.
+
+## Caching
+- `src/cache.js` stores cache files per job. Cache keys are based on job IDs,
+  file hashes, and accessed state paths.
+- `createTrackedObject` wraps state/config so accessed properties are tracked
+  and used as cache dependencies.
+
+## Actions
+- `src/actions/` contains built-ins:
+  - `compileSass`, `renderMarkdownToHtml`, `renderTemplate`,
+    `renderMarkdownWithTemplate`, `optimiseSvg`, `copy`, `imageToWebP`,
+    `generateFavicons`, `generateTaxonomy`.
+- Actions receive `{ config, jobConfig, meta }` and return
+  `{ detail, paths, deps, ref }`. The `config` value maps to `actionConfig`.
+- Returned `ref` results are exposed via `meta.resources` for later tasks.
+
+## Utilities
+- `src/util/*` provides filesystem helpers, path manipulation, and simple
+  object/state utilities.
+- `src/logging.js` formats log output and implements log levels.

+ 52 - 34
README.md

@@ -58,8 +58,8 @@ considerably larger cache files.
 By default, rhedyn will look for `rhedyn.config.js` in the current directory and fall back to the default if not found.
 You can override this behaviour using the `-c /path/to/config.js` or `--config path/to/config.js` CLI switches.
 
-A working example of how to configure can be found in `src/defaults.js`, with the processors separated out to
-`src/processors.js` for tidiness. 
+A working example of how to configure can be found in `src/defaults.js`, with the actions separated out to
+`src/actions/` for tidiness. 
 
 Your `rhedyn.config.js` should export an object with "tasks" and "opts". These are detailed below.
 
@@ -69,7 +69,7 @@ If you want to extend the default config, it can be imported as `defaultConfig`
 
 The `opts` object in your config is for anything related to the project as a whole, rather than individual tasks. That
 means things like the base output directory, cache directory, site metadata, etc. It'll be passed to the
-processor function as a key on the `meta` object.
+action function as a key on the `meta` object.
 
 `opts` can also have an `include` property, an object containing task keys (e.g. "styles") and additional glob patterns to include for
 that task. The path can be anywhere, not just local to the project - I use it to
@@ -124,39 +124,54 @@ Each task object should look something like this:
 ```javascript
 {
   name: "styles",
-  inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
-  stripPaths: ["styles/"],
-  outputDir: "static/styles/",
-  outputFileExtension: ".css",
-  processor: compileSass
+  action: compileSass,
+  jobConfig: {
+    inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
+    stripPaths: ["styles/"],
+    outputDir: "static/styles/",
+    outputFileExtension: ".css"
+  },
+  actionConfig: {}
 }
 ```
 
 **Task Properties:**
 - `name`: Task identifier (required)
-- `inputFiles`: Array of glob pattern objects with `pattern` and optional `ignore` properties - if set, the task will be
-expanded for every file found
-- `stripPaths`: Array of path prefixes to remove from input paths when generating output paths
-- `outputDir`: Directory within `outDir` where processed files should be placed
-- `outputFileExtension`: File extension for processed files
-- `processor`: Function that processes the files (required)
-- `logLevel`: optionally override the log level for a specific task
-- Additional processor-specific properties (e.g., `imageSizes`, `quality` for image processing)
+- `action`: Function that processes the files (required)
+- `jobConfig`: Config for file/state selection, expansion, output paths, and caching (see below)
+- `actionConfig`: Config passed to the action (plus computed fields like `filePath` and `fileOutputPath`)
 
 **Input File Patterns:**
 ```javascript
-inputFiles: [
-  { pattern: "styles/**/*.scss", ignore: "**/_*.scss" },
-  { pattern: "images/*.jpg" },
-  { pattern: "static/*" }
-]
+jobConfig: {
+  inputFiles: [
+    { pattern: "styles/**/*.scss", ignore: "**/_*.scss" },
+    { pattern: "images/*.jpg" },
+    { pattern: "static/*" }
+  ]
+}
 ```
 
-#### processors
-
-A processor is a function that receives an object with `config` and `meta` properties and returns an object describing what was processed.
-
-A processor that returns a `ref` will have its `detail`, `paths`, `ref` and `fromCache` properties made available in
+**jobConfig options:**
+- `inputFiles`: Array of glob objects (`{ pattern, ignore?, dot? }`) used for file expansion.
+- `stateSelectors`: Array of dot-paths (e.g. `resources.markdown`) to expand from `meta`.
+- `expand`: When `false`, run a single job with aggregated inputs instead of expanding per file/state item.
+- `stripPaths`: Array of path prefixes removed from inputs when building output paths (supports `~`).
+- `outputDir`: Output directory relative to `opts.outDir` for generated files.
+- `outputFileExtension`: New extension appended to output files (e.g. `.html`).
+- `outputFileName`: Base filename used when building a single output path for state tasks.
+- `buildFilePath`: When `true`, compute and pass `fileOutputPath` into the action for state tasks.
+- `itemsPerPage`: Page size for state expansion pagination (defaults to `opts.itemsPerPage`).
+- `skipCache`: When `true`, disables cache usage for this job.
+- `deps`: Extra cache dependencies `{ paths?: string[], state?: string[] }` added to the job fingerprint.
+- `logLevel`: Override log level for this task only.
+- `jobId`: Override the job ID used for logging and caching.
+
+#### actions
+
+An action is a function that receives an object with `config`, `jobConfig`, and `meta` properties and returns an object describing what was processed.
+
+An action that returns a `ref` will have its `detail`, `paths`, `ref` and `fromCache` properties made available in
 `meta.resources`. For expanded tasks (multiple results), the `ref` is used as the key under the task name. For
 single-result tasks, the result is stored directly under the task name:
 ```javascript
@@ -177,13 +192,16 @@ single-result tasks, the result is stored directly under the task name:
 }
 ```
 
-The processor function signature:
+The action function signature:
 ```javascript
-async function myProcessor({ config, meta }) {
-  // config contains the task configuration plus file-specific properties:
+async function myAction({ config, jobConfig, meta }) {
+  // config is the actionConfig plus computed properties:
   // - filePath: path to the input file being processed
   // - fileOutputPath: calculated output path
   // - fileOutputDir: directory for the output file
+  // - inputs/pagination/stateKey when expanding from state
+  //
+  // jobConfig contains selection/expansion config (inputFiles, stateSelectors, etc.)
   
   // meta contains:
   // - opts: global configuration
@@ -203,9 +221,9 @@ async function myProcessor({ config, meta }) {
 }
 ```
 
-**Built-in Processors:**
+**Built-in Actions:**
 
-The following processors are available from `processors`:
+The following actions are available from `actions`:
 - `compileSass`: Compiles SCSS files to compressed CSS
 - `renderMarkdownWithTemplate`: Renders markdown with Handlebars templates, includes frontmatter support
 - `optimiseSvg`: Optimizes SVG files using SVGO
@@ -215,10 +233,10 @@ The following processors are available from `processors`:
 
 **Resources and Cross-Task References:**
 
-Processed files are made available to subsequent tasks via `meta.resources[taskName][ref]`. For example, the image processor makes processed images available to the markdown renderer for automatic srcset generation.
+Processed files are made available to subsequent tasks via `meta.resources[taskName][ref]`. For example, the image action makes processed images available to the markdown renderer for automatic srcset generation.
 
-Some examples can be found in `src/processors.js`, and you can find utility functions exported from this package as
-`utils`. The sample processors are also exported from this module as `processors`.
+Some examples can be found in `src/actions/`, and you can find utility functions exported from this package as
+`utils`. The sample actions are also exported from this module as `actions`.
 
 ## Default Tasks
 

+ 1 - 1
package.json

@@ -14,7 +14,7 @@
   "exports": {
     "./utils": "./src/util/index.js",
     "./defaultConfig": "./src/defaults.js",
-    "./processors": "./src/processors.js"
+    "./actions": "./src/actions/index.js"
   },
   "keywords": [],
   "author": "",

+ 39 - 0
src/actions/_shared/markdown.js

@@ -0,0 +1,39 @@
+import { marked } from "marked"
+import markedCodePreview from "marked-code-preview"
+import { escapeHtmlAttr, slugifyString } from "../../util/index.js"
+
+export function createMarkdownRenderer(meta) {
+  return marked
+    .use({ gfm: true })
+    .use(markedCodePreview)
+    .use({
+      renderer: {
+        image({ href, title, text }) {
+          const attrs = [`alt="${escapeHtmlAttr(text)}"`]
+
+          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\"")
+            attrs.push(
+              `style=\"aspect-ratio: ${escapeHtmlAttr(foundSrcSet.detail.aspectRatio)}\"`,
+            )
+          } else {
+            attrs.push(`src="${escapeHtmlAttr(href)}"`)
+          }
+
+          if (title) {
+            attrs.push(`title="${escapeHtmlAttr(title)}"`)
+          }
+
+          return `<img ${attrs.join(" ")} >`
+        },
+      },
+    })
+}

+ 87 - 0
src/actions/_shared/template-cache.js

@@ -0,0 +1,87 @@
+import fs from "fs/promises"
+import path from "path"
+import { glob } from "glob"
+import handlebars from "handlebars"
+import { expandTilde, firstFound } from "../../util/index.js"
+
+const templateCache = new Map()
+const partialCache = new Map()
+const registeredPartials = new Set()
+
+export function clearTemplateCache() {
+  templateCache.clear()
+  partialCache.clear()
+  registeredPartials.clear()
+}
+
+export async function loadPartials(partialDirs) {
+  if (!partialDirs || partialDirs.length === 0) {
+    return []
+  }
+
+  const loadedPaths = []
+
+  for (const dir of partialDirs) {
+    const expandedDir = expandTilde(dir)
+    const pattern = path.join(expandedDir, "*.hbs")
+
+    let files
+    try {
+      files = await glob(pattern)
+    } catch {
+      continue
+    }
+
+    for (const filePath of files) {
+      const fileName = path.basename(filePath, ".hbs")
+      // Remove leading underscore if present (e.g., _pagination -> pagination)
+      const partialName = fileName.startsWith("_") ? fileName.slice(1) : fileName
+
+      if (registeredPartials.has(partialName)) {
+        continue
+      }
+
+      if (!partialCache.has(filePath)) {
+        const content = await fs.readFile(filePath, "utf8")
+        partialCache.set(filePath, { name: partialName, content })
+      }
+
+      const partial = partialCache.get(filePath)
+      handlebars.registerPartial(partial.name, partial.content)
+      registeredPartials.add(partialName)
+      loadedPaths.push(filePath)
+    }
+  }
+
+  return loadedPaths
+}
+
+export async function findTemplatePath(templateDirs, templateName) {
+  const templatePath = await firstFound(templateDirs, `${templateName}.hbs`)
+  if (!templatePath) throw new Error(`Template not found: ${templateName}`)
+  return templatePath
+}
+
+export async function getTemplate(templatePath) {
+  if (!templateCache.has(templatePath)) {
+    const templateContent = await fs.readFile(templatePath, "utf8")
+    templateCache.set(templatePath, {
+      path: templatePath,
+      renderer: handlebars.compile(templateContent),
+    })
+  }
+  return templateCache.get(templatePath)
+}
+
+export async function getTemplateByName(templateDirs, templateName) {
+  if (!templateCache.has(templateName)) {
+    const templatePath = await firstFound(templateDirs, `${templateName}.hbs`)
+    if (!templatePath) throw new Error(`Template not found: ${templateName}`)
+    const templateContent = await fs.readFile(templatePath, "utf8")
+    templateCache.set(templateName, {
+      path: templatePath,
+      renderer: handlebars.compile(templateContent),
+    })
+  }
+  return templateCache.get(templateName)
+}

+ 21 - 0
src/actions/compileSass/README.md

@@ -0,0 +1,21 @@
+# compileSass
+
+Compiles SCSS to compressed CSS.
+
+## actionConfig options
+- `filePath` (string, required): Input SCSS file path (computed from `jobConfig`).
+- `fileOutputPath` (string, required): Output CSS file path (computed from `jobConfig`).
+
+## Usage
+```javascript
+action: compileSass,
+jobConfig: {
+  inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
+  outputDir: "static/styles/",
+  outputFileExtension: ".css"
+},
+actionConfig: {}
+```
+
+## Notes
+- Dependency paths are captured from Sass `loadedUrls` for caching.

+ 19 - 0
src/actions/compileSass/index.js

@@ -0,0 +1,19 @@
+import * as sass from "sass"
+import { slugifyString, writeFile } from "../../util/index.js"
+
+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)
+  return {
+    paths: [fileOutputPath],
+    ref: slugifyString(fileOutputPath),
+    detail: {
+      href: fileOutputPath.replace(meta.opts.outDir, "/"),
+    },
+    deps: {
+      paths: [...result.loadedUrls.map(item => item.pathname)],
+    },
+  }
+}

+ 21 - 0
src/actions/copy/README.md

@@ -0,0 +1,21 @@
+# copy
+
+Copies files without modification.
+
+## actionConfig options
+- `filePath` (string, required): Source file path (computed from `jobConfig`).
+- `fileOutputPath` (string, required): Destination file path (computed from `jobConfig`).
+- `fileOutputDir` (string, required): Destination directory (computed from `jobConfig`).
+
+## Usage
+```javascript
+action: copy,
+jobConfig: {
+  inputFiles: [{ pattern: "static/*" }],
+  stripPaths: ["static/"]
+},
+actionConfig: {}
+```
+
+## Notes
+- Ensures output directories exist before copying.

+ 13 - 0
src/actions/copy/index.js

@@ -0,0 +1,13 @@
+import fs from "fs/promises"
+import { slugifyString } from "../../util/index.js"
+
+export async function copy({ config: actionConfig }) {
+  const filePath = actionConfig.filePath
+  const fileOutputPath = actionConfig.fileOutputPath
+  await fs.mkdir(actionConfig.fileOutputDir, { recursive: true })
+  await fs.copyFile(filePath, fileOutputPath)
+  return {
+    paths: [fileOutputPath],
+    ref: slugifyString(fileOutputPath),
+  }
+}

+ 22 - 0
src/actions/generateFavicons/README.md

@@ -0,0 +1,22 @@
+# generateFavicons
+
+Generates favicon sets and manifest files using the `favicons` package.
+
+## actionConfig options
+- `filePath` (string, required): Source image path (computed from `jobConfig`).
+- `fileOutputDir` (string, required): Output directory (computed from `jobConfig`).
+- `name` (string, injected): Task name used for the result `ref`.
+
+## Usage
+```javascript
+action: generateFavicons,
+jobConfig: {
+  inputFiles: [{ pattern: "images/favicon/*" }],
+  outputDir: "static/meta/"
+},
+actionConfig: {}
+```
+
+## Notes
+- Uses `opts.site` metadata (name, colors, url, etc.) when available.
+- Returns combined HTML meta tags in `detail.htmlMeta`.

+ 73 - 0
src/actions/generateFavicons/index.js

@@ -0,0 +1,73 @@
+import path from "path"
+import favicons from "favicons"
+import { getCleanPath, writeFile } from "../../util/index.js"
+
+export async function generateFavicons({ meta, config: actionConfig }) {
+  const filePath = actionConfig.filePath
+  const fileOutputDir = actionConfig.fileOutputDir
+  // Configuration for favicons package
+  const configuration = {
+    path: getCleanPath(fileOutputDir, meta), // Path for overriding default icons path
+    appName: meta.opts.site?.name || "Website",
+    appShortName: meta.opts.site?.shortName || "Site",
+    appDescription: meta.opts.site?.description || "",
+    developerName: meta.opts.site?.author || "",
+    developerURL: meta.opts.site?.url || "",
+    dir: "auto",
+    lang: meta.opts.site?.language || "en-US",
+    background: meta.opts.site?.backgroundColor || "#ffffff",
+    theme_color: meta.opts.site?.themeColor || "#ffffff",
+    appleStatusBarStyle: "black-translucent",
+    display: "standalone",
+    orientation: "any",
+    scope: "/",
+    start_url: "/",
+    version: "1.0",
+    logging: false,
+    pixel_art: false,
+    loadManifestWithCredentials: false,
+    manifestMaskable: false,
+    icons: {
+      android: true,
+      appleIcon: true,
+      appleStartup: true,
+      favicons: true,
+      windows: true,
+      yandex: true,
+    },
+  }
+  try {
+    const response = await favicons(filePath, configuration)
+
+    // Write all generated images to disk
+    await Promise.all(
+      response.images.map(async image => {
+        const outputPath = path.join(fileOutputDir, image.name)
+        await writeFile(outputPath, image.contents)
+      }),
+    )
+
+    // Write all generated files (manifests, etc.) to disk
+    await Promise.all(
+      response.files.map(async file => {
+        const outputPath = path.join(fileOutputDir, file.name)
+        await writeFile(outputPath, file.contents)
+      }),
+    )
+
+    // Combine HTML meta tags
+    const htmlMeta = response.html.join("\n    ")
+    return {
+      detail: {
+        htmlMeta,
+      },
+      paths: [
+        ...response.images.map(img => path.join(fileOutputDir, img.name)),
+        ...response.files.map(file => path.join(fileOutputDir, file.name)),
+      ],
+      ref: actionConfig.name,
+    }
+  } catch (error) {
+    throw new Error(`Failed to generate favicons: ${error.message}`)
+  }
+}

+ 29 - 0
src/actions/generateTaxonomy/README.md

@@ -0,0 +1,29 @@
+# generateTaxonomy
+
+Builds a taxonomy or ordered list from a set of inputs.
+
+## actionConfig options
+- `inputs` (array, required): Items to index or sort (provided by the task runner).
+- `indexOn` (string, optional): Property name to group by (e.g. `"tags"`).
+- `orderBy` (string, optional): Property name to sort by (default: `"date"`).
+- `sortAscending` (boolean, optional): When `true`, sort ascending (default: descending).
+- `properties` (string[], optional): If set, pick only these properties from items.
+- `name` (string, injected): Task name used for the result `ref`.
+
+## Usage
+```javascript
+action: generateTaxonomy,
+jobConfig: {
+  stateSelectors: ["resources.markdown"],
+  expand: false
+},
+actionConfig: {
+  indexOn: "tags",
+  orderBy: "date",
+  properties: ["title", "href"]
+}
+```
+
+## Notes
+- When `indexOn` is set, returns a grouped object keyed by the index value.
+- When `indexOn` is omitted, returns a sorted list.

+ 56 - 0
src/actions/generateTaxonomy/index.js

@@ -0,0 +1,56 @@
+import _ from "lodash-es"
+
+export async function generateTaxonomy({ config: actionConfig }) {
+  if (!Array.isArray(actionConfig.inputs)) {
+    return {
+      detail: actionConfig.indexOn ? {} : [],
+      ref: actionConfig.name,
+    }
+  }
+  const allValues = actionConfig.inputs.reduce((values, curr) => {
+    const items = curr[actionConfig.indexOn]
+    if (!Array.isArray(items)) {
+      return values
+    }
+    items.forEach(v => values.add(v))
+    return values
+  }, new Set())
+  const orderBy = actionConfig.orderBy || "date"
+  const sortedInputs = actionConfig.sortAscending
+    ? _.sortBy(actionConfig.inputs, orderBy)
+    : _.sortBy(actionConfig.inputs, orderBy).reverse()
+  const taxonomy = actionConfig.indexOn
+    ? [...allValues.values()].reduce((groups, currentGroup) => {
+      const grouped = {
+        ...groups,
+        [currentGroup]: sortedInputs
+          .filter(item => {
+            const items = item?.[actionConfig.indexOn]
+            return Array.isArray(items) && items.includes(currentGroup)
+          })
+          .map(item => {
+            const entry = actionConfig.properties
+              ? actionConfig.properties.reduce(
+                (ent, prop) => ({ ...ent, [prop]: item[prop] }),
+                {},
+              )
+              : item
+            return entry
+          }),
+      }
+      return grouped
+    }, {})
+    : sortedInputs.map(item => {
+      const entry = actionConfig.properties
+        ? actionConfig.properties.reduce(
+          (ent, prop) => ({ ...ent, [prop]: item[prop] }),
+          {},
+        )
+        : item
+      return entry
+    })
+  return {
+    detail: taxonomy,
+    ref: actionConfig.name,
+  }
+}

+ 28 - 0
src/actions/imageToWebP/README.md

@@ -0,0 +1,28 @@
+# imageToWebP
+
+Generates responsive WebP variants from a source image.
+
+## actionConfig options
+- `filePath` (string, required): Source image path (computed from `jobConfig`).
+- `fileOutputDir` (string, required): Output directory (computed from `jobConfig`).
+- `outputFileExtension` (string, required): Output extension, typically `.webp` (computed from `jobConfig`).
+- `imageSizes` (string[]|number[], required): Sizes to generate (e.g. `"640w"`, `1024`).
+- `quality` (number, required): WebP quality (0-100).
+- `uniqueFilenames` (boolean, optional): When `true`, append a random suffix to filenames.
+
+## Usage
+```javascript
+action: imageToWebP,
+jobConfig: {
+  inputFiles: [{ pattern: "images/content/*.jpg" }],
+  outputDir: "images/",
+  outputFileExtension: ".webp"
+},
+actionConfig: {
+  imageSizes: ["640w", "1024w"],
+  quality: 80
+}
+```
+
+## Notes
+- Returns a `srcSet` and `aspectRatio` in `detail` for template usage.

+ 60 - 0
src/actions/imageToWebP/index.js

@@ -0,0 +1,60 @@
+import fs from "fs/promises"
+import path from "path"
+import sharp from "sharp"
+import {
+  generateRandomId,
+  getCleanPath,
+  slugifyString,
+} from "../../util/index.js"
+
+export async function imageToWebP({ meta, config: actionConfig }) {
+  const filePath = actionConfig.filePath
+  const fileOutputDir = actionConfig.fileOutputDir
+  const sourceExtension = path.extname(filePath)
+  const outputExtension = actionConfig.outputFileExtension
+  const base = path.basename(filePath, sourceExtension)
+  await fs.mkdir(fileOutputDir, { recursive: true })
+
+  const original = sharp(filePath)
+  const metadata = await original.metadata()
+  const { width, height } = metadata
+
+  if (!width || !height) {
+    throw new Error("Could not determine image dimensions")
+  }
+
+  const aspectRatio = width / height
+  const name = actionConfig.uniqueFilenames ? `${base}-${generateRandomId()}` : base
+  const imageSizes = Array.isArray(actionConfig.imageSizes) ? actionConfig.imageSizes : []
+  if (imageSizes.length === 0) {
+    throw new Error(`imageToWebP: imageSizes must be a non-empty array for ${filePath}`)
+  }
+  const outputFiles = []
+  const srcSet = await Promise.all(
+    imageSizes.map(async size => {
+      const sizeStr = typeof size === "string" ? size : String(size)
+      const sizeNum = parseInt(sizeStr.replace("w", ""), 10)
+      const outputFile = path.join(
+        fileOutputDir,
+        `${name}-${sizeNum}${outputExtension}`,
+      )
+
+      await original
+        .clone()
+        .resize(sizeNum)
+        .webp({ quality: actionConfig.quality })
+        .toFile(outputFile)
+
+      outputFiles.push(outputFile)
+      return [getCleanPath(outputFile, meta), size]
+    }),
+  )
+
+  const imageRef = slugifyString(getCleanPath(path.join(filePath), meta))
+
+  return {
+    paths: outputFiles,
+    detail: { srcSet, aspectRatio },
+    ref: imageRef,
+  }
+}

+ 11 - 0
src/actions/index.js

@@ -0,0 +1,11 @@
+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 { generateTaxonomy } from "./generateTaxonomy/index.js"
+export { imageToWebP } from "./imageToWebP/index.js"
+export { optimiseSvg } from "./optimiseSvg/index.js"
+export { renderMarkdownToHtml } from "./renderMarkdownToHtml/index.js"
+export { renderMarkdownWithTemplate } from "./renderMarkdownWithTemplate/index.js"
+export { renderTemplate } from "./renderTemplate/index.js"

+ 21 - 0
src/actions/optimiseSvg/README.md

@@ -0,0 +1,21 @@
+# optimiseSvg
+
+Optimizes SVG files using SVGO.
+
+## actionConfig options
+- `filePath` (string, required): Input SVG file path (computed from `jobConfig`).
+- `fileOutputPath` (string, required): Output SVG file path (computed from `jobConfig`).
+
+## Usage
+```javascript
+action: optimiseSvg,
+jobConfig: {
+  inputFiles: [{ pattern: "images/icons/*.svg" }],
+  outputDir: "static/",
+  outputFileExtension: ".svg"
+},
+actionConfig: {}
+```
+
+## Notes
+- Writes the optimized SVG to `fileOutputPath`.

+ 17 - 0
src/actions/optimiseSvg/index.js

@@ -0,0 +1,17 @@
+import fs from "fs/promises"
+import { optimize } from "svgo"
+import { slugifyString, writeFile } from "../../util/index.js"
+
+export async function optimiseSvg({ config: actionConfig }) {
+  const filePath = actionConfig.filePath
+  const fileOutputPath = actionConfig.fileOutputPath
+  const svgString = await fs.readFile(filePath, "utf8")
+  const result = optimize(svgString, {
+    plugins: ["preset-default"],
+  })
+  await writeFile(fileOutputPath, result.data)
+  return {
+    paths: [fileOutputPath],
+    ref: slugifyString(fileOutputPath),
+  }
+}

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

@@ -0,0 +1,21 @@
+# renderMarkdownToHtml
+
+Parses a markdown file (with frontmatter) into HTML and returns it as task detail.
+
+## actionConfig options
+- `filePath` (string, required): Input markdown file path (computed from `jobConfig`).
+- `fileOutputPath` (string, required): Intended output HTML path (computed from `jobConfig`).
+
+## Usage
+```javascript
+action: renderMarkdownToHtml,
+jobConfig: {
+  inputFiles: [{ pattern: "markdown/**/*.md" }],
+  outputFileExtension: ".html"
+},
+actionConfig: {}
+```
+
+## Notes
+- Emits `detail` containing frontmatter, `href`, `content` (HTML), and `fileOutputPath`.
+- Uses `meta.resources.images` to attach responsive image srcsets when available.

+ 21 - 0
src/actions/renderMarkdownToHtml/index.js

@@ -0,0 +1,21 @@
+import fs from "fs/promises"
+import matter from "gray-matter"
+import { getHref, slugifyString } from "../../util/index.js"
+import { createMarkdownRenderer } from "../_shared/markdown.js"
+
+export async function renderMarkdownToHtml({ config: actionConfig, meta }) {
+  const filePath = actionConfig.filePath
+  const fileOutputPath = actionConfig.fileOutputPath
+  const content = await fs.readFile(filePath, "utf8")
+  const { data, content: markdown } = matter(content)
+  const href = getHref(fileOutputPath, meta)
+
+  const renderer = createMarkdownRenderer(meta)
+  const html = renderer(markdown)
+  const detail = { ...data, href, content: html, fileOutputPath }
+
+  return {
+    detail,
+    ref: slugifyString(filePath),
+  }
+}

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

@@ -0,0 +1,26 @@
+# renderMarkdownWithTemplate
+
+Renders markdown with frontmatter into a Handlebars template and writes HTML output.
+
+## actionConfig options
+- `filePath` (string, required): Input markdown file path (computed from `jobConfig`).
+- `fileOutputPath` (string, required): Output HTML file path (computed from `jobConfig`).
+- `templateDirs` (string[], required): Directories to search for Handlebars templates.
+- `defaultTemplate` (string, required unless frontmatter sets `template`): Template name fallback.
+
+## Usage
+```javascript
+action: renderMarkdownWithTemplate,
+jobConfig: {
+  inputFiles: [{ pattern: "markdown/**/*.md" }],
+  outputFileExtension: ".html"
+},
+actionConfig: {
+  templateDirs: ["templates/"],
+  defaultTemplate: "page"
+}
+```
+
+## Notes
+- Uses frontmatter `template` when present, otherwise `defaultTemplate`.
+- Writes minified HTML to `fileOutputPath` and records the template as a dependency.

+ 43 - 0
src/actions/renderMarkdownWithTemplate/index.js

@@ -0,0 +1,43 @@
+import fs from "fs/promises"
+import { minify } from "html-minifier-terser"
+import matter from "gray-matter"
+import { getHref, slugifyString, writeFile } from "../../util/index.js"
+import { createMarkdownRenderer } from "../_shared/markdown.js"
+import { getTemplateByName } from "../_shared/template-cache.js"
+
+export async function renderMarkdownWithTemplate({ config: actionConfig, meta }) {
+  const filePath = actionConfig.filePath
+  const fileOutputPath = actionConfig.fileOutputPath
+  const content = await fs.readFile(filePath, "utf8")
+  const { data, content: markdown } = matter(content)
+  const templateName = data.template || actionConfig.defaultTemplate
+  const href = getHref(fileOutputPath, meta)
+
+  const template = await getTemplateByName(actionConfig.templateDirs, templateName)
+  const renderer = createMarkdownRenderer(meta)
+  const html = template.renderer({
+    ...data,
+    ...meta,
+    href,
+    content: renderer(markdown),
+  })
+  const minifiedHtml = await minify(html, {
+    collapseWhitespace: true,
+    removeComments: true,
+    removeRedundantAttributes: true,
+    removeEmptyAttributes: true,
+    minifyCSS: true,
+    minifyJS: true,
+  })
+
+  await writeFile(fileOutputPath, minifiedHtml)
+
+  return {
+    detail: { ...data, href },
+    paths: [fileOutputPath],
+    deps: {
+      paths: [template.path],
+    },
+    ref: slugifyString(fileOutputPath),
+  }
+}

+ 30 - 0
src/actions/renderTemplate/README.md

@@ -0,0 +1,30 @@
+# renderTemplate
+
+Renders a Handlebars template with the current task state and optional output.
+
+## actionConfig options
+- `template` (string, optional): Template name to render (without extension).
+- `defaultTemplate` (string, optional): Fallback template name if `template` is not set.
+- `templateDirs` (string[], required unless `filePath` is set): Directories to search for templates.
+- `partialDirs` (string[], optional): Directories to search for partials.
+- `writeOut` (boolean, optional): When `true`, writes minified HTML to `fileOutputPath`.
+- `filePath` (string, optional): Explicit template path override.
+- `fileOutputPath` (string, required when `writeOut` is true): Output HTML path.
+
+## Usage
+```javascript
+action: renderTemplate,
+jobConfig: {
+  stateSelectors: ["resources.markdown"]
+},
+actionConfig: {
+  writeOut: true,
+  templateDirs: ["templates/"],
+  partialDirs: ["partials/"],
+  template: "page"
+}
+```
+
+## Notes
+- All `actionConfig` properties are passed into the template context along with `meta`.
+- For state tasks, `fileOutputPath` is often provided via upstream `detail` or `buildFilePath`.

+ 58 - 0
src/actions/renderTemplate/index.js

@@ -0,0 +1,58 @@
+import { minify } from "html-minifier-terser"
+import {
+  getHref,
+  slugifyString,
+  writeFile,
+} from "../../util/index.js"
+import {
+  findTemplatePath,
+  getTemplate,
+  loadPartials,
+} from "../_shared/template-cache.js"
+
+export async function renderTemplate({ config: actionConfig, meta }) {
+  const templateName = actionConfig.template || actionConfig.defaultTemplate
+  const templatePath =
+    actionConfig.filePath ||
+    (await findTemplatePath(actionConfig.templateDirs, templateName))
+  const fileOutputPath = actionConfig.fileOutputPath
+  const href = getHref(fileOutputPath, meta)
+
+  // Load partials from configured directories
+  const partialPaths = await loadPartials(actionConfig.partialDirs)
+
+  const template = await getTemplate(templatePath)
+  const html = template.renderer({
+    ...meta,
+    href,
+    ...actionConfig,
+  })
+  if (actionConfig.writeOut) {
+    const minifiedHtml = await minify(html, {
+      collapseWhitespace: true,
+      removeComments: true,
+      removeRedundantAttributes: true,
+      removeEmptyAttributes: true,
+      minifyCSS: true,
+      minifyJS: true,
+    })
+
+    await writeFile(fileOutputPath, minifiedHtml)
+    return {
+      detail: { html },
+      deps: {
+        paths: [template.path, ...partialPaths],
+      },
+      paths: [fileOutputPath],
+      ref: slugifyString(href),
+    }
+  }
+
+  return {
+    detail: { html },
+    deps: {
+      paths: [template.path, ...partialPaths],
+    },
+    ref: slugifyString(href),
+  }
+}

+ 131 - 83
src/defaults.js

@@ -7,131 +7,179 @@ import {
   renderTemplate,
   renderMarkdownToHtml,
   generateTaxonomy,
-} from "./processors.js"
+} from "./actions/index.js"
 
 export const tasks = [
   [
     {
       name: "images",
-      inputFiles: [{ pattern: "images/content/*.jpg" }],
-      stripPaths: ["images/content/"],
-      outputDir: "images/",
-      outputFileExtension: ".webp",
-      imageSizes: [
-        "640w", "768w", "1024w", "1366w", "1600w", "1920w", "2560w",
-      ],
-      quality: 80,
-      processor: imageToWebP,
+      action: imageToWebP,
+      jobConfig: {
+        inputFiles: [{ pattern: "images/content/*.jpg" }],
+        stripPaths: ["images/content/"],
+        outputDir: "images/",
+        outputFileExtension: ".webp",
+      },
+      actionConfig: {
+        imageSizes: [
+          "640w",
+          "768w",
+          "1024w",
+          "1366w",
+          "1600w",
+          "1920w",
+          "2560w",
+        ],
+        quality: 80,
+      },
     },
     {
       name: "styles",
-      inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
-      stripPaths: ["styles/"],
-      outputDir: "static/styles/",
-      outputFileExtension: ".css",
-      processor: compileSass,
+      action: compileSass,
+      jobConfig: {
+        inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
+        stripPaths: ["styles/"],
+        outputDir: "static/styles/",
+        outputFileExtension: ".css",
+      },
+      actionConfig: {},
     },
     {
       name: "icons",
-      inputFiles: [{ pattern: "images/icons/*.svg" }],
-      stripPaths: ["images/"],
-      outputDir: "static/",
-      outputFileExtension: ".svg",
-      processor: optimiseSvg,
+      action: optimiseSvg,
+      jobConfig: {
+        inputFiles: [{ pattern: "images/icons/*.svg" }],
+        stripPaths: ["images/"],
+        outputDir: "static/",
+        outputFileExtension: ".svg",
+      },
+      actionConfig: {},
     },
     {
       name: "static files",
-      inputFiles: [{ pattern: "static/*" }],
-      stripPaths: ["static/"],
-      processor: copy,
+      action: copy,
+      jobConfig: {
+        inputFiles: [{ pattern: "static/*" }],
+        stripPaths: ["static/"],
+      },
+      actionConfig: {},
     },
     {
       name: "favicons",
-      inputFiles: [{ pattern: "images/favicon/*" }],
-      stripPaths: ["images/favicon/"],
-      outputDir: "static/meta/",
-      processor: generateFavicons,
+      action: generateFavicons,
+      jobConfig: {
+        inputFiles: [{ pattern: "images/favicon/*" }],
+        stripPaths: ["images/favicon/"],
+        outputDir: "static/meta/",
+      },
+      actionConfig: {},
     },
   ],
   {
     name: "markdown",
-    inputFiles: [{ pattern: "markdown/**/*.md" }],
-    stripPaths: ["markdown/"],
-    outputFileExtension: ".html",
-    processor: renderMarkdownToHtml,
+    action: renderMarkdownToHtml,
+    jobConfig: {
+      inputFiles: [{ pattern: "markdown/**/*.md" }],
+      stripPaths: ["markdown/"],
+      outputFileExtension: ".html",
+    },
+    actionConfig: {},
   },
   [
     {
       name: "tag-taxonomy",
-      stateSelectors: ["resources.markdown"],
-      processor: generateTaxonomy,
-      expand: false,
-      indexOn: "tags",
-      orderBy: "date",
-      properties: [
-        "title", "href", "date", "author",
-      ],
-      sortAscending: false,
-      skipCache: true,
+      action: generateTaxonomy,
+      jobConfig: {
+        stateSelectors: ["resources.markdown"],
+        expand: false,
+        skipCache: true,
+      },
+      actionConfig: {
+        indexOn: "tags",
+        orderBy: "date",
+        properties: [
+          "title", "href", "date", "author", "tags", "description",
+        ],
+        sortAscending: false,
+      },
     },
     {
       name: "blog-latest",
-      stateSelectors: ["resources.markdown"],
-      processor: generateTaxonomy,
-      expand: false,
-      orderBy: "date",
-      properties: [
-        "title", "href", "date", "author",
-      ],
-      sortAscending: false,
-      skipCache: true,
+      action: generateTaxonomy,
+      jobConfig: {
+        stateSelectors: ["resources.markdown"],
+        expand: false,
+        skipCache: true,
+      },
+      actionConfig: {
+        orderBy: "date",
+        properties: [
+          "title", "href", "date", "author", "tags", "description",
+        ],
+        sortAscending: false,
+      },
     },
     {
       name: "includes",
-      inputFiles: [{ pattern: "includes/*.hbs" }],
-      stripPaths: ["includes/"],
-      outputFileExtension: ".html",
-      processor: renderTemplate,
+      action: renderTemplate,
+      jobConfig: {
+        inputFiles: [{ pattern: "includes/*.hbs" }],
+        stripPaths: ["includes/"],
+        outputFileExtension: ".html",
+      },
+      actionConfig: {},
     },
   ],
   [
     {
       name: "render pages",
-      stateSelectors: ["resources.markdown"],
-      processor: renderTemplate,
-      writeOut: true,
-      templateDirs: ["templates/", "~/.rhedyn/templates/"],
-      partialDirs: ["partials/", "~/.rhedyn/partials/"],
-      defaultTemplate: "page",
+      action: renderTemplate,
+      jobConfig: {
+        stateSelectors: ["resources.markdown"],
+      },
+      actionConfig: {
+        writeOut: true,
+        templateDirs: ["templates/", "~/.rhedyn/templates/"],
+        partialDirs: ["partials/", "~/.rhedyn/partials/"],
+        defaultTemplate: "page",
+      },
     },
     {
       name: "render indexes",
-      stateSelectors: ["resources.tag-taxonomy.detail"],
-      processor: renderTemplate,
-      writeOut: true,
-      template: "index",
-      templateDirs: ["templates/", "~/.rhedyn/templates/"],
-      partialDirs: ["partials/", "~/.rhedyn/partials/"],
-      outputFileExtension: ".html",
-      outputDir: "blog/by-tag/",
-      buildFilePath: true,
-      itemsPerPage: 10,
+      action: renderTemplate,
+      jobConfig: {
+        stateSelectors: ["resources.tag-taxonomy.detail"],
+        outputFileExtension: ".html",
+        outputDir: "blog/by-tag/",
+        buildFilePath: true,
+        itemsPerPage: 10,
+      },
+      actionConfig: {
+        writeOut: true,
+        template: "index",
+        templateDirs: ["templates/", "~/.rhedyn/templates/"],
+        partialDirs: ["partials/", "~/.rhedyn/partials/"],
+      },
     },
     {
       name: "render blog home",
-      stateSelectors: ["resources.blog-latest.detail"],
-      processor: renderTemplate,
-      writeOut: true,
-      template: "index",
-      templateDirs: ["templates/", "~/.rhedyn/templates/"],
-      partialDirs: ["partials/", "~/.rhedyn/partials/"],
-      outputFileExtension: ".html",
-      outputDir: "blog/",
-      expand: false,
-      outputFileName: "index",
-      title: "Blog",
-      buildFilePath: true,
-      itemsPerPage: 10,
+      action: renderTemplate,
+      jobConfig: {
+        stateSelectors: ["resources.blog-latest.detail"],
+        outputFileExtension: ".html",
+        outputDir: "blog/",
+        expand: false,
+        outputFileName: "index",
+        buildFilePath: true,
+        itemsPerPage: 10,
+      },
+      actionConfig: {
+        writeOut: true,
+        template: "index",
+        templateDirs: ["templates/", "~/.rhedyn/templates/"],
+        partialDirs: ["partials/", "~/.rhedyn/partials/"],
+        title: "Blog",
+      },
     },
   ],
 ]

+ 125 - 73
src/lib.js

@@ -24,8 +24,8 @@ function buildOutputPath(outDir, outputDir, pathsToStrip, inputPath, outputFileE
   )
 }
 
-function getPathsToStrip(config) {
-  return (config.stripPaths || []).map(p => expandTilde(p))
+function getPathsToStrip(jobConfig) {
+  return (jobConfig.stripPaths || []).map(p => expandTilde(p))
 }
 
 function paginateItems(items, itemsPerPage) {
@@ -112,20 +112,22 @@ export async function getConfig() {
   }
 }
 
-async function runTask({ meta, config, jobId }) {
+async function runTask({ meta, action, jobConfig, actionConfig, jobId }) {
   const log = getLogger(
-    config.logLevel ? config.logLevel : meta.opts.logLevel,
+    jobConfig.logLevel ? jobConfig.logLevel : meta.opts.logLevel,
     jobId,
   )
   log.trace(`meta: ${JSON.stringify(meta, null, 2)}`)
-  log.trace(`config: ${JSON.stringify(config, null, 2)}`)
+  log.trace(`jobConfig: ${JSON.stringify(jobConfig, null, 2)}`)
+  log.trace(`actionConfig: ${JSON.stringify(actionConfig, null, 2)}`)
 
   const stateObject = {
     meta,
-    config,
+    config: actionConfig,
+    jobConfig,
   }
   const cache =
-    meta.opts.cacheDir && !config.skipCache
+    meta.opts.cacheDir && !jobConfig.skipCache
       ? await checkCache(jobId, stateObject, meta.opts)
       : { disabled: true, reason: "Cache disabled" }
 
@@ -142,9 +144,9 @@ async function runTask({ meta, config, jobId }) {
   const {
     detail = {},
     paths = [],
-    deps: processorDeps,
+    deps: actionDeps,
     ref,
-  } = await config.processor(state.proxy)
+  } = await action(state.proxy)
 
   const taskResult = {
     detail,
@@ -156,17 +158,17 @@ async function runTask({ meta, config, jobId }) {
   log.debug(`Wrote ${taskResult.paths.length} files for ${jobId}`)
   if (cache && !cache.disabled) {
     log.debug(`Updating cache for ${jobId}: ${cache.filePath}`)
-    const processorPathDeps = processorDeps?.paths || []
-    const processorStateDeps = processorDeps?.state || []
-    const configPathDeps = config.deps?.paths || []
-    const configStateDeps = config.deps?.state || []
-    const stateSelectors = config.stateSelectors || []
+    const actionPathDeps = actionDeps?.paths || []
+    const actionStateDeps = actionDeps?.state || []
+    const configPathDeps = jobConfig.deps?.paths || []
+    const configStateDeps = jobConfig.deps?.state || []
+    const stateSelectors = jobConfig.stateSelectors || []
     await updateCache(
       meta.opts.cacheDir,
       jobId,
       removeCwd(
         [
-          ...configPathDeps, ...processorPathDeps, config?.filePath,
+          ...configPathDeps, ...actionPathDeps, actionConfig?.filePath,
         ].filter(
           item => !!item,
         ),
@@ -174,7 +176,7 @@ async function runTask({ meta, config, jobId }) {
       [
         ...configStateDeps,
         ...stateSelectors,
-        ...processorStateDeps,
+        ...actionStateDeps,
         ...(state?.accessed || []),
       ].filter(item => !!item),
       taskResult,
@@ -186,8 +188,8 @@ async function runTask({ meta, config, jobId }) {
   return taskResult
 }
 
-function selectFiles(patternsToInclude, config) {
-  const pathsToStrip = getPathsToStrip(config)
+function selectFiles(patternsToInclude, jobConfig) {
+  const pathsToStrip = getPathsToStrip(jobConfig)
 
   return async meta => {
     const filesToProcess = await readFilesByGlob(patternsToInclude)
@@ -195,10 +197,10 @@ function selectFiles(patternsToInclude, config) {
     return filesToProcess.map(filePath => {
       const fileOutputPath = buildOutputPath(
         meta.opts.outDir,
-        config.outputDir,
+        jobConfig.outputDir,
         pathsToStrip,
         filePath,
-        config.outputFileExtension,
+        jobConfig.outputFileExtension,
       )
 
       return {
@@ -210,20 +212,23 @@ function selectFiles(patternsToInclude, config) {
   }
 }
 
-async function expandFileTask(patternsToInclude, config, meta) {
-  const filesToProcess = await selectFiles(patternsToInclude, config)(meta)
+async function expandFileTask(patternsToInclude, task, jobConfig, actionConfig, meta) {
+  const filesToProcess = await selectFiles(patternsToInclude, jobConfig)(meta)
 
   return await Promise.all(
     filesToProcess.map(async fileJob => {
-      const jobConfig = {
-        ...config,
+      const jobActionConfig = {
+        ...actionConfig,
         ...fileJob,
+        outputFileExtension: jobConfig.outputFileExtension,
       }
 
       return runTask({
         meta,
-        config: jobConfig,
-        jobId: `${config.name} @ ${fileJob.filePath}`,
+        action: task.action,
+        jobConfig,
+        actionConfig: jobActionConfig,
+        jobId: `${task.name} @ ${fileJob.filePath}`,
       })
     }),
   )
@@ -250,10 +255,10 @@ function selectState(stateToSelect, meta) {
     .flat()
 }
 
-async function expandStateTask(stateToExpand, config, meta) {
+async function expandStateTask(stateToExpand, task, jobConfig, actionConfig, meta) {
   const stateToProcess = selectState(stateToExpand, meta)
-  const pathsToStrip = getPathsToStrip(config)
-  const itemsPerPage = config.itemsPerPage ?? meta.opts.itemsPerPage
+  const pathsToStrip = getPathsToStrip(jobConfig)
+  const itemsPerPage = jobConfig.itemsPerPage ?? meta.opts.itemsPerPage
 
   // Build jobs, potentially with pagination
   const jobs = stateToProcess.flatMap((stateJob, index) => {
@@ -270,10 +275,10 @@ async function expandStateTask(stateToExpand, config, meta) {
     return pages.map(({ items: pageItems, page, totalPages }) => {
       const basePath = buildOutputPath(
         meta.opts.outDir,
-        config.outputDir,
+        jobConfig.outputDir,
         pathsToStrip,
         stateJob.key || String(index),
-        config.outputFileExtension,
+        jobConfig.outputFileExtension,
       )
 
       // For page 2+, modify the output path
@@ -293,7 +298,7 @@ async function expandStateTask(stateToExpand, config, meta) {
         ...(Array.isArray(stateJob.value)
           ? { inputs: pageItems }
           : { ...stateJob.value?.detail, inputs: pageItems }),
-        ...(config.buildFilePath ? { fileOutputPath } : {}),
+        ...(jobConfig.buildFilePath ? { fileOutputPath } : {}),
         ...(pagination ? { pagination } : {}),
       }
 
@@ -309,75 +314,101 @@ async function expandStateTask(stateToExpand, config, meta) {
 
   return await Promise.all(
     jobs.map(async ({ stateJob, page, decorations }) => {
-      const jobConfig = {
-        ...config,
+      const fileOutputDir = decorations.fileOutputPath
+        ? path.dirname(decorations.fileOutputPath)
+        : undefined
+      const jobActionConfig = {
+        ...actionConfig,
         ...decorations,
         stateKey: stateJob.key,
+        ...(fileOutputDir ? { fileOutputDir } : {}),
+        outputFileExtension: jobConfig.outputFileExtension,
       }
       const pageInfo = page > 1 ? `:page-${page}` : ""
       return runTask({
         meta,
-        config: jobConfig,
-        jobId: `${config.name} @ ${stateJob.property}:${stateJob.index}${pageInfo}`,
+        action: task.action,
+        jobConfig,
+        actionConfig: jobActionConfig,
+        jobId: `${task.name} @ ${stateJob.property}:${stateJob.index}${pageInfo}`,
       })
     }),
   )
 }
 
-export async function expandAndRunTask(meta, config) {
-  const includes = meta.opts?.include?.[config.name] || []
-  const patternsToInclude = [...(config?.inputFiles || []), ...includes]
+export async function expandAndRunTask(meta, task) {
+  const jobConfig = task.jobConfig || {}
+  const actionConfig = {
+    ...(task.actionConfig || {}),
+    name: task.name,
+  }
+  const includes = meta.opts?.include?.[task.name] || []
+  const patternsToInclude = [...(jobConfig?.inputFiles || []), ...includes]
 
   if (patternsToInclude.length) {
-    if (config.expand === false) {
-      const inputs = selectFiles(patternsToInclude, config)
-      const jobId = config.jobId || config.name
+    if (jobConfig.expand === false) {
+      const inputs = selectFiles(patternsToInclude, jobConfig)
+      const jobId = jobConfig.jobId || task.name
+      const jobActionConfig = {
+        ...actionConfig,
+        inputs,
+        outputFileExtension: jobConfig.outputFileExtension,
+      }
       const taskResult = await runTask({
         meta,
-        config: { ...config, inputs },
+        action: task.action,
+        jobConfig,
+        actionConfig: jobActionConfig,
         jobId,
       })
       return [taskResult]
     }
 
-    return expandFileTask(patternsToInclude, config, meta)
+    return expandFileTask(patternsToInclude, task, jobConfig, actionConfig, meta)
   }
 
-  if (config.stateSelectors) {
-    if (config.expand === false) {
-      const pathsToStrip = getPathsToStrip(config)
-      const inputs = selectState(config.stateSelectors, meta).map(stateItem => {
+  if (jobConfig.stateSelectors) {
+    if (jobConfig.expand === false) {
+      const pathsToStrip = getPathsToStrip(jobConfig)
+      const inputs = selectState(jobConfig.stateSelectors, meta).map(stateItem => {
         return stateItem.value?.detail ?? stateItem.value
       })
-      const itemsPerPage = config.itemsPerPage
+      const itemsPerPage = jobConfig.itemsPerPage
       const shouldPaginate =
         Array.isArray(inputs) && itemsPerPage != null && itemsPerPage > 0
       const basePath =
-        (config.buildFilePath || shouldPaginate)
+        (jobConfig.buildFilePath || shouldPaginate)
           ? buildOutputPath(
             meta.opts.outDir,
-            config.outputDir,
+            jobConfig.outputDir,
             pathsToStrip,
-            config.outputFileName || config.name,
-            config.outputFileExtension,
+            jobConfig.outputFileName || task.name,
+            jobConfig.outputFileExtension,
           )
           : null
 
       if (!shouldPaginate) {
         const decorations = {
-          ...(config.buildFilePath
+          ...(jobConfig.buildFilePath
             ? { fileOutputPath: basePath }
             : {}),
         }
-        const jobConfig = {
-          ...config,
+        const fileOutputDir = decorations.fileOutputPath
+          ? path.dirname(decorations.fileOutputPath)
+          : undefined
+        const jobActionConfig = {
+          ...actionConfig,
           ...decorations,
           inputs,
+          ...(fileOutputDir ? { fileOutputDir } : {}),
+          outputFileExtension: jobConfig.outputFileExtension,
         }
-        const jobId = config.jobId || config.name
+        const jobId = jobConfig.jobId || task.name
         const taskResult = await runTask({
           meta,
-          config: { ...jobConfig },
+          action: task.action,
+          jobConfig,
+          actionConfig: jobActionConfig,
           jobId,
         })
         return [taskResult]
@@ -386,19 +417,26 @@ export async function expandAndRunTask(meta, config) {
       const pages = paginateItems(inputs, itemsPerPage)
       if (pages.length <= 1) {
         const decorations = {
-          ...(config.buildFilePath
+          ...(jobConfig.buildFilePath
             ? { fileOutputPath: basePath }
             : {}),
         }
-        const jobConfig = {
-          ...config,
+        const fileOutputDir = decorations.fileOutputPath
+          ? path.dirname(decorations.fileOutputPath)
+          : undefined
+        const jobActionConfig = {
+          ...actionConfig,
           ...decorations,
           inputs,
+          ...(fileOutputDir ? { fileOutputDir } : {}),
+          outputFileExtension: jobConfig.outputFileExtension,
         }
-        const jobId = config.jobId || config.name
+        const jobId = jobConfig.jobId || task.name
         const taskResult = await runTask({
           meta,
-          config: { ...jobConfig },
+          action: task.action,
+          jobConfig,
+          actionConfig: jobActionConfig,
           jobId,
         })
         return [taskResult]
@@ -407,7 +445,7 @@ export async function expandAndRunTask(meta, config) {
       const basePathForPagination = basePath
         ? basePath.replace(meta.opts.outDir, "/")
         : null
-      const baseJobId = config.jobId || config.name
+      const baseJobId = jobConfig.jobId || task.name
       return await Promise.all(
         pages.map(({ items: pageItems, page, totalPages }) => {
           const fileOutputPath = basePath
@@ -424,31 +462,44 @@ export async function expandAndRunTask(meta, config) {
               ? buildPaginationMeta(page, totalPages, basePathForPagination)
               : null
           const decorations = {
-            ...(config.buildFilePath && fileOutputPath
+            ...(jobConfig.buildFilePath && fileOutputPath
               ? { fileOutputPath }
               : {}),
             ...(pagination ? { pagination } : {}),
           }
-          const jobConfig = {
-            ...config,
+          const fileOutputDir = fileOutputPath
+            ? path.dirname(fileOutputPath)
+            : undefined
+          const jobActionConfig = {
+            ...actionConfig,
             ...decorations,
             inputs: pageItems,
+            ...(fileOutputDir ? { fileOutputDir } : {}),
+            outputFileExtension: jobConfig.outputFileExtension,
           }
           const pageInfo = page > 1 ? `:page-${page}` : ""
           return runTask({
             meta,
-            config: { ...jobConfig },
+            action: task.action,
+            jobConfig,
+            actionConfig: jobActionConfig,
             jobId: `${baseJobId}${pageInfo}`,
           })
         }),
       )
     }
 
-    return expandStateTask(config.stateSelectors, config, meta)
+    return expandStateTask(jobConfig.stateSelectors, task, jobConfig, actionConfig, meta)
   }
 
-  const jobId = config.jobId || config.name
-  const taskResult = await runTask({ meta, config, jobId })
+  const jobId = jobConfig.jobId || task.name
+  const taskResult = await runTask({
+    meta,
+    action: task.action,
+    jobConfig,
+    actionConfig,
+    jobId,
+  })
   return [taskResult]
 }
 
@@ -459,9 +510,10 @@ export async function processTask(meta, task) {
   const cached = taskResult.filter(taskResult => taskResult.fromCache)
   const processed = taskResult.filter(taskResult => !taskResult.fromCache)
   const resourcesWithRef = taskResult.filter(tResult => tResult.ref)
+  const jobConfig = task.jobConfig || {}
   const hasExpandableInputs =
-    (task.inputFiles?.length || task.stateSelectors?.length) &&
-    task.expand !== false
+    (jobConfig.inputFiles?.length || jobConfig.stateSelectors?.length) &&
+    jobConfig.expand !== false
   const shouldCollapseResources =
     resourcesWithRef.length === 1 &&
     taskResult.length === 1 &&

+ 0 - 457
src/processors.js

@@ -1,457 +0,0 @@
-import * as sass from "sass"
-import {
-  escapeHtmlAttr,
-  expandTilde,
-  firstFound,
-  generateRandomId,
-  getCleanPath,
-  getHref,
-  slugifyString,
-  writeFile,
-} from "./util/index.js"
-import { glob } from "glob"
-import fs from "fs/promises"
-import handlebars from "handlebars"
-import { marked } from "marked"
-import markedCodePreview from "marked-code-preview"
-import matter from "gray-matter"
-import { optimize } from "svgo"
-import sharp from "sharp"
-import path from "path"
-import { minify } from "html-minifier-terser"
-import favicons from "favicons"
-import _ from "lodash-es"
-
-const templateCache = new Map()
-const partialCache = new Map()
-const registeredPartials = new Set()
-
-export function clearTemplateCache() {
-  templateCache.clear()
-  partialCache.clear()
-  registeredPartials.clear()
-}
-
-async function loadPartials(partialDirs) {
-  if (!partialDirs || partialDirs.length === 0) {
-    return []
-  }
-
-  const loadedPaths = []
-
-  for (const dir of partialDirs) {
-    const expandedDir = expandTilde(dir)
-    const pattern = path.join(expandedDir, "*.hbs")
-
-    let files
-    try {
-      files = await glob(pattern)
-    } catch {
-      continue
-    }
-
-    for (const filePath of files) {
-      const fileName = path.basename(filePath, ".hbs")
-      // Remove leading underscore if present (e.g., _pagination -> pagination)
-      const partialName = fileName.startsWith("_") ? fileName.slice(1) : fileName
-
-      if (registeredPartials.has(partialName)) {
-        continue
-      }
-
-      if (!partialCache.has(filePath)) {
-        const content = await fs.readFile(filePath, "utf8")
-        partialCache.set(filePath, { name: partialName, content })
-      }
-
-      const partial = partialCache.get(filePath)
-      handlebars.registerPartial(partial.name, partial.content)
-      registeredPartials.add(partialName)
-      loadedPaths.push(filePath)
-    }
-  }
-
-  return loadedPaths
-}
-
-function createMarkdownRenderer(meta) {
-  return marked
-    .use({ gfm: true })
-    .use(markedCodePreview)
-    .use({
-      renderer: {
-        image({ href, title, text }) {
-          const attrs = [`alt="${escapeHtmlAttr(text)}"`]
-
-          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\"")
-            attrs.push(
-              `style="aspect-ratio: ${escapeHtmlAttr(foundSrcSet.detail.aspectRatio)}"`,
-            )
-          } else {
-            attrs.push(`src="${escapeHtmlAttr(href)}"`)
-          }
-
-          if (title) {
-            attrs.push(`title="${escapeHtmlAttr(title)}"`)
-          }
-
-          return `<img ${attrs.join(" ")} >`
-        },
-      },
-    })
-}
-async function findTemplatePath(templateDirs, templateName) {
-  const templatePath = await firstFound(templateDirs, `${templateName}.hbs`)
-  if (!templatePath) throw new Error(`Template not found: ${templateName}`)
-  return templatePath
-}
-
-async function getTemplate(templatePath) {
-  if (!templateCache.has(templatePath)) {
-    const templateContent = await fs.readFile(templatePath, "utf8")
-    templateCache.set(templatePath, {
-      path: templatePath,
-      renderer: handlebars.compile(templateContent),
-    })
-  }
-  return templateCache.get(templatePath)
-}
-
-export async function renderTemplate({ config, meta }) {
-  const templateName = config.template || config.defaultTemplate
-  const templatePath =
-    config.filePath ||
-    (await findTemplatePath(config.templateDirs, templateName))
-  const fileOutputPath = config.fileOutputPath
-  const href = getHref(fileOutputPath, meta)
-
-  // Load partials from configured directories
-  const partialPaths = await loadPartials(config.partialDirs)
-
-  const template = await getTemplate(templatePath)
-  const html = template.renderer({
-    ...meta,
-    href,
-    ...config,
-  })
-  if (config.writeOut) {
-    const minifiedHtml = await minify(html, {
-      collapseWhitespace: true,
-      removeComments: true,
-      removeRedundantAttributes: true,
-      removeEmptyAttributes: true,
-      minifyCSS: true,
-      minifyJS: true,
-    })
-
-    await writeFile(fileOutputPath, minifiedHtml)
-    return {
-      detail: { html },
-      deps: {
-        paths: [template.path, ...partialPaths],
-      },
-      paths: [fileOutputPath],
-      ref: slugifyString(href),
-    }
-  }
-
-  return {
-    detail: { html },
-    deps: {
-      paths: [template.path, ...partialPaths],
-    },
-    ref: slugifyString(href),
-  }
-}
-export async function renderMarkdownToHtml({ config, meta }) {
-  const filePath = config.filePath
-  const fileOutputPath = config.fileOutputPath
-  const content = await fs.readFile(filePath, "utf8")
-  const { data, content: markdown } = matter(content)
-  const href = getHref(fileOutputPath, meta)
-
-  const renderer = createMarkdownRenderer(meta)
-  const html = renderer(markdown)
-  const detail = { ...data, href, content: html, fileOutputPath }
-
-  return {
-    detail,
-    ref: slugifyString(filePath),
-  }
-}
-export async function renderMarkdownWithTemplate({ config, meta }) {
-  const filePath = config.filePath
-  const fileOutputPath = config.fileOutputPath
-  const content = await fs.readFile(filePath, "utf8")
-  const { data, content: markdown } = matter(content)
-  const templateName = data.template || config.defaultTemplate
-  const href = getHref(fileOutputPath, meta)
-
-  if (!templateCache.has(templateName)) {
-    const templatePath = await firstFound(
-      config.templateDirs,
-      `${templateName}.hbs`,
-    )
-    if (!templatePath) throw new Error(`Template not found: ${templateName}`)
-    const templateContent = await fs.readFile(templatePath, "utf8")
-    templateCache.set(templateName, {
-      path: templatePath,
-      renderer: handlebars.compile(templateContent),
-    })
-  }
-  const template = templateCache.get(templateName)
-  const renderer = createMarkdownRenderer(meta)
-  const html = template.renderer({
-    ...data,
-    ...meta,
-    href,
-    content: renderer(markdown),
-  })
-  const minifiedHtml = await minify(html, {
-    collapseWhitespace: true,
-    removeComments: true,
-    removeRedundantAttributes: true,
-    removeEmptyAttributes: true,
-    minifyCSS: true,
-    minifyJS: true,
-  })
-
-  await writeFile(fileOutputPath, minifiedHtml)
-
-  return {
-    detail: { ...data, href },
-    paths: [fileOutputPath],
-    deps: {
-      paths: [template.path],
-    },
-    ref: slugifyString(fileOutputPath),
-  }
-}
-
-export async function compileSass({ config, meta }) {
-  const filePath = config.filePath
-  const fileOutputPath = config.fileOutputPath
-  const result = await sass.compileAsync(filePath, { style: "compressed" })
-  await writeFile(fileOutputPath, result.css)
-  return {
-    paths: [fileOutputPath],
-    ref: slugifyString(fileOutputPath),
-    detail: {
-      href: fileOutputPath.replace(meta.opts.outDir, "/"),
-    },
-    deps: {
-      paths: [...result.loadedUrls.map(item => item.pathname)],
-    },
-  }
-}
-
-export async function optimiseSvg({ config }) {
-  const filePath = config.filePath
-  const fileOutputPath = config.fileOutputPath
-  const svgString = await fs.readFile(filePath, "utf8")
-  const result = optimize(svgString, {
-    plugins: ["preset-default"],
-  })
-  await writeFile(fileOutputPath, result.data)
-  return {
-    paths: [fileOutputPath],
-    ref: slugifyString(fileOutputPath),
-  }
-}
-
-export async function copy({ config }) {
-  const filePath = config.filePath
-  const fileOutputPath = config.fileOutputPath
-  await fs.mkdir(config.fileOutputDir, { recursive: true })
-  await fs.copyFile(filePath, fileOutputPath)
-  return {
-    paths: [fileOutputPath],
-    ref: slugifyString(fileOutputPath),
-  }
-}
-
-export async function imageToWebP({ meta, config }) {
-  const filePath = config.filePath
-  const fileOutputDir = config.fileOutputDir
-  const sourceExtension = path.extname(filePath)
-  const outputExtension = config.outputFileExtension
-  const base = path.basename(filePath, sourceExtension)
-  await fs.mkdir(fileOutputDir, { recursive: true })
-
-  const original = sharp(filePath)
-  const metadata = await original.metadata()
-  const { width, height } = metadata
-
-  if (!width || !height) {
-    throw new Error("Could not determine image dimensions")
-  }
-
-  const aspectRatio = width / height
-  const name = config.uniqueFilenames ? `${base}-${generateRandomId()}` : base
-  const imageSizes = Array.isArray(config.imageSizes) ? config.imageSizes : []
-  if (imageSizes.length === 0) {
-    throw new Error(`imageToWebP: imageSizes must be a non-empty array for ${filePath}`)
-  }
-  const outputFiles = []
-  const srcSet = await Promise.all(
-    imageSizes.map(async size => {
-      const sizeStr = typeof size === "string" ? size : String(size)
-      const sizeNum = parseInt(sizeStr.replace("w", ""), 10)
-      const outputFile = path.join(
-        fileOutputDir,
-        `${name}-${sizeNum}${outputExtension}`,
-      )
-
-      await original
-        .clone()
-        .resize(sizeNum)
-        .webp({ quality: config.quality })
-        .toFile(outputFile)
-
-      outputFiles.push(outputFile)
-      return [getCleanPath(outputFile, meta), size]
-    }),
-  )
-
-  const imageRef = slugifyString(getCleanPath(path.join(filePath), meta))
-
-  return {
-    paths: outputFiles,
-    detail: { srcSet, aspectRatio },
-    ref: imageRef,
-  }
-}
-
-export async function generateFavicons({ meta, config }) {
-  const filePath = config.filePath
-  const fileOutputDir = config.fileOutputDir
-  // Configuration for favicons package
-  const configuration = {
-    path: getCleanPath(fileOutputDir, meta), // Path for overriding default icons path
-    appName: meta.opts.site?.name || "Website",
-    appShortName: meta.opts.site?.shortName || "Site",
-    appDescription: meta.opts.site?.description || "",
-    developerName: meta.opts.site?.author || "",
-    developerURL: meta.opts.site?.url || "",
-    dir: "auto",
-    lang: meta.opts.site?.language || "en-US",
-    background: meta.opts.site?.backgroundColor || "#ffffff",
-    theme_color: meta.opts.site?.themeColor || "#ffffff",
-    appleStatusBarStyle: "black-translucent",
-    display: "standalone",
-    orientation: "any",
-    scope: "/",
-    start_url: "/",
-    version: "1.0",
-    logging: false,
-    pixel_art: false,
-    loadManifestWithCredentials: false,
-    manifestMaskable: false,
-    icons: {
-      android: true,
-      appleIcon: true,
-      appleStartup: true,
-      favicons: true,
-      windows: true,
-      yandex: true,
-    },
-  }
-  try {
-    const response = await favicons(filePath, configuration)
-
-    // Write all generated images to disk
-    await Promise.all(
-      response.images.map(async image => {
-        const outputPath = path.join(fileOutputDir, image.name)
-        await writeFile(outputPath, image.contents)
-      }),
-    )
-
-    // Write all generated files (manifests, etc.) to disk
-    await Promise.all(
-      response.files.map(async file => {
-        const outputPath = path.join(fileOutputDir, file.name)
-        await writeFile(outputPath, file.contents)
-      }),
-    )
-
-    // Combine HTML meta tags
-    const htmlMeta = response.html.join("\n    ")
-    return {
-      detail: {
-        htmlMeta,
-      },
-      paths: [
-        ...response.images.map(img => path.join(fileOutputDir, img.name)),
-        ...response.files.map(file => path.join(fileOutputDir, file.name)),
-      ],
-      ref: config.name,
-    }
-  } catch (error) {
-    throw new Error(`Failed to generate favicons: ${error.message}`)
-  }
-}
-
-export async function generateTaxonomy({ config }) {
-  if (!Array.isArray(config.inputs)) {
-    return {
-      detail: config.indexOn ? {} : [],
-      ref: config.name,
-    }
-  }
-  const allValues = config.inputs.reduce((values, curr) => {
-    const items = curr[config.indexOn]
-    if (!Array.isArray(items)) {
-      return values
-    }
-    items.forEach(v => values.add(v))
-    return values
-  }, new Set())
-  const orderBy = config.orderBy || "date"
-  const sortedInputs = config.sortAscending
-    ? _.sortBy(config.inputs, orderBy)
-    : _.sortBy(config.inputs, orderBy).reverse()
-  const taxonomy = config.indexOn
-    ? [...allValues.values()].reduce((groups, currentGroup) => {
-      const grouped = {
-        ...groups,
-        [currentGroup]: sortedInputs
-          .filter(item => {
-            const items = item?.[config.indexOn]
-            return Array.isArray(items) && items.includes(currentGroup)
-          })
-          .map(item => {
-            const entry = config.properties
-              ? config.properties.reduce(
-                (ent, prop) => ({ ...ent, [prop]: item[prop] }),
-                {},
-              )
-              : item
-            return entry
-          }),
-      }
-      return grouped
-    }, {})
-    : sortedInputs.map(item => {
-      const entry = config.properties
-        ? config.properties.reduce(
-          (ent, prop) => ({ ...ent, [prop]: item[prop] }),
-          {},
-        )
-        : item
-      return entry
-    })
-  return {
-    detail: taxonomy,
-    ref: config.name,
-  }
-}