Răsfoiți Sursa

Refactoring and documenting processors -> actions

Craig Fletcher 2 săptămâni în urmă
părinte
comite
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.
 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.
 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.
 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
 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
 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
 `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
 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
 ```javascript
 {
 {
   name: "styles",
   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:**
 **Task Properties:**
 - `name`: Task identifier (required)
 - `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:**
 **Input File Patterns:**
 ```javascript
 ```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
 `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:
 single-result tasks, the result is stored directly under the task name:
 ```javascript
 ```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
 ```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
   // - filePath: path to the input file being processed
   // - fileOutputPath: calculated output path
   // - fileOutputPath: calculated output path
   // - fileOutputDir: directory for the output file
   // - fileOutputDir: directory for the output file
+  // - inputs/pagination/stateKey when expanding from state
+  //
+  // jobConfig contains selection/expansion config (inputFiles, stateSelectors, etc.)
   
   
   // meta contains:
   // meta contains:
   // - opts: global configuration
   // - 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
 - `compileSass`: Compiles SCSS files to compressed CSS
 - `renderMarkdownWithTemplate`: Renders markdown with Handlebars templates, includes frontmatter support
 - `renderMarkdownWithTemplate`: Renders markdown with Handlebars templates, includes frontmatter support
 - `optimiseSvg`: Optimizes SVG files using SVGO
 - `optimiseSvg`: Optimizes SVG files using SVGO
@@ -215,10 +233,10 @@ The following processors are available from `processors`:
 
 
 **Resources and Cross-Task References:**
 **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
 ## Default Tasks
 
 

+ 1 - 1
package.json

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