Sfoglia il codice sorgente

further refactoring

Craig Fletcher 2 settimane fa
parent
commit
83c34a7326

+ 28 - 7
README.md

@@ -21,7 +21,8 @@ extension. These file paths are made available to the template renderer, for inc
 
 Markdown is compiled using `marked` with the `marked-code-preview` extension enabled, then passed to the template
 renderer in the `content` property. Images referenced in markdown are automatically processed and converted to responsive
-srcsets if matching images are found in the images task.
+srcsets if matching images are found in the images task. Raw HTML in markdown is stripped by default; set
+`opts.markdown.allowHtml` to `true` to allow it.
 
 Once the markdown content and template have been rendered out, the resulting `.html` file is minified and output to `dist/`, 
 with the matching path (e.g. `markdown/recipes/soup.md` would be rendered to `dist/recipes/soup.html`).
@@ -50,8 +51,8 @@ Cache files are stored in `.cache/` by default and can be disabled by setting `c
 
 If output file checks are skipped with `ignoreExisting`, only files that have changed inputs will be output.
 
-You can get additional detail on state cache misses by setting `opts.includeStateValues` to `true`, at the cost of
-considerably larger cache files.
+`opts.includeStateValues` controls whether cache files store state values for debugging. It defaults to `true` and can be
+set to `false` to reduce cache file size.
 
 ## Configuration
 
@@ -83,11 +84,14 @@ opts: {
   cacheDir: '.cache',
   clean: true,
   ignoreExisting: false,
-  includeStateValues: false,
+  includeStateValues: true,
   logLevel: 'debug',
   include: { 
     styles: [{ pattern: '~/.rhedyn/styles/*.scss' }] 
   },
+  markdown: {
+    allowHtml: false
+  },
   site: {
     name: "My Website",
     shortName: "My Site",
@@ -226,12 +230,15 @@ async function myAction({ config, jobConfig, meta }) {
 
 The following actions are available from `actions`:
 - `compileSass`: Compiles SCSS files to compressed CSS
+- `renderMarkdownToHtml`: Parses markdown into HTML and returns it as task detail
+- `renderTemplate`: Renders Handlebars templates with the current task state
 - `renderIndex`: Renders index pages and index list pages
 - `renderMarkdownWithTemplate`: Renders markdown with Handlebars templates, includes frontmatter support
 - `optimiseSvg`: Optimizes SVG files using SVGO
 - `copy`: Copies files without processing
 - `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
 
 **Resources and Cross-Task References:**
 
@@ -245,14 +252,28 @@ Some examples can be found in `src/actions/`, and you can find utility functions
 The default configuration includes:
 
 1. **Parallel processing group:**
+   - `images`: Converts JPG images to WebP with multiple sizes
    - `styles`: Compiles SCSS files to CSS
    - `icons`: Optimizes SVG icons
-   - `images`: Converts JPG images to WebP with multiple sizes
    - `static files`: Copies static files
    - `favicons`: Generates favicon sets from source images
 
-2. **Sequential processing:**
-   - `pages`: Renders Markdown files with Handlebars templates (runs after the parallel group to access processed resources)
+2. **Sequential tasks:**
+   - `blog-markdown`: Parses blog markdown into HTML detail
+   - `markdown`: Parses general markdown into HTML detail
+
+3. **Parallel processing group:**
+   - `author-taxonomy`: Builds author groupings from blog entries
+   - `tag-taxonomy`: Builds tag groupings from blog entries
+   - `blog-latest`: Builds a sorted list of recent blog entries
+   - `includes`: Renders template includes to HTML
+
+4. **Parallel processing group:**
+   - `render pages`: Renders standard pages from markdown state
+   - `render blog pages`: Renders blog pages from blog markdown state
+   - `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
 
 ## Logging
 

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

@@ -3,11 +3,15 @@ import markedCodePreview from "marked-code-preview"
 import { escapeHtmlAttr, slugifyString } from "../../util/index.js"
 
 export function createMarkdownRenderer(meta) {
+  const allowHtml = meta?.opts?.markdown?.allowHtml === true
   return marked
     .use({ gfm: true })
     .use(markedCodePreview)
     .use({
       renderer: {
+        html(html) {
+          return allowHtml ? html : ""
+        },
         image({ href, title, text }) {
           const attrs = [`alt="${escapeHtmlAttr(text)}"`]
 

+ 13 - 12
src/actions/_shared/template-cache.js

@@ -6,7 +6,7 @@ import { expandTilde, firstFound } from "../../util/index.js"
 
 const templateCache = new Map()
 const partialCache = new Map()
-const registeredPartials = new Set()
+const registeredPartials = new Map()
 
 export function clearTemplateCache() {
   templateCache.clear()
@@ -37,18 +37,19 @@ export async function loadPartials(partialDirs) {
       // 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 existingPath = registeredPartials.get(partialName)
+      if (existingPath === filePath) {
+        continue
+      }
+
       const partial = partialCache.get(filePath)
-      handlebars.registerPartial(partial.name, partial.content)
-      registeredPartials.add(partialName)
+      handlebars.registerPartial(partialName, partial.content)
+      registeredPartials.set(partialName, filePath)
       loadedPaths.push(filePath)
     }
   }
@@ -74,14 +75,14 @@ export async function getTemplate(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 templatePath = await firstFound(templateDirs, `${templateName}.hbs`)
+  if (!templatePath) throw new Error(`Template not found: ${templateName}`)
+  if (!templateCache.has(templatePath)) {
     const templateContent = await fs.readFile(templatePath, "utf8")
-    templateCache.set(templateName, {
+    templateCache.set(templatePath, {
       path: templatePath,
       renderer: handlebars.compile(templateContent),
     })
   }
-  return templateCache.get(templateName)
+  return templateCache.get(templatePath)
 }

+ 47 - 41
src/actions/generateTaxonomy/index.js

@@ -7,51 +7,57 @@ export async function generateTaxonomy({ config: actionConfig }) {
       ref: actionConfig.name,
     }
   }
-  const allValues = actionConfig.inputs.reduce((values, curr) => {
-    const items = curr[actionConfig.indexOn]
+  const orderBy = actionConfig.orderBy || "date"
+  const inputs = actionConfig.inputs
+  const sortedInputs = actionConfig.sortAscending
+    ? _.sortBy(inputs, orderBy)
+    : _.sortBy(inputs, orderBy).reverse()
+  const buildEntry = item => actionConfig.properties
+    ? actionConfig.properties.reduce(
+      (ent, prop) => ({ ...ent, [prop]: item[prop] }),
+      {},
+    )
+    : item
+
+  if (!actionConfig.indexOn) {
+    return {
+      detail: sortedInputs.map(buildEntry),
+      ref: actionConfig.name,
+    }
+  }
+
+  const groupOrder = []
+  const groupSeen = new Set()
+  for (const item of inputs) {
+    const items = item?.[actionConfig.indexOn]
     const normalizedItems = Array.isArray(items)
       ? items
       : (items == null ? [] : [items])
-    normalizedItems.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]
-            const normalizedItems = Array.isArray(items)
-              ? items
-              : (items == null ? [] : [items])
-            return normalizedItems.includes(currentGroup)
-          })
-          .map(item => {
-            const entry = actionConfig.properties
-              ? actionConfig.properties.reduce(
-                (ent, prop) => ({ ...ent, [prop]: item[prop] }),
-                {},
-              )
-              : item
-            return entry
-          }),
+    for (const value of normalizedItems) {
+      if (!groupSeen.has(value)) {
+        groupSeen.add(value)
+        groupOrder.push(value)
       }
-      return grouped
-    }, {})
-    : sortedInputs.map(item => {
-      const entry = actionConfig.properties
-        ? actionConfig.properties.reduce(
-          (ent, prop) => ({ ...ent, [prop]: item[prop] }),
-          {},
-        )
-        : item
-      return entry
-    })
+    }
+  }
+
+  const groups = new Map(groupOrder.map(value => [value, []]))
+  for (const item of sortedInputs) {
+    const items = item?.[actionConfig.indexOn]
+    const normalizedItems = Array.isArray(items)
+      ? items
+      : (items == null ? [] : [items])
+    if (normalizedItems.length === 0) continue
+    const entry = buildEntry(item)
+    for (const value of normalizedItems) {
+      if (!groups.has(value)) {
+        groups.set(value, [])
+      }
+      groups.get(value).push(entry)
+    }
+  }
+
+  const taxonomy = Object.fromEntries(groups)
   return {
     detail: taxonomy,
     ref: actionConfig.name,

+ 10 - 1
src/actions/optimiseSvg/index.js

@@ -7,7 +7,16 @@ export async function optimiseSvg({ config: actionConfig }) {
   const fileOutputPath = actionConfig.fileOutputPath
   const svgString = await fs.readFile(filePath, "utf8")
   const result = optimize(svgString, {
-    plugins: ["preset-default"],
+    plugins: [
+      {
+        name: "preset-default",
+        params: {
+          overrides: {
+            removeViewBox: false,
+          },
+        },
+      },
+    ],
   })
   await writeFile(fileOutputPath, result.data)
   return {

+ 3 - 0
src/defaults.js

@@ -251,6 +251,9 @@ export const opts = {
   logLevel: "info",
   includeStateValues: true,
   itemsPerPage: 25,
+  markdown: {
+    allowHtml: false,
+  },
   site: {
     name: "Website generated by Rhedyn",
     shortName: "Rhedyn test site",

+ 8 - 1
src/lib.js

@@ -15,7 +15,7 @@ import process from "node:process"
 import { getLogger } from "./logging.js"
 
 function buildOutputPath(outDir, outputDir, pathsToStrip, inputPath, outputFileExtension) {
-  return path.join(
+  const outputPath = path.join(
     outDir,
     outputDir || "",
     replaceFileExtension(
@@ -23,6 +23,13 @@ function buildOutputPath(outDir, outputDir, pathsToStrip, inputPath, outputFileE
       outputFileExtension,
     ),
   )
+  const resolvedOutDir = path.resolve(outDir)
+  const resolvedOutputPath = path.resolve(outputPath)
+  const relativePath = path.relative(resolvedOutDir, resolvedOutputPath)
+  if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
+    throw new Error(`Output path escapes outDir: ${outputPath}`)
+  }
+  return outputPath
 }
 
 function getPathsToStrip(jobConfig) {