Explorar el Código

sitemap + taxonomy

Craig Fletcher hace 2 semanas
padre
commit
5c03ada4b5

+ 4 - 2
CONTEXT.md

@@ -14,9 +14,11 @@ with caching based on file hashes and state access.
 ## 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:
+  - `key` for state/resource identity
+  - `name` for human-readable logging
   - `jobConfig` for file/state selection, expansion, output paths, and caching
   - `actionConfig` for action-specific options
-  - `action` function and a `name`
+  - `action` function
 - Global config under `opts` controls output/cache dirs, logging, and site meta.
 
 ## Task Expansion
@@ -37,7 +39,7 @@ with caching based on file hashes and state access.
 - `src/actions/` contains built-ins:
   - `compileSass`, `renderMarkdownToHtml`, `renderTemplate`,
     `renderMarkdownWithTemplate`, `renderIndex`, `optimiseSvg`, `copy`,
-    `imageToWebP`, `generateFavicons`, `generateTaxonomy`.
+    `imageToWebP`, `generateFavicons`, `generateTaxonomy`, `generateSitemap`.
 - 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.

+ 25 - 18
README.md

@@ -114,12 +114,12 @@ Example structure:
 tasks: [
   [
     // These tasks run in parallel
-    { name: "styles", ... },
-    { name: "icons", ... },
-    { name: "images", ... }
+    { key: "styles", name: "Styles", ... },
+    { key: "icons", name: "Icons", ... },
+    { key: "images", name: "Images", ... }
   ],
   // This task runs after the above group completes
-  { name: "pages", ... }
+  { key: "pages", name: "Pages", ... }
 ]
 ```
 
@@ -127,7 +127,8 @@ Each task object should look something like this:
 
 ```javascript
 {
-  name: "styles",
+  key: "styles",
+  name: "Styles",
   action: compileSass,
   jobConfig: {
     inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
@@ -140,7 +141,8 @@ Each task object should look something like this:
 ```
 
 **Task Properties:**
-- `name`: Task identifier (required)
+- `key`: Task identifier used in state/resources (required)
+- `name`: Human-readable task label used in logs (optional, defaults to `key`)
 - `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`)
@@ -158,7 +160,7 @@ jobConfig: {
 
 **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`.
+- `stateSelectors`: Array of dot-paths (e.g. `resources.markdown`) to expand from `meta` using task `key` values.
 - `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.
@@ -177,13 +179,13 @@ jobConfig: {
 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:
+`meta.resources`. For expanded tasks (multiple results), the `ref` is used as the key under the task key. For
+single-result tasks, the result is stored directly under the task key:
 ```javascript
 {
   ...meta,
   resources: {
-    [task.name]: {
+    [task.key]: {
       [jobResult.ref]: {
         detail,
         paths,
@@ -192,7 +194,7 @@ single-result tasks, the result is stored directly under the task name:
       }
     }
     // or, for single-result tasks:
-    // [task.name]: { detail, paths, ref, fromCache }
+    // [task.key]: { detail, paths, ref, fromCache }
   }
 }
 ```
@@ -205,6 +207,7 @@ async function myAction({ config, jobConfig, meta }) {
   // - fileOutputPath: calculated output path
   // - fileOutputDir: directory for the output file
   // - inputs/pagination/stateKey when expanding from state
+  // - key/name for the current task identity
   //
   // jobConfig contains selection/expansion config (inputFiles, stateSelectors, etc.)
   
@@ -240,10 +243,11 @@ The following actions are available from `actions`:
 - `imageToWebP`: Converts images to WebP with multiple sizes for responsive images
 - `generateFavicons`: Generates favicon sets and web app manifests
 - `generateTaxonomy`: Builds grouped/sorted lists from state inputs
+- `generateSitemap`: Generates `sitemap.xml` from rendered page state entries
 
 **Resources and Cross-Task References:**
 
-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.
+Processed files are made available to subsequent tasks via `meta.resources[taskKey][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/actions/`, and you can find utility functions exported from this package as
 `utils`. The sample actions are also exported from this module as `actions`.
@@ -256,7 +260,7 @@ The default configuration includes:
    - `images`: Converts JPG images to WebP with multiple sizes
    - `styles`: Compiles SCSS files to CSS
    - `icons`: Optimizes SVG icons
-   - `static files`: Copies static files
+   - `static-files`: Copies static files
    - `favicons`: Generates favicon sets from source images
 
 2. **Sequential tasks:**
@@ -270,11 +274,14 @@ The default configuration includes:
    - `includes`: Renders template includes to HTML
 
 4. **Parallel processing group:**
-   - `render pages`: Renders standard pages from markdown files
-   - `render blog pages`: Renders blog pages from blog markdown files
-   - `render author indexes`: Renders author index pages and index list
-   - `render tag indexes`: Renders tag index pages and index list
-   - `render blog home`: Renders the blog index page
+   - `render-pages`: Renders standard pages from markdown files
+   - `render-blog-pages`: Renders blog pages from blog markdown files
+   - `render-author-indexes`: Renders author index pages and index list
+   - `render-tag-indexes`: Renders tag index pages and index list
+   - `render-blog-home`: Renders the blog index page
+
+5. **Sequential task:**
+   - `sitemap`: Generates `sitemap.xml` from rendered page state entries
 
 ## Logging
 

+ 2 - 1
src/actions/generateFavicons/README.md

@@ -5,7 +5,8 @@ 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`.
+- `key` (string, injected): Task key used for the result `ref`.
+- `name` (string, injected): Human-readable task name.
 
 ## Usage
 ```javascript

+ 2 - 1
src/actions/generateFavicons/index.js

@@ -3,6 +3,7 @@ import favicons from "favicons"
 import { getCleanPath, writeFile } from "../../util/index.js"
 
 export async function generateFavicons({ meta, config: actionConfig }) {
+  const ref = actionConfig.key || actionConfig.name
   const filePath = actionConfig.filePath
   const fileOutputDir = actionConfig.fileOutputDir
   // Configuration for favicons package
@@ -65,7 +66,7 @@ export async function generateFavicons({ meta, config: actionConfig }) {
         ...response.images.map(img => path.join(fileOutputDir, img.name)),
         ...response.files.map(file => path.join(fileOutputDir, file.name)),
       ],
-      ref: actionConfig.name,
+      ref,
     }
   } catch (error) {
     throw new Error(`Failed to generate favicons: ${error.message}`)

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

@@ -0,0 +1,28 @@
+# generateSitemap
+
+Generates a `sitemap.xml` from state inputs selected via `jobConfig.stateSelectors`.
+
+## actionConfig options
+- `outputFileName` (string, optional): Output filename in `opts.outDir`. Defaults to `sitemap.xml`.
+- `fileOutputPath` (string, optional): Explicit sitemap output path override.
+
+## Usage
+```javascript
+action: generateSitemap,
+jobConfig: {
+  stateSelectors: [
+    "resources.render-pages",
+    "resources.render-blog-pages"
+  ],
+  expand: false,
+  skipCache: true
+},
+actionConfig: {
+  outputFileName: "sitemap.xml"
+}
+```
+
+## Notes
+- Requires `opts.site.url` to be set to a valid URL.
+- Emits one URL per unique `href` found in inputs.
+- Emits `<lastmod>` values using `modified`, then `date`, then `lastmod` from each input entry.

+ 121 - 0
src/actions/generateSitemap/index.js

@@ -0,0 +1,121 @@
+import path from "node:path"
+import {
+  slugifyString,
+  writeFile,
+} from "../../util/index.js"
+
+function getSiteBaseUrl(siteUrl) {
+  if (typeof siteUrl !== "string" || !siteUrl.trim()) {
+    throw new Error("Cannot generate sitemap.xml without opts.site.url")
+  }
+  try {
+    return new URL(siteUrl)
+  } catch {
+    throw new Error(`Cannot generate sitemap.xml with invalid opts.site.url: ${siteUrl}`)
+  }
+}
+
+function escapeXml(value) {
+  return value
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&apos;")
+}
+
+function normaliseHref(href) {
+  if (typeof href !== "string" || !href.trim()) {
+    return null
+  }
+  const trimmedHref = href.trim()
+  if (trimmedHref === "/") {
+    return "/"
+  }
+  return trimmedHref.startsWith("/") ? trimmedHref : `/${trimmedHref}`
+}
+
+function normaliseDate(value) {
+  if (!value) {
+    return null
+  }
+  if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
+    return value
+  }
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) {
+    return null
+  }
+  return date.toISOString().slice(0, 10)
+}
+
+function getEntryLastmod(entry) {
+  return (
+    normaliseDate(entry?.modified) ||
+    normaliseDate(entry?.date) ||
+    normaliseDate(entry?.lastmod)
+  )
+}
+
+export async function generateSitemap({ config: actionConfig, meta }) {
+  const outDir = meta.opts.outDir
+  const siteBaseUrl = getSiteBaseUrl(meta.opts.site?.url)
+  const outputFileName = actionConfig.outputFileName || "sitemap.xml"
+  const fileOutputPath =
+    actionConfig.fileOutputPath || path.join(outDir, outputFileName)
+  const inputs = Array.isArray(actionConfig.inputs)
+    ? actionConfig.inputs.flat()
+    : []
+
+  const urlsByHref = new Map()
+  for (const input of inputs) {
+    if (!input || typeof input !== "object") {
+      continue
+    }
+    const href = normaliseHref(input.href)
+    if (!href) {
+      continue
+    }
+    const lastmod = getEntryLastmod(input)
+    const existing = urlsByHref.get(href)
+    if (!existing) {
+      urlsByHref.set(href, { href, lastmod })
+      continue
+    }
+    if (!existing.lastmod || (lastmod && lastmod > existing.lastmod)) {
+      urlsByHref.set(href, { href, lastmod })
+    }
+  }
+
+  const uniqueUrls = [...urlsByHref.values()]
+    .map(({ href, lastmod }) => {
+      return {
+        loc: new URL(href, siteBaseUrl).toString(),
+        lastmod,
+      }
+    })
+    .sort((a, b) => a.loc.localeCompare(b.loc))
+
+  const sitemap = [
+    "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
+    "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">",
+    ...uniqueUrls.map(
+      ({ loc, lastmod }) => {
+        const lastmodTag = lastmod ? `<lastmod>${lastmod}</lastmod>` : ""
+        return `  <url><loc>${escapeXml(loc)}</loc>${lastmodTag}</url>`
+      },
+    ),
+    "</urlset>",
+    "",
+  ].join("\n")
+
+  await writeFile(fileOutputPath, sitemap)
+
+  return {
+    detail: {
+      urlCount: uniqueUrls.length,
+    },
+    paths: [fileOutputPath],
+    ref: slugifyString(fileOutputPath),
+  }
+}

+ 2 - 1
src/actions/generateTaxonomy/README.md

@@ -8,7 +8,8 @@ Builds a taxonomy or ordered list from a set of inputs.
 - `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`.
+- `key` (string, injected): Task key used for the result `ref`.
+- `name` (string, injected): Human-readable task name.
 
 ## Usage
 ```javascript

+ 4 - 3
src/actions/generateTaxonomy/index.js

@@ -1,10 +1,11 @@
 import _ from "lodash-es"
 
 export async function generateTaxonomy({ config: actionConfig }) {
+  const ref = actionConfig.key || actionConfig.name
   if (!Array.isArray(actionConfig.inputs)) {
     return {
       detail: actionConfig.indexOn ? {} : [],
-      ref: actionConfig.name,
+      ref,
     }
   }
   const orderBy = actionConfig.orderBy || "date"
@@ -22,7 +23,7 @@ export async function generateTaxonomy({ config: actionConfig }) {
   if (!actionConfig.indexOn) {
     return {
       detail: sortedInputs.map(buildEntry),
-      ref: actionConfig.name,
+      ref,
     }
   }
 
@@ -60,6 +61,6 @@ export async function generateTaxonomy({ config: actionConfig }) {
   const taxonomy = Object.fromEntries(groups)
   return {
     detail: taxonomy,
-    ref: actionConfig.name,
+    ref,
   }
 }

+ 1 - 0
src/actions/index.js

@@ -3,6 +3,7 @@ export { clearTemplateCache } from "./_shared/template-cache.js"
 export { compileSass } from "./compileSass/index.js"
 export { copy } from "./copy/index.js"
 export { generateFavicons } from "./generateFavicons/index.js"
+export { generateSitemap } from "./generateSitemap/index.js"
 export { generateTaxonomy } from "./generateTaxonomy/index.js"
 export { imageToWebP } from "./imageToWebP/index.js"
 export { optimiseSvg } from "./optimiseSvg/index.js"

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

@@ -27,3 +27,7 @@ actionConfig: {
 - Uses frontmatter `template` when present, otherwise `defaultTemplate`.
 - Writes minified HTML to `fileOutputPath` and records the template as a dependency.
 - Respects `opts.markdown.allowHtml` to enable or strip raw HTML in markdown.
+- When rendering with template `article`, it auto-injects `BlogPosting` JSON-LD.
+- JSON-LD frontmatter sources: `title`, `description`, `date`/`published`, `modified`/`updated`, `author`, `image`/`images`/`heroImage`/`coverImage`.
+- Set frontmatter `includeArticleJsonLd: false` to disable auto-injection for a page.
+- Template context includes `structuredData.article` (object) and `structuredData.articleJsonLd` (JSON string).

+ 171 - 1
src/actions/renderMarkdownWithTemplate/index.js

@@ -5,6 +5,162 @@ import { getHref, slugifyString, writeFile } from "../../util/index.js"
 import { createMarkdownRenderer } from "../_shared/markdown.js"
 import { getTemplateByName, loadPartials } from "../_shared/template-cache.js"
 
+function normaliseDateValue(value) {
+  if (!value) {
+    return null
+  }
+  if (typeof value === "string") {
+    const trimmed = value.trim()
+    if (!trimmed) {
+      return null
+    }
+    if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
+      return trimmed
+    }
+    if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
+      return trimmed
+    }
+  }
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) {
+    return null
+  }
+  return date.toISOString()
+}
+
+function toAbsoluteUrl(urlOrPath, siteUrl) {
+  if (!urlOrPath || !siteUrl) {
+    return null
+  }
+  try {
+    return new URL(urlOrPath, siteUrl).toString()
+  } catch {
+    return null
+  }
+}
+
+function normaliseAuthor(authorValue) {
+  if (Array.isArray(authorValue)) {
+    return authorValue
+      .map(author => {
+        if (typeof author === "string") {
+          return {
+            "@type": "Person",
+            name: author,
+          }
+        }
+        if (author && typeof author === "object" && author.name) {
+          return {
+            "@type": "Person",
+            name: author.name,
+          }
+        }
+        return null
+      })
+      .filter(Boolean)
+  }
+
+  if (typeof authorValue === "string") {
+    return {
+      "@type": "Person",
+      name: authorValue,
+    }
+  }
+
+  if (authorValue && typeof authorValue === "object" && authorValue.name) {
+    return {
+      "@type": "Person",
+      name: authorValue.name,
+    }
+  }
+
+  return null
+}
+
+function getImageCandidates(data) {
+  const candidates = []
+  if (Array.isArray(data.images)) {
+    candidates.push(...data.images)
+  }
+  if (data.image) {
+    candidates.push(data.image)
+  }
+  if (data.heroImage) {
+    candidates.push(data.heroImage)
+  }
+  if (data.coverImage) {
+    candidates.push(data.coverImage)
+  }
+  return [...new Set(candidates.filter(Boolean))]
+}
+
+function buildArticleJsonLd(data, meta, href) {
+  const siteUrl = meta.opts.site?.url
+  const pageUrl = toAbsoluteUrl(href, siteUrl)
+  const headline = data.title || data.headline
+  if (!pageUrl || !headline) {
+    return null
+  }
+
+  const datePublished = normaliseDateValue(
+    data.datePublished || data.published || data.date,
+  )
+  const dateModified = normaliseDateValue(
+    data.dateModified || data.modified || data.updated || datePublished,
+  )
+  const images = getImageCandidates(data)
+    .map(image => toAbsoluteUrl(image, siteUrl))
+    .filter(Boolean)
+  const author = normaliseAuthor(data.author || data.authors)
+  const publisherName = meta.opts.site?.name
+
+  return {
+    "@context": "https://schema.org",
+    "@type": "BlogPosting",
+    headline,
+    ...(data.description || meta.opts.site?.description
+      ? { description: data.description || meta.opts.site?.description }
+      : {}),
+    ...(datePublished ? { datePublished } : {}),
+    ...(dateModified ? { dateModified } : {}),
+    ...(author ? { author } : {}),
+    ...(images.length ? { image: images } : {}),
+    url: pageUrl,
+    mainEntityOfPage: {
+      "@type": "WebPage",
+      "@id": pageUrl,
+    },
+    ...(meta.opts.site?.language ? { inLanguage: meta.opts.site.language } : {}),
+    ...(publisherName
+      ? {
+        publisher: {
+          "@type": "Organization",
+          name: publisherName,
+          ...(siteUrl ? { url: siteUrl } : {}),
+        },
+      }
+      : {}),
+  }
+}
+
+function safeJsonLdString(jsonLdObject) {
+  return JSON.stringify(jsonLdObject).replace(/<\/script/gi, "<\\/script")
+}
+
+function injectJsonLdScript(html, jsonLdString) {
+  if (!jsonLdString) {
+    return html
+  }
+  const script = `<script type="application/ld+json">${jsonLdString}</script>`
+  if (html.includes("</head>")) {
+    return html.replace("</head>", `  ${script}\n</head>`)
+  }
+  if (html.includes("</body>")) {
+    return html.replace("</body>", `  ${script}\n</body>`)
+  }
+  return `${html}\n${script}`
+}
+
 export async function renderMarkdownWithTemplate({ config: actionConfig, meta }) {
   const filePath = actionConfig.filePath
   const fileOutputPath = actionConfig.fileOutputPath
@@ -12,16 +168,30 @@ export async function renderMarkdownWithTemplate({ config: actionConfig, meta })
   const { data, content: markdown } = matter(content)
   const templateName = data.template || actionConfig.defaultTemplate
   const href = getHref(fileOutputPath, meta)
+  const shouldEmitArticleJsonLd =
+    data.includeArticleJsonLd !== false &&
+    templateName === "article"
+  const articleJsonLd = shouldEmitArticleJsonLd
+    ? buildArticleJsonLd(data, meta, href)
+    : null
+  const articleJsonLdString = articleJsonLd
+    ? safeJsonLdString(articleJsonLd)
+    : null
 
   const partialPaths = await loadPartials(actionConfig.partialDirs)
   const template = await getTemplateByName(actionConfig.templateDirs, templateName)
   const renderer = createMarkdownRenderer(meta)
-  const html = template.renderer({
+  const renderedHtml = template.renderer({
     ...data,
     ...meta,
     href,
+    structuredData: {
+      article: articleJsonLd,
+      articleJsonLd: articleJsonLdString,
+    },
     content: renderer(markdown),
   })
+  const html = injectJsonLdScript(renderedHtml, articleJsonLdString)
   const minifiedHtml = await minify(html, {
     collapseWhitespace: true,
     removeComments: true,

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

@@ -36,3 +36,4 @@ actionConfig: {
 - All `actionConfig` properties are passed into the template context along with `meta`.
 - For state tasks, `fileOutputPath` is often provided via upstream `detail` or `buildFilePath`.
 - `indexPages` entries include `key`, `title`, `href`, `fileOutputPath`, `count`, and `totalPages`.
+- Returned `detail` includes `href`, and includes `lastmod` when `inputs` contain `modified`/`date` fields.

+ 40 - 2
src/actions/renderTemplate/index.js

@@ -10,6 +10,35 @@ import {
   loadPartials,
 } from "../_shared/template-cache.js"
 
+function normaliseDate(value) {
+  if (!value) {
+    return null
+  }
+  if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
+    return value
+  }
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) {
+    return null
+  }
+  return date.toISOString().slice(0, 10)
+}
+
+function getMostRecentInputDate(inputs) {
+  if (!Array.isArray(inputs) || !inputs.length) {
+    return null
+  }
+  const dateCandidates = inputs
+    .map(item => normaliseDate(item?.modified) || normaliseDate(item?.date))
+    .filter(Boolean)
+  if (!dateCandidates.length) {
+    return null
+  }
+  return dateCandidates.reduce((latest, candidate) => {
+    return candidate > latest ? candidate : latest
+  })
+}
+
 export async function renderTemplate({ config: actionConfig, meta }) {
   const templateName = actionConfig.template || actionConfig.defaultTemplate
   const templatePath =
@@ -17,6 +46,7 @@ export async function renderTemplate({ config: actionConfig, meta }) {
     (await findTemplatePath(actionConfig.templateDirs, templateName))
   const fileOutputPath = actionConfig.fileOutputPath
   const href = getHref(fileOutputPath, meta)
+  const lastmod = getMostRecentInputDate(actionConfig.inputs)
 
   // Load partials from configured directories
   const partialPaths = await loadPartials(actionConfig.partialDirs)
@@ -39,7 +69,11 @@ export async function renderTemplate({ config: actionConfig, meta }) {
 
     await writeFile(fileOutputPath, minifiedHtml)
     return {
-      detail: { html },
+      detail: {
+        html,
+        href,
+        ...(lastmod ? { lastmod } : {}),
+      },
       deps: {
         paths: [template.path, ...partialPaths],
       },
@@ -49,7 +83,11 @@ export async function renderTemplate({ config: actionConfig, meta }) {
   }
 
   return {
-    detail: { html },
+    detail: {
+      html,
+      href,
+      ...(lastmod ? { lastmod } : {}),
+    },
     deps: {
       paths: [template.path, ...partialPaths],
     },

+ 55 - 19
src/defaults.js

@@ -2,6 +2,7 @@ import {
   compileSass,
   copy,
   generateFavicons,
+  generateSitemap,
   imageToWebP,
   optimiseSvg,
   renderTemplate,
@@ -14,7 +15,8 @@ import {
 export const tasks = [
   [
     {
-      name: "images",
+      key: "images",
+      name: "Images",
       action: imageToWebP,
       jobConfig: {
         inputFiles: [{ pattern: "images/content/*.jpg" }],
@@ -36,7 +38,8 @@ export const tasks = [
       },
     },
     {
-      name: "styles",
+      key: "styles",
+      name: "Styles",
       action: compileSass,
       jobConfig: {
         inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
@@ -47,7 +50,8 @@ export const tasks = [
       actionConfig: {},
     },
     {
-      name: "icons",
+      key: "icons",
+      name: "Icons",
       action: optimiseSvg,
       jobConfig: {
         inputFiles: [{ pattern: "images/icons/*.svg" }],
@@ -58,7 +62,8 @@ export const tasks = [
       actionConfig: {},
     },
     {
-      name: "static files",
+      key: "static-files",
+      name: "Static Files",
       action: copy,
       jobConfig: {
         inputFiles: [{ pattern: "static/*" }],
@@ -67,7 +72,8 @@ export const tasks = [
       actionConfig: {},
     },
     {
-      name: "favicons",
+      key: "favicons",
+      name: "Favicons",
       action: generateFavicons,
       jobConfig: {
         inputFiles: [{ pattern: "images/favicon/*" }],
@@ -78,7 +84,8 @@ export const tasks = [
     },
   ],
   {
-    name: "blog-markdown",
+    key: "blog-markdown",
+    name: "Blog Markdown",
     action: renderMarkdownToMeta,
     jobConfig: {
       inputFiles: [{ pattern: "markdown/blog/*.md" }],
@@ -88,7 +95,8 @@ export const tasks = [
     actionConfig: {},
   },
   {
-    name: "markdown",
+    key: "markdown",
+    name: "Markdown",
     action: renderMarkdownToMeta,
     jobConfig: {
       inputFiles: [{ pattern: "markdown/*.md" }],
@@ -99,7 +107,8 @@ export const tasks = [
   },
   [
     {
-      name: "author-taxonomy",
+      key: "author-taxonomy",
+      name: "Author Taxonomy",
       action: generateTaxonomy,
       jobConfig: {
         stateSelectors: ["resources.blog-markdown"],
@@ -110,13 +119,14 @@ export const tasks = [
         indexOn: "author",
         orderBy: "date",
         properties: [
-          "title", "href", "date", "author", "tags", "description",
+          "title", "href", "date", "modified", "author", "tags", "description",
         ],
         sortAscending: false,
       },
     },
     {
-      name: "tag-taxonomy",
+      key: "tag-taxonomy",
+      name: "Tag Taxonomy",
       action: generateTaxonomy,
       jobConfig: {
         stateSelectors: ["resources.blog-markdown"],
@@ -127,13 +137,14 @@ export const tasks = [
         indexOn: "tags",
         orderBy: "date",
         properties: [
-          "title", "href", "date", "author", "tags", "description",
+          "title", "href", "date", "modified", "author", "tags", "description",
         ],
         sortAscending: false,
       },
     },
     {
-      name: "blog-latest",
+      key: "blog-latest",
+      name: "Blog Latest",
       action: generateTaxonomy,
       jobConfig: {
         stateSelectors: ["resources.blog-markdown"],
@@ -143,13 +154,14 @@ export const tasks = [
       actionConfig: {
         orderBy: "date",
         properties: [
-          "title", "href", "date", "author", "tags", "description",
+          "title", "href", "date", "modified", "author", "tags", "description",
         ],
         sortAscending: false,
       },
     },
     {
-      name: "includes",
+      key: "includes",
+      name: "Includes",
       action: renderTemplate,
       jobConfig: {
         inputFiles: [{ pattern: "includes/*.hbs" }],
@@ -161,7 +173,8 @@ export const tasks = [
   ],
   [
     {
-      name: "render pages",
+      key: "render-pages",
+      name: "Render Pages",
       action: renderMarkdownWithTemplate,
       jobConfig: {
         inputFiles: [{ pattern: "markdown/*.md" }],
@@ -175,7 +188,8 @@ export const tasks = [
       },
     },
     {
-      name: "render blog pages",
+      key: "render-blog-pages",
+      name: "Render Blog Pages",
       action: renderMarkdownWithTemplate,
       jobConfig: {
         inputFiles: [{ pattern: "markdown/blog/*.md" }],
@@ -189,7 +203,8 @@ export const tasks = [
       },
     },
     {
-      name: "render author indexes",
+      key: "render-author-indexes",
+      name: "Render Author Indexes",
       action: renderIndex,
       jobConfig: {
         stateSelectors: ["resources.author-taxonomy.detail"],
@@ -205,7 +220,8 @@ export const tasks = [
       },
     },
     {
-      name: "render tag indexes",
+      key: "render-tag-indexes",
+      name: "Render Tag Indexes",
       action: renderIndex,
       jobConfig: {
         stateSelectors: ["resources.tag-taxonomy.detail"],
@@ -221,7 +237,8 @@ export const tasks = [
       },
     },
     {
-      name: "render blog home",
+      key: "render-blog-home",
+      name: "Render Blog Home",
       action: renderIndex,
       jobConfig: {
         stateSelectors: ["resources.blog-latest.detail"],
@@ -240,6 +257,25 @@ export const tasks = [
       },
     },
   ],
+  {
+    key: "sitemap",
+    name: "Sitemap",
+    action: generateSitemap,
+    jobConfig: {
+      stateSelectors: [
+        "resources.render-pages",
+        "resources.render-blog-pages",
+        "resources.render-author-indexes",
+        "resources.render-tag-indexes",
+        "resources.render-blog-home",
+      ],
+      expand: false,
+      skipCache: true,
+    },
+    actionConfig: {
+      outputFileName: "sitemap.xml",
+    },
+  },
 ]
 
 export const opts = {

+ 22 - 4
src/index.js

@@ -1,12 +1,30 @@
 #!/usr/bin/env node
 
 import * as defaultConfig from "./defaults.js"
-import { getConfig, processTask } from "./lib.js"
+import { getConfig, getTaskKey, getTaskName, processTask } from "./lib.js"
 import { performance } from "node:perf_hooks"
 import { getLogger } from "./logging.js"
 const startTime = performance.now()
 const { opts, tasks } = await getConfig() || { ...defaultConfig }
 const log = getLogger(opts.logLevel, "main")
+const flatTasks = tasks.flatMap(step => (Array.isArray(step) ? step : [step]))
+const duplicateTaskKeys = []
+const seenTaskKeys = new Set()
+for (const task of flatTasks) {
+  const taskKey = getTaskKey(task)
+  if (!taskKey) {
+    throw new Error("Each task must define `key` (or legacy `name`).")
+  }
+  if (seenTaskKeys.has(taskKey)) {
+    duplicateTaskKeys.push(taskKey)
+  } else {
+    seenTaskKeys.add(taskKey)
+  }
+}
+if (duplicateTaskKeys.length > 0) {
+  const uniqueDuplicates = [...new Set(duplicateTaskKeys)]
+  throw new Error(`Duplicate task keys found: ${uniqueDuplicates.join(", ")}`)
+}
 log.info(`Processing ${tasks.length} steps`)
 log.debug(`Running directory: ${opts.runDir}`)
 log.debug(`Output directory: ${opts.outDir}`)
@@ -20,10 +38,10 @@ const taskRunner = tasks.reduce(
   async (metaPromise, step) => {
     const tasks = Array.isArray(step) ? step : [step]
     const { meta, filesWritten } = await metaPromise
-    const stepTasks = tasks.map(task => task.name)
+    const stepTasks = tasks.map(task => getTaskName(task))
     log.info(`Starting tasks: ${stepTasks.join(", ")}`)
     const stepResults = await Promise.all(tasks.map(async task => {
-      const log = getLogger(opts.logLevel, task.name)
+      const log = getLogger(opts.logLevel, getTaskName(task))
       const taskResult = await processTask(meta, task)
       log.trace(`taskResult: ${JSON.stringify(taskResult)}`)
       return taskResult
@@ -31,7 +49,7 @@ const taskRunner = tasks.reduce(
     const newState = stepResults.reduce((newState, taskResult) => {
       const resources = Object.keys(taskResult.resources).length > 0 ? {
         ...newState.meta.resources,
-        [taskResult.name]: taskResult.resources,
+        [taskResult.key]: taskResult.resources,
       } : { ...newState.meta.resources }
       return {
         meta: {

+ 53 - 30
src/lib.js

@@ -101,6 +101,23 @@ function getIndexPropertyLabel(propertyPath) {
   return label.replace(/\b\w/g, char => char.toUpperCase())
 }
 
+export function getTaskKey(task) {
+  return task?.key || task?.name
+}
+
+export function getTaskName(task) {
+  return task?.name || task?.key || "unnamed task"
+}
+
+function getTaskIdentity(task) {
+  const key = getTaskKey(task)
+  const name = getTaskName(task)
+  if (!key) {
+    throw new Error("Task must define `key` (or legacy `name`).")
+  }
+  return { key, name }
+}
+
 export async function getConfig() {
   const args = process.argv.slice(2)
   const defaultPath = path.join(process.cwd(), "rhedyn.config.js")
@@ -235,6 +252,7 @@ function selectFiles(patternsToInclude, jobConfig) {
 
 async function expandFileTask(patternsToInclude, task, jobConfig, actionConfig, meta) {
   const filesToProcess = await selectFiles(patternsToInclude, jobConfig)(meta)
+  const { key: taskKey } = getTaskIdentity(task)
 
   return await Promise.all(
     filesToProcess.map(async fileJob => {
@@ -249,7 +267,7 @@ async function expandFileTask(patternsToInclude, task, jobConfig, actionConfig,
         action: task.action,
         jobConfig,
         actionConfig: jobActionConfig,
-        jobId: `${task.name} @ ${fileJob.filePath}`,
+        jobId: `${taskKey} @ ${fileJob.filePath}`,
       })
     }),
   )
@@ -278,6 +296,7 @@ function selectState(stateToSelect, meta) {
 
 async function expandStateTask(stateToExpand, task, jobConfig, actionConfig, meta) {
   const stateToProcess = selectState(stateToExpand, meta)
+  const { key: taskKey } = getTaskIdentity(task)
   const pathsToStrip = getPathsToStrip(jobConfig)
   const itemsPerPage = jobConfig.itemsPerPage ?? meta.opts.itemsPerPage
   const shouldSetIndexTitle = jobConfig.buildFilePath && actionConfig.writeOut
@@ -359,25 +378,25 @@ async function expandStateTask(stateToExpand, task, jobConfig, actionConfig, met
   })
 
   const jobPromises = jobs.map(async ({ stateJob, page, decorations }) => {
-      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,
-        action: task.action,
-        jobConfig,
-        actionConfig: jobActionConfig,
-        jobId: `${task.name} @ ${stateJob.property}:${stateJob.index}${pageInfo}`,
-      })
+    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,
+      action: task.action,
+      jobConfig,
+      actionConfig: jobActionConfig,
+      jobId: `${taskKey} @ ${stateJob.property}:${stateJob.index}${pageInfo}`,
     })
+  })
 
   if (shouldBuildIndexList) {
     const indexPropertyLabel = getIndexPropertyLabel(stateToProcess[0]?.property)
@@ -431,7 +450,7 @@ async function expandStateTask(stateToExpand, task, jobConfig, actionConfig, met
         action: task.action,
         jobConfig,
         actionConfig: indexJobActionConfig,
-        jobId: `${task.name} @ index-list`,
+        jobId: `${taskKey} @ index-list`,
       }),
     )
   }
@@ -440,18 +459,20 @@ async function expandStateTask(stateToExpand, task, jobConfig, actionConfig, met
 }
 
 export async function expandAndRunTask(meta, task) {
+  const { key: taskKey, name: taskName } = getTaskIdentity(task)
   const jobConfig = task.jobConfig || {}
   const actionConfig = {
     ...(task.actionConfig || {}),
-    name: task.name,
+    name: taskName,
+    key: taskKey,
   }
-  const includes = meta.opts?.include?.[task.name] || []
+  const includes = meta.opts?.include?.[taskKey] || meta.opts?.include?.[taskName] || []
   const patternsToInclude = [...(jobConfig?.inputFiles || []), ...includes]
 
   if (patternsToInclude.length) {
     if (jobConfig.expand === false) {
       const inputs = selectFiles(patternsToInclude, jobConfig)
-      const jobId = jobConfig.jobId || task.name
+      const jobId = jobConfig.jobId || taskKey
       const jobActionConfig = {
         ...actionConfig,
         inputs,
@@ -486,7 +507,7 @@ export async function expandAndRunTask(meta, task) {
             meta.opts.outDir,
             jobConfig.outputDir,
             pathsToStrip,
-            jobConfig.outputFileName || task.name,
+            jobConfig.outputFileName || taskKey,
             jobConfig.outputFileExtension,
           )
           : null
@@ -508,7 +529,7 @@ export async function expandAndRunTask(meta, task) {
           outputFileExtension: jobConfig.outputFileExtension,
           ...(actionConfig.isIndexList == null ? { isIndexList: true } : {}),
         }
-        const jobId = jobConfig.jobId || task.name
+        const jobId = jobConfig.jobId || taskKey
         const taskResult = await runTask({
           meta,
           action: task.action,
@@ -537,7 +558,7 @@ export async function expandAndRunTask(meta, task) {
           outputFileExtension: jobConfig.outputFileExtension,
           ...(actionConfig.isIndexList == null ? { isIndexList: true } : {}),
         }
-        const jobId = jobConfig.jobId || task.name
+        const jobId = jobConfig.jobId || taskKey
         const taskResult = await runTask({
           meta,
           action: task.action,
@@ -551,7 +572,7 @@ export async function expandAndRunTask(meta, task) {
       const basePathForPagination = basePath
         ? basePath.replace(meta.opts.outDir, "/")
         : null
-      const baseJobId = jobConfig.jobId || task.name
+      const baseJobId = jobConfig.jobId || taskKey
       return await Promise.all(
         pages.map(({ items: pageItems, page, totalPages }) => {
           const fileOutputPath = basePath
@@ -599,7 +620,7 @@ export async function expandAndRunTask(meta, task) {
     return expandStateTask(jobConfig.stateSelectors, task, jobConfig, actionConfig, meta)
   }
 
-  const jobId = jobConfig.jobId || task.name
+  const jobId = jobConfig.jobId || taskKey
   const taskResult = await runTask({
     meta,
     action: task.action,
@@ -611,7 +632,8 @@ export async function expandAndRunTask(meta, task) {
 }
 
 export async function processTask(meta, task) {
-  const log = getLogger(meta.opts.logLevel, task.name)
+  const { key: taskKey, name: taskName } = getTaskIdentity(task)
+  const log = getLogger(meta.opts.logLevel, taskName)
   const startTime = performance.now()
   const taskResult = await expandAndRunTask(meta, task)
   const cached = taskResult.filter(taskResult => taskResult.fromCache)
@@ -648,7 +670,8 @@ export async function processTask(meta, task) {
     } | ${hrTime}`,
   )
   return {
-    name: task.name,
+    key: taskKey,
+    name: taskName,
     taskResult,
     cached,
     processed,

+ 10 - 1
src/util/path-utils.js

@@ -26,7 +26,16 @@ export function removeCwd(paths) {
 
 export function removeBasePaths(baseDirs, fullPath) {
   return baseDirs.reduce((cleanedPath, dir) => {
-    return cleanedPath.replace(dir, "")
+    if (typeof cleanedPath !== "string" || typeof dir !== "string" || !dir) {
+      return cleanedPath
+    }
+    const normalizedPath = cleanedPath.replace(/\\/g, "/")
+    const normalizedDir = dir.replace(/\\/g, "/")
+    const stripIndex = normalizedPath.indexOf(normalizedDir)
+    if (stripIndex === -1) {
+      return cleanedPath
+    }
+    return normalizedPath.slice(stripIndex + normalizedDir.length)
   }, fullPath)
 }