瀏覽代碼

Add image optimisation

Craig Fletcher 5 月之前
父節點
當前提交
5835cc9815
共有 6 個文件被更改,包括 1088 次插入85 次删除
  1. 873 13
      package-lock.json
  2. 3 1
      package.json
  3. 33 11
      src/defaults.js
  4. 7 5
      src/index.js
  5. 45 36
      src/lib.js
  6. 127 19
      src/processors.js

文件差異過大導致無法顯示
+ 873 - 13
package-lock.json


+ 3 - 1
package.json

@@ -25,7 +25,9 @@
     "handlebars": "^4.7.8",
     "marked": "^15.0.6",
     "marked-code-preview": "^1.3.7",
-    "sass": "^1.83.1"
+    "sass": "^1.83.1",
+    "sharp": "^0.34.2",
+    "svgo": "^3.3.2"
   },
   "devDependencies": {
     "@eslint/js": "^9.15.0",

+ 33 - 11
src/defaults.js

@@ -1,4 +1,9 @@
-import { compileSass, renderMarkdownWithTemplate } from "./processors.js"
+import {
+  compileSass,
+  optimiseSvg,
+  optimiseImage,
+  renderMarkdownWithTemplate
+} from "./processors.js";
 
 export const tasks = [
   {
@@ -7,7 +12,23 @@ export const tasks = [
     name: "styles",
     outputDir: "static/styles/",
     outputFileExtension: ".css",
-    processor: compileSass,
+    processor: compileSass
+  },
+  {
+    inputDirs: ["images/icons/"],
+    inputFileExtension: ".svg",
+    name: "icons",
+    outputDir: "static/icons/",
+    outputFileExtension: ".svg",
+    processor: optimiseSvg
+  },
+  {
+    inputDirs: ["images/content/"],
+    inputFileExtension: ".jpg",
+    name: "images",
+    outputDir: "images/",
+    outputFileExtension: ".webp",
+    processor: optimiseImage
   },
   {
     inputDirs: ["markdown/"],
@@ -15,22 +36,23 @@ export const tasks = [
     name: "pages",
     outputDir: "./",
     outputFileExtension: ".html",
-    processor: renderMarkdownWithTemplate,
-  },
-]
+    processor: renderMarkdownWithTemplate
+  }
+];
 
 export const opts = {
   baseDir: "dist/",
+  runDir: process.cwd(),
   defaultTemplate: "default",
   include: {
-    styles: ["~/.rhedyn/styles/"],
+    styles: ["~/.rhedyn/styles/"]
   },
-  templateDirs: ["templates/", "~/.rhedyn/templates/"],
-}
+  templateDirs: ["templates/", "~/.rhedyn/templates/"]
+};
 
 const defaults = {
   opts,
-  tasks,
-}
+  tasks
+};
 
-export default defaults
+export default defaults;

+ 7 - 5
src/index.js

@@ -6,10 +6,11 @@ import { getConfig, processFiles } from "./lib.js"
 const { opts, tasks } = await getConfig() || { ...defaultConfig }
 
 console.log(`Processing ${tasks.length} tasks`)
-tasks.reduce(
-  (meta, task) => {
+const taskRunner = tasks.reduce(
+  async (metaPromise, task) => {
+    const meta = await metaPromise;
     console.log("Processing task:", task.name)
-    const processedPaths = processFiles(task, meta)
+    const processedPaths = await processFiles(task, meta)
     console.log(
       `Processed ${processedPaths.length} paths for task ${task.name}`,
     )
@@ -21,6 +22,7 @@ tasks.reduce(
       },
     }
   },
-  { opts },
+   Promise.resolve({ opts }),
 )
-console.log("Done")
+await taskRunner;
+console.log("Done!")

+ 45 - 36
src/lib.js

@@ -1,56 +1,65 @@
 import {
   readDirectoryRecursively,
   removeBasePaths,
-  resolvePath,
-} from "./util.js"
-import fs from "fs"
-import path from "path"
-import process from "node:process"
+  resolvePath
+} from "./util.js";
+import fs from "fs";
+import { writeFile } from "node:fs/promises";
+import path from "path";
+import process from "node:process";
 
 export async function getConfig() {
-  const configPath = path.join(process.cwd(), "rhedyn.config.js")
+  const configPath = path.join(process.cwd(), "rhedyn.config.js");
   if (fs.existsSync(configPath)) {
     try {
-      const config = await import(configPath)
-      return config.default || config
+      const config = await import(configPath);
+      return config.default || config;
     } catch (err) {
-      console.error("Error reading rhedyn.config.js:", err)
-      throw new Error("Failed reading config file")
+      console.error("Error reading rhedyn.config.js:", err);
+      throw new Error("Failed reading config file");
     }
   } else {
-    return
+    return;
   }
 }
 
-export function processFiles(config, meta) {
-  const includes = meta.opts?.include?.[config.name] || []
-  const pathsToInclude = [...config.inputDirs, ...includes].map(resolvePath)
+export async function processFiles(config, meta) {
+  const includes = meta.opts?.include?.[config.name] || [];
+  const pathsToInclude = [...config.inputDirs, ...includes].map(resolvePath);
   const filesToProcess = pathsToInclude
     .map(dirPath => {
-      return readDirectoryRecursively(dirPath)
+      return readDirectoryRecursively(dirPath);
     })
-    .flat()
+    .flat();
 
-  return filesToProcess.map(filePath => {
-    const fileOutputPath = path.join(
-      meta.opts.baseDir,
-      config.outputDir,
-      removeBasePaths(pathsToInclude, filePath).replace(
-        config.inputFileExtension,
-        config.outputFileExtension,
-      ),
-    )
-    const fileOutputDir = path.dirname(fileOutputPath)
-    if (!fs.existsSync(fileOutputDir)) {
-      fs.mkdirSync(fileOutputDir, { recursive: true })
-    }
+  return await Promise.all(
+    filesToProcess.map(async filePath => {
+      const fileOutputPath = path.join(
+        meta.opts.baseDir,
+        config.outputDir,
+        removeBasePaths(pathsToInclude, filePath).replace(
+          config.inputFileExtension,
+          config.outputFileExtension
+        )
+      );
+      const fileOutputDir = path.dirname(fileOutputPath);
+      if (!fs.existsSync(fileOutputDir)) {
+        fs.mkdirSync(fileOutputDir, { recursive: true });
+      }
 
-    const { result, detail } = config.processor(filePath, meta)
-    fs.writeFileSync(fileOutputPath, result)
+      const { result, detail, written } = await config.processor(
+        filePath,
+        meta,
+        fileOutputDir
+      );
+      if (!written) {
+        await writeFile(fileOutputPath, result);
+      }
 
-    return {
-      detail,
-      path: fileOutputPath.replace(meta.opts.baseDir, ""),
-    }
-  })
+      return {
+        detail,
+        path: written ? result : fileOutputPath.replace(meta.opts.baseDir, "/")
+      };
+    })
+  );
 }

+ 127 - 19
src/processors.js

@@ -1,37 +1,145 @@
-import * as sass from "sass"
-import { firstFound } from "./util.js"
-import fs from "fs"
-import handlebars from "handlebars"
-import { marked } from "marked"
-import markedCodePreview from "marked-code-preview"
-import matter from "gray-matter"
+import * as sass from "sass";
+import { firstFound } from "./util.js";
+import fs from "fs";
+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";
 
-const markedRenderer = marked.use({ gfm: true }).use(markedCodePreview)
+const imageSizes = [
+  "640w",
+  "768w",
+  "1024w",
+  "1366w",
+  "1600w",
+  "1920w",
+  "2560w"
+];
+
+function stripFileExtension(filePath) {
+  if (typeof filePath !== "string") return "";
+
+  const parts = filePath.split("/");
+  const fileName = parts.pop();
+
+  const nameWithoutExt = fileName.replace(/\.[^/.]+$/, "");
+
+  return [...parts, nameWithoutExt].join("/");
+}
+
+const renderer = meta => ({
+  image({ href, title, text }) {
+    const hrefWithoutFileExtension = stripFileExtension(href);
+    const attrs = [`alt="${text}"`];
+
+    const foundSrcSet = meta.resources.images.find(imageResource => {
+      return (
+        stripFileExtension(imageResource.path[0]) === hrefWithoutFileExtension
+      );
+    });
+    if (foundSrcSet) {
+      const srcSetString = foundSrcSet.path[1]
+        .map(src => src.join(" "))
+        .join(", ");
+      const defaultSrc = foundSrcSet.path[1][0][0];
+      attrs.push(`src="${defaultSrc}"`);
+      attrs.push(`srcset="${srcSetString}"`);
+      attrs.push(
+        `sizes="(min-width: 1600px) 25vw, (min-width: 800px) 50vw, 100vw"`
+      );
+      attrs.push(`style="aspect-ratio: ${foundSrcSet.path[2].aspectRatio}"`);
+    } else {
+      attrs.push(`src="${href}"`);
+    }
+
+    if (title) {
+      attrs.push(`title="${title}"`);
+    }
+
+    return `<img ${attrs.join(" ")} >`;
+  }
+});
+
+const markedRenderer = marked.use({ gfm: true }).use(markedCodePreview);
 
 export function renderMarkdownWithTemplate(filePath, meta) {
-  const content = fs.readFileSync(filePath, "utf8")
-  const { data, content: markdown } = matter(content)
-  const templateName = data.template || meta.opts.defaultTemplate
+  const content = fs.readFileSync(filePath, "utf8");
+  const { data, content: markdown } = matter(content);
+  const templateName = data.template || meta.opts.defaultTemplate;
 
   const template = handlebars.compile(
     fs.readFileSync(
       firstFound(meta.opts.templateDirs, `${templateName}.hbs`),
-      "utf8",
-    ),
-  )
+      "utf8"
+    )
+  );
   const html = template({
     ...data,
     ...meta,
-    content: markedRenderer(markdown),
-  })
+    content: markedRenderer.use({ renderer: renderer(meta) })(markdown)
+  });
   return {
     detail: data,
-    result: html,
-  }
+    result: html
+  };
 }
 
 export function compileSass(filePath) {
   return {
-    result: sass.compile(filePath, { style: "compressed" }).css.toString(),
+    result: sass.compile(filePath, { style: "compressed" }).css.toString()
+  };
+}
+
+export function optimiseSvg(filePath) {
+  const svgString = fs.readFileSync(filePath, "utf8");
+  const result = optimize(svgString, {
+    plugins: ["preset-default"]
+  });
+  return { result: result.data };
+}
+
+function getCleanPath(path, meta) {
+  return path.replace(meta.opts.runDir, "").replace(meta.opts.baseDir, "/");
+}
+
+export async function optimiseImage(filePath, meta, fileOutputPath) {
+  const sourceExtension = path.extname(filePath);
+  const outputExtension = ".webp";
+  const base = path.basename(filePath).slice(0, -sourceExtension.length);
+  const metadata = await sharp(filePath).metadata();
+  const { width, height } = metadata;
+
+  if (!width || !height) {
+    throw new Error("Could not determine image dimensions");
   }
+
+  const aspectRatio = width / height;
+
+  const srcset = await Promise.all(
+    imageSizes.map(async size => {
+      const sizeNum = parseInt(size.replace("w", ""), 10);
+      const outputFile = path.join(
+        fileOutputPath,
+        `${base}-${sizeNum}${outputExtension}`
+      );
+
+      await sharp(filePath)
+        .resize(sizeNum)
+        .webp({ quality: 80 })
+        .toFile(outputFile);
+
+      return [getCleanPath(outputFile, meta), size];
+    })
+  );
+  const imageRef = getCleanPath(
+    path.join(fileOutputPath, `${base}${outputExtension}`),
+    meta
+  );
+  return {
+    result: [imageRef, srcset, { aspectRatio }],
+    written: true
+  };
 }

部分文件因文件數量過多而無法顯示