2 次代碼提交 a1629dbd1d ... 9195f6c557

作者 SHA1 備註 提交日期
  Craig Fletcher 9195f6c557 Add pagination 2 周之前
  Craig Fletcher 27b4e5d17a Refactor/tidy 2 周之前
共有 7 個文件被更改,包括 369 次插入106 次删除
  1. 9 0
      .claude/settings.local.json
  2. 22 14
      src/cache.js
  3. 7 1
      src/defaults.js
  4. 226 63
      src/lib.js
  5. 78 11
      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(

+ 7 - 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,
@@ -101,6 +101,7 @@ export const tasks = [
       processor: renderTemplate,
       writeOut: true,
       templateDirs: ["templates/", "~/.rhedyn/templates/"],
+      partialDirs: ["partials/", "~/.rhedyn/partials/"],
       defaultTemplate: "page",
     },
     {
@@ -110,9 +111,11 @@ export const tasks = [
       writeOut: true,
       template: "index",
       templateDirs: ["templates/", "~/.rhedyn/templates/"],
+      partialDirs: ["partials/", "~/.rhedyn/partials/"],
       outputFileExtension: ".html",
       outputDir: "by-tag/",
       buildFilePath: true,
+      itemsPerPage: 10,
     },
     {
       name: "render blog home",
@@ -121,12 +124,14 @@ export const tasks = [
       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,
     },
   ],
 ]
@@ -142,6 +147,7 @@ export const opts = {
   ignoreExisting: false,
   logLevel: "info",
   includeStateValues: true,
+  itemsPerPage: 25,
   site: {
     name: "Website generated by Rhedyn",
     shortName: "Rhedyn test site",

+ 226 - 63
src/lib.js

@@ -13,6 +13,73 @@ 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))
+}
+
+function paginateItems(items, itemsPerPage) {
+  if (!itemsPerPage || itemsPerPage <= 0 || !Array.isArray(items)) {
+    return [{ items, page: 1, totalPages: 1 }]
+  }
+  const totalPages = Math.ceil(items.length / itemsPerPage)
+  if (totalPages <= 1) {
+    return [{ items, page: 1, totalPages: 1 }]
+  }
+  const pages = []
+  for (let page = 1; page <= totalPages; page++) {
+    const start = (page - 1) * itemsPerPage
+    const end = start + itemsPerPage
+    pages.push({
+      items: items.slice(start, end),
+      page,
+      totalPages,
+    })
+  }
+  return pages
+}
+
+function buildPaginationMeta(page, totalPages, basePath) {
+  const ext = path.extname(basePath)
+  const base = basePath.slice(0, -ext.length)
+
+  const getPagePath = (pageNum) => {
+    if (pageNum === 1) return base.endsWith("/") ? base : `${base}/`
+    return `${base}/${pageNum}`
+  }
+
+  const pages = []
+  for (let i = 1; i <= totalPages; i++) {
+    pages.push({
+      pageNum: i,
+      path: getPagePath(i),
+      isCurrent: i === page,
+    })
+  }
+
+  return {
+    currentPage: page,
+    totalPages,
+    hasNextPage: page < totalPages,
+    hasPrevPage: page > 1,
+    nextPagePath: page < totalPages ? getPagePath(page + 1) : null,
+    prevPagePath: page > 1 ? getPagePath(page - 1) : null,
+    firstPagePath: getPagePath(1),
+    lastPagePath: getPagePath(totalPages),
+    pages,
+  }
+}
+
 export async function getConfig() {
   const args = process.argv.slice(2)
   const defaultPath = path.join(process.cwd(), "rhedyn.config.js")
@@ -38,8 +105,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 +187,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 +233,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,37 +252,73 @@ 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 || ""
-  return await Promise.all(
-    stateToProcess.map(async (stateJob, index) => {
+  const pathsToStrip = getPathsToStrip(config)
+  const itemsPerPage = config.itemsPerPage ?? meta.opts.itemsPerPage
+
+  // Build jobs, potentially with pagination
+  const jobs = stateToProcess.flatMap((stateJob, index) => {
+    const items = Array.isArray(stateJob.value)
+      ? stateJob.value
+      : stateJob.value?.detail || []
+
+    // Only paginate if items is an array and itemsPerPage is set
+    const shouldPaginate = Array.isArray(items) && itemsPerPage > 0
+    const pages = shouldPaginate
+      ? paginateItems(items, itemsPerPage)
+      : [{ items, page: 1, totalPages: 1 }]
+
+    return pages.map(({ items: pageItems, page, totalPages }) => {
+      const basePath = buildOutputPath(
+        meta.opts.outDir,
+        config.outputDir,
+        pathsToStrip,
+        stateJob.key || String(index),
+        config.outputFileExtension,
+      )
+
+      // For page 2+, modify the output path
+      const fileOutputPath = page === 1
+        ? basePath
+        : (() => {
+          const ext = path.extname(basePath)
+          const base = basePath.slice(0, -ext.length)
+          return `${base}/${page}${ext}`
+        })()
+
+      const pagination = totalPages > 1
+        ? buildPaginationMeta(page, totalPages, basePath.replace(meta.opts.outDir, "/"))
+        : null
+
       const decorations = {
         ...(Array.isArray(stateJob.value)
-          ? { inputs: stateJob.value }
-          : stateJob.value.detail),
-
-        ...(config.buildFilePath
-          ? {
-            fileOutputPath: path.join(
-              meta.opts.outDir,
-              outputDir,
-              replaceFileExtension(
-                removeBasePaths(pathsToStrip, stateJob.key || String(index)),
-                config.outputFileExtension,
-              ),
-            ),
-          }
-          : {}),
+          ? { inputs: pageItems }
+          : { ...stateJob.value?.detail, inputs: pageItems }),
+        ...(config.buildFilePath ? { fileOutputPath } : {}),
+        ...(pagination ? { pagination } : {}),
       }
+
+      return {
+        stateJob,
+        index,
+        page,
+        decorations,
+        fileOutputPath,
+      }
+    })
+  })
+
+  return await Promise.all(
+    jobs.map(async ({ stateJob, page, decorations }) => {
       const jobConfig = {
         ...config,
         ...decorations,
         stateKey: stateJob.key,
       }
+      const pageInfo = page > 1 ? `:page-${page}` : ""
       return runTask({
         meta,
         config: jobConfig,
-        jobId: `${config.name} @ ${stateJob.property}:${stateJob.index}`,
+        jobId: `${config.name} @ ${stateJob.property}:${stateJob.index}${pageInfo}`,
       })
     }),
   )
@@ -242,43 +345,103 @@ 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(
-              meta.opts.outDir,
-              config.outputDir,
-              replaceFileExtension(
-                removeBasePaths(
-                  pathsToStrip,
-                  config.outputFileName || config.name,
-                ),
-                config.outputFileExtension,
-              ),
-            ),
-          }
-          : {}),
+      const itemsPerPage = config.itemsPerPage
+      const shouldPaginate =
+        Array.isArray(inputs) && itemsPerPage != null && itemsPerPage > 0
+      const basePath =
+        (config.buildFilePath || shouldPaginate)
+          ? buildOutputPath(
+            meta.opts.outDir,
+            config.outputDir,
+            pathsToStrip,
+            config.outputFileName || config.name,
+            config.outputFileExtension,
+          )
+          : null
+
+      if (!shouldPaginate) {
+        const decorations = {
+          ...(config.buildFilePath
+            ? { fileOutputPath: basePath }
+            : {}),
+        }
+        const jobConfig = {
+          ...config,
+          ...decorations,
+          inputs,
+        }
+        const jobId = config.jobId || config.name
+        const taskResult = await runTask({
+          meta,
+          config: { ...jobConfig },
+          jobId,
+        })
+        return [taskResult]
       }
-      const jobConfig = {
-        ...config,
-        ...decorations,
-        inputs,
+
+      const pages = paginateItems(inputs, itemsPerPage)
+      if (pages.length <= 1) {
+        const decorations = {
+          ...(config.buildFilePath
+            ? { fileOutputPath: basePath }
+            : {}),
+        }
+        const jobConfig = {
+          ...config,
+          ...decorations,
+          inputs,
+        }
+        const jobId = config.jobId || config.name
+        const taskResult = await runTask({
+          meta,
+          config: { ...jobConfig },
+          jobId,
+        })
+        return [taskResult]
       }
-      const jobId = config.jobId || config.name
-      const taskResult = await runTask({
-        meta,
-        config: { ...jobConfig },
-        jobId,
-      })
-      return [taskResult]
+
+      const basePathForPagination = basePath
+        ? basePath.replace(meta.opts.outDir, "/")
+        : null
+      const baseJobId = config.jobId || config.name
+      return await Promise.all(
+        pages.map(({ items: pageItems, page, totalPages }) => {
+          const fileOutputPath = basePath
+            ? (page === 1
+              ? basePath
+              : (() => {
+                const ext = path.extname(basePath)
+                const base = basePath.slice(0, -ext.length)
+                return `${base}/${page}${ext}`
+              })())
+            : undefined
+          const pagination =
+            totalPages > 1 && basePathForPagination
+              ? buildPaginationMeta(page, totalPages, basePathForPagination)
+              : null
+          const decorations = {
+            ...(config.buildFilePath && fileOutputPath
+              ? { fileOutputPath }
+              : {}),
+            ...(pagination ? { pagination } : {}),
+          }
+          const jobConfig = {
+            ...config,
+            ...decorations,
+            inputs: pageItems,
+          }
+          const pageInfo = page > 1 ? `:page-${page}` : ""
+          return runTask({
+            meta,
+            config: { ...jobConfig },
+            jobId: `${baseJobId}${pageInfo}`,
+          })
+        }),
+      )
     }
 
     return expandStateTask(config.stateSelectors, config, meta)

+ 78 - 11
src/processors.js

@@ -1,5 +1,7 @@
 import * as sass from "sass"
 import {
+  escapeHtmlAttr,
+  expandTilde,
   firstFound,
   generateRandomId,
   getCleanPath,
@@ -7,6 +9,7 @@ import {
   slugifyString,
   writeFile,
 } from "./util/index.js"
+import { glob } from "glob"
 import fs from "fs/promises"
 import handlebars from "handlebars"
 import { marked } from "marked"
@@ -20,6 +23,56 @@ 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
@@ -28,27 +81,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(" ")} >`
@@ -81,6 +134,9 @@ export async function renderTemplate({ config, meta }) {
   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,
@@ -101,7 +157,7 @@ export async function renderTemplate({ config, meta }) {
     return {
       detail: { html },
       deps: {
-        paths: [template.path],
+        paths: [template.path, ...partialPaths],
       },
       paths: [fileOutputPath],
       ref: slugifyString(href),
@@ -111,7 +167,7 @@ export async function renderTemplate({ config, meta }) {
   return {
     detail: { html },
     deps: {
-      paths: [template.path],
+      paths: [template.path, ...partialPaths],
     },
     ref: slugifyString(href),
   }
@@ -241,10 +297,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 +402,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;")
+}