Craig Fletcher 2 tygodni temu
rodzic
commit
27b4e5d17a
7 zmienionych plików z 122 dodań i 74 usunięć
  1. 9 0
      .claude/settings.local.json
  2. 22 14
      src/cache.js
  3. 1 1
      src/defaults.js
  4. 38 33
      src/lib.js
  5. 25 9
      src/processors.js
  6. 17 17
      src/util/file-system.js
  7. 10 0
      src/util/general-utils.js

+ 9 - 0
.claude/settings.local.json

@@ -0,0 +1,9 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(tree:*)",
+      "Bash(wc:*)",
+      "Bash(find:*)"
+    ]
+  }
+}

+ 22 - 14
src/cache.js

@@ -12,6 +12,13 @@ import {
 } from "./util/index.js"
 
 const objectHashCache = new Map()
+const fileHashCache = new Map()
+
+export function clearHashCaches() {
+  objectHashCache.clear()
+  fileHashCache.clear()
+}
+
 export function hashObject(obj) {
   const str = stableStringify(obj)
   if (!objectHashCache.has(str)) {
@@ -26,7 +33,6 @@ export function hashObject(obj) {
   }
 }
 
-const fileHashCache = new Map()
 export async function getFileHash(filePath, algorithm = "md5") {
   return new Promise((resolve, reject) => {
     if (!fileHashCache.has(filePath)) {
@@ -52,10 +58,9 @@ async function getFileHashes(pathDeps) {
     Object.keys(pathDeps).map(async filePath => {
       const hash = await getFileHash(filePath)
       if (hash !== pathDeps[filePath]) {
-        return Promise.reject({ filePath, hash })
+        throw { filePath, hash }
       }
-
-      return Promise.resolve(pathDeps[filePath])
+      return pathDeps[filePath]
     }),
   )
 }
@@ -82,12 +87,14 @@ export async function checkCache(cacheKey, currentState, opts) {
           await getFileHashes(existingCacheObject.content.deps.paths)
           return { hit: true, taskResult: existingCacheObject.content.taskResult, filePath: existingCacheObject.filePath }
         } catch (e) {
-          const updates = {
-            deps: {
-              paths: [e],
-            },
-          }
-          return { hit: false, reason: `File hash mismatch: ${e.filePath}`, updates, filePath: existingCacheObject.filePath }
+          const hashMismatch = e && typeof e === "object" && "filePath" in e && "hash" in e
+          const updates = hashMismatch
+            ? { deps: { paths: [e] } }
+            : undefined
+          const reason = hashMismatch
+            ? `File hash mismatch: ${e.filePath}`
+            : `File hash check failed: ${e?.message || e}`
+          return { hit: false, reason, updates, filePath: existingCacheObject.filePath }
         }
       }
       const updates = {
@@ -145,10 +152,11 @@ export async function updateCache(
   const statePropsList = Object.keys(deps.state)
   const stateValuesList = Object.values(deps.state)
   const updatesStateHash = updates?.deps?.state?.props || []
-  const stateDepsHash =
-    JSON.stringify(statePropsList) === JSON.stringify(updatesStateHash)
-      ? updates?.deps?.state?.hash
-      : hashObject(deps.state)
+  const arraysEqual = statePropsList.length === updatesStateHash.length &&
+    statePropsList.every((prop, i) => prop === updatesStateHash[i])
+  const stateDepsHash = arraysEqual
+    ? updates?.deps?.state?.hash
+    : hashObject(deps.state)
 
   const updatesPathsCache =
     updates?.deps?.paths?.reduce(

+ 1 - 1
src/defaults.js

@@ -55,7 +55,7 @@ export const tasks = [
   ],
   {
     name: "markdown",
-    inputFiles: [{ pattern: "markdown/*.md" }],
+    inputFiles: [{ pattern: "markdown/**/*.md" }],
     stripPaths: ["markdown/"],
     outputFileExtension: ".html",
     processor: renderMarkdownToHtml,

+ 38 - 33
src/lib.js

@@ -13,6 +13,21 @@ import path from "path"
 import process from "node:process"
 import { getLogger } from "./logging.js"
 
+function buildOutputPath(outDir, outputDir, pathsToStrip, inputPath, outputFileExtension) {
+  return path.join(
+    outDir,
+    outputDir || "",
+    replaceFileExtension(
+      removeBasePaths(pathsToStrip, inputPath),
+      outputFileExtension,
+    ),
+  )
+}
+
+function getPathsToStrip(config) {
+  return (config.stripPaths || []).map(p => expandTilde(p))
+}
+
 export async function getConfig() {
   const args = process.argv.slice(2)
   const defaultPath = path.join(process.cwd(), "rhedyn.config.js")
@@ -38,8 +53,7 @@ export async function getConfig() {
       const config = await import(configPath)
       return config.default || config
     } catch (err) {
-      console.error(`Error reading config file at ${configPath}:`, err)
-      throw new Error("Failed reading config file")
+      throw new Error(`Failed reading config file at ${configPath}: ${err.message}`, { cause: err })
     }
   } else {
     return
@@ -121,20 +135,18 @@ async function runTask({ meta, config, jobId }) {
 }
 
 function selectFiles(patternsToInclude, config) {
-  const pathsToStrip = (config.stripPaths || []).map(path => expandTilde(path))
-  const outputDir = config.outputDir || ""
+  const pathsToStrip = getPathsToStrip(config)
 
   return async meta => {
     const filesToProcess = await readFilesByGlob(patternsToInclude)
 
     return filesToProcess.map(filePath => {
-      const fileOutputPath = path.join(
+      const fileOutputPath = buildOutputPath(
         meta.opts.outDir,
-        outputDir,
-        replaceFileExtension(
-          removeBasePaths(pathsToStrip, filePath),
-          config.outputFileExtension,
-        ),
+        config.outputDir,
+        pathsToStrip,
+        filePath,
+        config.outputFileExtension,
       )
 
       return {
@@ -169,6 +181,9 @@ function selectState(stateToSelect, meta) {
   return stateToSelect
     .map(property => {
       const values = getValueAtPath(meta, property)
+      if (values == null) {
+        return []
+      }
       const expandedValues = Array.isArray(values)
         ? values
         : Object.values(values)
@@ -185,24 +200,22 @@ function selectState(stateToSelect, meta) {
 
 async function expandStateTask(stateToExpand, config, meta) {
   const stateToProcess = selectState(stateToExpand, meta)
-  const pathsToStrip = (config.stripPaths || []).map(path => expandTilde(path))
-  const outputDir = config.outputDir || ""
+  const pathsToStrip = getPathsToStrip(config)
   return await Promise.all(
     stateToProcess.map(async (stateJob, index) => {
       const decorations = {
         ...(Array.isArray(stateJob.value)
           ? { inputs: stateJob.value }
-          : stateJob.value.detail),
+          : stateJob.value?.detail || {}),
 
         ...(config.buildFilePath
           ? {
-            fileOutputPath: path.join(
+            fileOutputPath: buildOutputPath(
               meta.opts.outDir,
-              outputDir,
-              replaceFileExtension(
-                removeBasePaths(pathsToStrip, stateJob.key || String(index)),
-                config.outputFileExtension,
-              ),
+              config.outputDir,
+              pathsToStrip,
+              stateJob.key || String(index),
+              config.outputFileExtension,
             ),
           }
           : {}),
@@ -242,27 +255,19 @@ export async function expandAndRunTask(meta, config) {
 
   if (config.stateSelectors) {
     if (config.expand === false) {
-      const pathsToStrip = (config.stripPaths || []).map(path =>
-        expandTilde(path),
-      )
+      const pathsToStrip = getPathsToStrip(config)
       const inputs = selectState(config.stateSelectors, meta).map(stateItem => {
-        return stateItem.value.detail
-          ? stateItem.value.detail
-          : stateItem.value
+        return stateItem.value?.detail ?? stateItem.value
       })
       const decorations = {
         ...(config.buildFilePath
           ? {
-            fileOutputPath: path.join(
+            fileOutputPath: buildOutputPath(
               meta.opts.outDir,
               config.outputDir,
-              replaceFileExtension(
-                removeBasePaths(
-                  pathsToStrip,
-                  config.outputFileName || config.name,
-                ),
-                config.outputFileExtension,
-              ),
+              pathsToStrip,
+              config.outputFileName || config.name,
+              config.outputFileExtension,
             ),
           }
           : {}),

+ 25 - 9
src/processors.js

@@ -1,5 +1,6 @@
 import * as sass from "sass"
 import {
+  escapeHtmlAttr,
   firstFound,
   generateRandomId,
   getCleanPath,
@@ -21,6 +22,10 @@ import _ from "lodash-es"
 
 const templateCache = new Map()
 
+export function clearTemplateCache() {
+  templateCache.clear()
+}
+
 function createMarkdownRenderer(meta) {
   return marked
     .use({ gfm: true })
@@ -28,27 +33,27 @@ function createMarkdownRenderer(meta) {
     .use({
       renderer: {
         image({ href, title, text }) {
-          const attrs = [`alt="${text}"`]
+          const attrs = [`alt="${escapeHtmlAttr(text)}"`]
 
           const foundSrcSet = meta.resources.images?.[slugifyString(href)]
 
-          if (foundSrcSet) {
+          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="${defaultSrc}"`)
-            attrs.push(`srcset="${srcSetString}"`)
+            attrs.push(`src="${escapeHtmlAttr(defaultSrc)}"`)
+            attrs.push(`srcset="${escapeHtmlAttr(srcSetString)}"`)
             attrs.push("sizes=\"(min-width: 800px) 40vw, 100vw\"")
             attrs.push(
-              `style="aspect-ratio: ${foundSrcSet.detail.aspectRatio}"`,
+              `style="aspect-ratio: ${escapeHtmlAttr(foundSrcSet.detail.aspectRatio)}"`,
             )
           } else {
-            attrs.push(`src="${href}"`)
+            attrs.push(`src="${escapeHtmlAttr(href)}"`)
           }
 
           if (title) {
-            attrs.push(`title="${title}"`)
+            attrs.push(`title="${escapeHtmlAttr(title)}"`)
           }
 
           return `<img ${attrs.join(" ")} >`
@@ -241,10 +246,15 @@ export async function imageToWebP({ meta, config }) {
 
   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(
-    config.imageSizes.map(async size => {
-      const sizeNum = parseInt(size.replace("w", ""), 10)
+    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}`,
@@ -341,6 +351,12 @@ export async function generateFavicons({ meta, config }) {
 }
 
 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] || []
     items.forEach(v => values.add(v))

+ 17 - 17
src/util/file-system.js

@@ -38,28 +38,24 @@ export async function readDirectoryRecursively(dir, files = []) {
 //  ignore:  String | String[];
 // }
 export async function readFilesByGlob(globConfigs) {
-  const matchPromises = globConfigs.reduce(
-    async (existingMatches, globConfig) => {
+  const matchArrays = await Promise.all(
+    globConfigs.map(async globConfig => {
       const { pattern, ignore, dot } = {
         dot: false,
         ignore: [],
         ...globConfig,
       }
-      const matches = await glob(expandTilde(pattern), {
-        ignore,
-        dot,
-      })
-      return [...(await existingMatches), ...matches]
-    },
-    [],
+      return glob(expandTilde(pattern), { ignore, dot })
+    }),
   )
-  const files = await matchPromises
+  const files = matchArrays.flat()
   return [...new Set(files)]
 }
 
-export function expandTilde(path) {
-  if (!path.startsWith("~")) return path
-  return path.replace(/^~(?=$|\/|\\)/, os.homedir())
+export function expandTilde(pathStr) {
+  if (typeof pathStr !== "string") return pathStr
+  if (!pathStr.startsWith("~")) return pathStr
+  return pathStr.replace(/^~(?=$|\/|\\)/, os.homedir())
 }
 
 export async function checkFilesExist(files, baseDir) {
@@ -83,8 +79,12 @@ export async function checkFilesExist(files, baseDir) {
 
 export async function writeFile(filePath, content) {
   const fileDir = path.dirname(filePath)
-  await fs.mkdir(fileDir, { recursive: true })
-  return await fs.writeFile(filePath, content, {
-    encoding: "utf8",
-  })
+  try {
+    await fs.mkdir(fileDir, { recursive: true })
+    return await fs.writeFile(filePath, content, {
+      encoding: "utf8",
+    })
+  } catch (err) {
+    throw new Error(`Failed to write file ${filePath}: ${err.message}`, { cause: err })
+  }
 }

+ 10 - 0
src/util/general-utils.js

@@ -6,3 +6,13 @@ export function generateRandomId(length = 8) {
   }
   return result
 }
+
+export function escapeHtmlAttr(str) {
+  if (str == null) return ""
+  return String(str)
+    .replace(/&/g, "&amp;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#39;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+}