Craig Fletcher 5 mesi fa
parent
commit
1a4361527a
8 ha cambiato i file con 1033 aggiunte e 252 eliminazioni
  1. 0 5
      eslint.config.js
  2. 410 5
      package-lock.json
  3. 1 0
      package.json
  4. 31 25
      src/defaults.js
  5. 41 19
      src/index.js
  6. 97 40
      src/lib.js
  7. 127 121
      src/processors.js
  8. 326 37
      src/util.js

+ 0 - 5
eslint.config.js

@@ -37,11 +37,6 @@ export default [
       "prefer-const": "error",
       quotes: ["error", "double"],
       semi: ["error", "never"],
-      "sort-imports": ["error", { ignoreCase: true }],
-      "sort-keys": [
-        "error", "asc", { caseSensitive: true, natural: true },
-      ],
-      "sort-vars": ["error", { ignoreCase: true }],
     },
   },
 ]

+ 410 - 5
package-lock.json

@@ -14,6 +14,7 @@
         "gray-matter": "^4.0.3",
         "handlebars": "^4.7.8",
         "html-minifier-terser": "^7.2.0",
+        "json-stable-stringify": "^1.3.0",
         "marked": "^15.0.6",
         "marked-code-preview": "^1.3.7",
         "sass": "^1.83.1",
@@ -1385,6 +1386,53 @@
         "node": ">=8"
       }
     },
+    "node_modules/call-bind": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.0",
+        "es-define-property": "^1.0.0",
+        "get-intrinsic": "^1.2.4",
+        "set-function-length": "^1.2.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/call-me-maybe": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
@@ -1985,6 +2033,23 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/define-data-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/define-property": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz",
@@ -2259,6 +2324,20 @@
         "node": ">=4"
       }
     },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/duplexer3": {
       "version": "0.1.5",
       "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz",
@@ -2327,6 +2406,36 @@
         "errno": "cli.js"
       }
     },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/escape-html": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -3441,12 +3550,48 @@
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
       "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
-      "dev": true,
       "license": "MIT",
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/get-proxy": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz",
@@ -3600,6 +3745,18 @@
         "node": ">= 4"
       }
     },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/got": {
       "version": "9.6.0",
       "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
@@ -3684,6 +3841,18 @@
         "node": ">=8"
       }
     },
+    "node_modules/has-property-descriptors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/has-symbol-support-x": {
       "version": "1.4.2",
       "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz",
@@ -3693,6 +3862,18 @@
         "node": "*"
       }
     },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/has-to-string-tag-x": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz",
@@ -3777,7 +3958,6 @@
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
       "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "function-bind": "^1.1.2"
@@ -4165,12 +4345,46 @@
       "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
       "dev": true
     },
+    "node_modules/json-stable-stringify": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+      "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "isarray": "^2.0.5",
+        "jsonify": "^0.0.1",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
       "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
       "dev": true
     },
+    "node_modules/json-stable-stringify/node_modules/isarray": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+      "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+      "license": "MIT"
+    },
+    "node_modules/jsonify": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+      "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+      "license": "Public Domain",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/keyv": {
       "version": "4.5.4",
       "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4398,6 +4612,15 @@
         "marked": ">=7.0.0"
       }
     },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/mdn-data": {
       "version": "2.0.30",
       "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
@@ -4853,6 +5076,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/object-visit": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
@@ -5807,6 +6039,23 @@
         "node": ">=10"
       }
     },
+    "node_modules/set-function-length": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.4",
+        "gopd": "^1.0.1",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/set-value": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
@@ -7852,6 +8101,35 @@
         }
       }
     },
+    "call-bind": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.0",
+        "es-define-property": "^1.0.0",
+        "get-intrinsic": "^1.2.4",
+        "set-function-length": "^1.2.2"
+      }
+    },
+    "call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "requires": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      }
+    },
+    "call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      }
+    },
     "call-me-maybe": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
@@ -8291,6 +8569,16 @@
       "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==",
       "dev": true
     },
+    "define-data-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "requires": {
+        "es-define-property": "^1.0.0",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.0.1"
+      }
+    },
     "define-property": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz",
@@ -8492,6 +8780,16 @@
         }
       }
     },
+    "dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      }
+    },
     "duplexer3": {
       "version": "0.1.5",
       "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz",
@@ -8542,6 +8840,24 @@
         "prr": "~1.0.1"
       }
     },
+    "es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
+    },
+    "es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
+    },
+    "es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "requires": {
+        "es-errors": "^1.3.0"
+      }
+    },
     "escape-html": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -9241,8 +9557,33 @@
     "function-bind": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
-      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
-      "dev": true
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
+    },
+    "get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "requires": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      }
+    },
+    "get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "requires": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      }
     },
     "get-proxy": {
       "version": "2.1.0",
@@ -9347,6 +9688,11 @@
         }
       }
     },
+    "gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
+    },
     "got": {
       "version": "9.6.0",
       "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
@@ -9408,12 +9754,25 @@
       "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
       "dev": true
     },
+    "has-property-descriptors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "requires": {
+        "es-define-property": "^1.0.0"
+      }
+    },
     "has-symbol-support-x": {
       "version": "1.4.2",
       "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz",
       "integrity": "sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==",
       "dev": true
     },
+    "has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
+    },
     "has-to-string-tag-x": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz",
@@ -9479,7 +9838,6 @@
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
       "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
-      "dev": true,
       "requires": {
         "function-bind": "^1.1.2"
       }
@@ -9741,12 +10099,36 @@
       "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
       "dev": true
     },
+    "json-stable-stringify": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+      "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+      "requires": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "isarray": "^2.0.5",
+        "jsonify": "^0.0.1",
+        "object-keys": "^1.1.1"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "2.0.5",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+          "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
+        }
+      }
+    },
     "json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
       "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
       "dev": true
     },
+    "jsonify": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+      "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="
+    },
     "keyv": {
       "version": "4.5.4",
       "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9911,6 +10293,11 @@
         "attributes-parser": "^2.2.3"
       }
     },
+    "math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
+    },
     "mdn-data": {
       "version": "2.0.30",
       "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
@@ -10252,6 +10639,11 @@
         }
       }
     },
+    "object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
+    },
     "object-visit": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
@@ -10904,6 +11296,19 @@
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
       "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="
     },
+    "set-function-length": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "requires": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.4",
+        "gopd": "^1.0.1",
+        "has-property-descriptors": "^1.0.2"
+      }
+    },
     "set-value": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",

+ 1 - 0
package.json

@@ -26,6 +26,7 @@
     "gray-matter": "^4.0.3",
     "handlebars": "^4.7.8",
     "html-minifier-terser": "^7.2.0",
+    "json-stable-stringify": "^1.3.0",
     "marked": "^15.0.6",
     "marked-code-preview": "^1.3.7",
     "sass": "^1.83.1",

+ 31 - 25
src/defaults.js

@@ -1,20 +1,20 @@
 import {
   compileSass,
+  copy,
+  generateFavicons,
+  imageToWebP,
   optimiseSvg,
-  optimiseImage,
   renderMarkdownWithTemplate,
-  copy,
-  generateFavicons
-} from "./processors.js";
+} from "./processors.js"
 
 export const tasks = [
   {
     name: "styles",
-    inputFiles: [{ pattern: "styles/**/*.scss" }],
+    inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
     stripPaths: ["styles/"],
     outputDir: "static/styles/",
     outputFileExtension: ".css",
-    processor: compileSass
+    processor: compileSass,
   },
   {
     name: "icons",
@@ -22,7 +22,7 @@ export const tasks = [
     stripPaths: ["images/"],
     outputDir: "static/",
     outputFileExtension: ".svg",
-    processor: optimiseSvg
+    processor: optimiseSvg,
   },
   {
     name: "images",
@@ -30,53 +30,59 @@ export const tasks = [
     stripPaths: ["images/content/"],
     outputDir: "images/",
     outputFileExtension: ".webp",
-    processor: optimiseImage
+    imageSizes: [
+      "640w", "768w", "1024w", "1366w", "1600w", "1920w", "2560w",
+    ],
+    quality: 80,
+    processor: imageToWebP,
   },
   {
     name: "favicons",
     inputFiles: [{ pattern: "images/favicon/*" }],
     stripPaths: ["images/favicon/"],
     outputDir: "static/meta/",
-    processor: generateFavicons
+    processor: generateFavicons,
   },
   {
     name: "pages",
     inputFiles: [{ pattern: "markdown/*.md" }],
     stripPaths: ["markdown/"],
     outputFileExtension: ".html",
-    processor: renderMarkdownWithTemplate
+    processor: renderMarkdownWithTemplate,
   },
   {
     name: "static files",
     inputFiles: [{ pattern: "static/*" }],
     stripPaths: ["static/"],
-    processor: copy
-  }
-];
+    processor: copy,
+  },
+]
 
 export const opts = {
-  baseDir: "dist/",
+  outDir: "dist/",
   runDir: process.cwd(),
+  cacheDir: ".cache",
   defaultTemplate: "default",
   include: {
-    styles: [{ pattern: "~/.rhedyn/styles/*.scss" }]
+    styles: [{ pattern: "~/.rhedyn/styles/*.scss" }],
   },
   templateDirs: ["templates/", "~/.rhedyn/templates/"],
+  clean: true,
   site: {
-    name: "My test website",
-    shortName: "test site",
-    description: "a test website for generating a blog",
+    name: "Website generated by Rhedyn",
+    shortName: "Rhedyn test site",
+    description: "A website generated from files using Rhedyn",
     author: "Craig Fletcher",
     url: "https://www.leakypixel.net",
     language: "en-GB",
-    backgroundColor: "#0f0",
-    themeColor: "#f00"
-  }
-};
+    backgroundColor: "#22242c",
+    themeColor: "#f00",
+  },
+}
 
 const defaults = {
   opts,
-  tasks
-};
+  tasks,
+}
 
-export default defaults;
+export default defaults

+ 41 - 19
src/index.js

@@ -2,31 +2,53 @@
 
 import * as defaultConfig from "./defaults.js"
 import { getConfig, processFiles } from "./lib.js"
+import { performance } from "node:perf_hooks"
 
+const startTime = performance.now()
 const { opts, tasks } = await getConfig() || { ...defaultConfig }
+console.info(`[Start] Processing ${tasks.length} tasks`)
+console.info(`[Info] Running directory: ${opts.runDir}`)
+console.info(`[Info] Output directory: ${opts.outDir}`)
+if (opts.cacheDir) {
+  console.info(`[Info] Cache directory: ${opts.cacheDir}`)
+} else {
+  console.warn("[Warn] Cache disabled")
+}
+async function runTask(meta, task) {
+  const allResults = await processFiles(task, meta)
+  const cachedResults = allResults.filter(taskResult => taskResult.fromCache)
+  const processedResults = allResults.filter(taskResult => !taskResult.fromCache)
+  const taskResult = {
+    ...meta,
+    resources: {
+      ...meta.resources,
+      [task.name]: allResults.reduce((obj, path) => ({ ...obj, [path.ref]: path }), {}),
+    },
+  }
+  return { taskResult, cachedResults, processedResults }
+}
 
-console.log(`Processing ${tasks.length} tasks`)
 const taskRunner = tasks.reduce(
   async (metaPromise, task) => {
-    const meta = await metaPromise;
-    console.log("Processing task:", task.name)
+    const meta = await metaPromise
+    const startTime = performance.now()
+    console.group(`[Task] ${task.name}`)
+    console.log(`[Start] patterns: ${JSON.stringify(task.inputFiles)}`)
     if (meta.opts.debug) {
-      console.log(task.name, "in meta", JSON.stringify(meta, null, 2));
-    }
-    const processedPaths = await processFiles(task, meta)
-    console.log(
-      `Processed ${processedPaths.length} paths for task ${task.name}`,
-    )
-
-    return {
-      ...meta,
-      resources: {
-        ...meta.resources,
-        [task.name]: processedPaths,
-      },
+      console.log(task.name, "in meta", JSON.stringify(meta, null, 2))
     }
+    const { taskResult, cachedResults, processedResults } = await runTask(meta, task)
+    const endTime = performance.now()
+    const timeTaken = (endTime - startTime)
+    const hrTime = timeTaken > 1000 ? `${Number.parseFloat(timeTaken / 1000).toFixed(2)}s` : `${Number.parseFloat(timeTaken).toFixed(2)}ms`
+    console.log(`[Done] processed: ${processedResults.length} | from cache: ${cachedResults.length} | ${hrTime}`)
+    console.groupEnd()
+    return taskResult
   },
-   Promise.resolve({ opts }),
+  Promise.resolve({ opts }),
 )
-await taskRunner;
-console.log("Done!")
+await taskRunner
+const endTime = performance.now()
+const timeTaken = (endTime - startTime)
+const hrTime = timeTaken > 1000 ? `${Number.parseFloat(timeTaken / 1000).toFixed(2)}s` : `${Number.parseFloat(timeTaken).toFixed(2)}ms`
+console.log(`[Done] ${tasks.length} tasks in ${hrTime}`)

+ 97 - 40
src/lib.js

@@ -1,77 +1,134 @@
 import {
+  checkCache,
+  createTrackedObject,
+  fileExists,
   readFilesByGlob,
   removeBasePaths,
-  resolvePath,
-  replaceFileExtension
-} from "./util.js";
-import fs from "fs";
-import { writeFile } from "node:fs/promises";
-import path from "path";
-import process from "node:process";
+  removeCwd,
+  replaceFileExtension,
+  slugifyString,
+  updateCache,
+} from "./util.js"
+import fs 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");
-  if (fs.existsSync(configPath)) {
+  const configPath = path.join(process.cwd(), "rhedyn.config.js")
+  const configFileExists = await fileExists(configPath)
+  if (configFileExists) {
     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 async function processFiles(config, meta) {
-  const includes = meta.opts?.include?.[config.name] || [];
-  const patternsToInclude = [...config.inputFiles, ...includes];
-  const filesToProcess = await readFilesByGlob(patternsToInclude);
-  const pathsToStrip = config.stripPaths || [];
-  const outputDir = config.outputDir || "";
+  const includes = meta.opts?.include?.[config.name] || []
+  const patternsToInclude = [...config.inputFiles, ...includes]
+  const filesToProcess = await readFilesByGlob(patternsToInclude)
+  const pathsToStrip = config.stripPaths || []
+  const outputDir = config.outputDir || ""
+  const configPathDeps = config.deps?.paths || []
+  const configStateDeps = config.deps?.state || []
 
   return await Promise.all(
     filesToProcess.map(async filePath => {
       const fileOutputPath = path.join(
-        meta.opts.baseDir,
+        meta.opts.outDir,
         outputDir,
         replaceFileExtension(
           removeBasePaths(pathsToStrip, filePath),
-          config.outputFileExtension
-        )
-      );
+          config.outputFileExtension,
+        ),
+      )
 
       if (meta.opts.debug) {
-        console.log("in filePath", filePath);
-        console.log("out fileOutputPath", fileOutputPath);
+        console.log("in filePath", filePath)
+        console.log("out fileOutputPath", fileOutputPath)
       }
 
-      const fileOutputDir = path.dirname(fileOutputPath);
-      if (!fs.existsSync(fileOutputDir)) {
-        fs.mkdirSync(fileOutputDir, { recursive: true });
+      const fileOutputDir = path.dirname(fileOutputPath)
+      const exists = await fileExists(fileOutputDir)
+      if (!exists) {
+        await fs.mkdir(fileOutputDir, { recursive: true })
       }
 
-      const { result, detail, written } = await config.processor(
+      const stateObject = {
         filePath,
         meta,
         fileOutputDir,
-        fileOutputPath
-      );
+        fileOutputPath,
+        config,
+      }
+
+      let cache = {}
+      if (meta.opts.cacheDir) {
+        cache = await checkCache(
+          slugifyString(filePath),
+          stateObject,
+          meta.opts,
+        )
+
+        if (cache && cache.hit) {
+          console.log(`[Info] Loaded cache for ${filePath}`)
+          return { ...cache.taskResult, fromCache: true }
+        }
+        console.log(`[Info] Cache miss for ${filePath} (${cache.reason})`)
+      }
+
+      const state = meta.opts.cacheDir
+        ? createTrackedObject(stateObject)
+        : { proxy: stateObject }
+
+      const {
+        result,
+        detail,
+        written,
+        deps: processorDeps,
+        ref,
+      } = await config.processor(state.proxy)
 
       if (!written) {
-        await writeFile(fileOutputPath, result);
+        await fs.writeFile(fileOutputPath, result, {
+          encoding: "utf8",
+        })
       }
 
       if (meta.opts.debug) {
-        console.log(filePath, "out detail", detail);
-        console.log(filePath, "out result", result);
+        console.log(filePath, "out detail", detail)
+        console.log(filePath, "out result", result)
       }
-
-      return {
+      const taskRef = ref ? slugifyString(ref) : slugifyString(fileOutputPath)
+      const taskResult = {
         detail,
-        path: written ? result : fileOutputPath.replace(meta.opts.baseDir, "/")
-      };
-    })
-  );
+        path: written ? result : [fileOutputPath.replace(meta.opts.outDir, "")],
+        ref: taskRef,
+      }
+      if (meta.opts.cacheDir) {
+        const processorPathDeps = processorDeps?.paths || []
+        const processorStateDeps = processorDeps?.state || []
+        await updateCache(
+          meta.opts.cacheDir,
+          slugifyString(filePath),
+          removeCwd([
+            ...configPathDeps, ...processorPathDeps, filePath,
+          ]),
+          [
+            ...configStateDeps, ...processorStateDeps, ...state.accessed,
+          ],
+          taskResult,
+          cache.updates,
+        )
+      }
+
+      return taskResult
+    }),
+  )
 }

+ 127 - 121
src/processors.js

@@ -1,31 +1,24 @@
-import * as sass from "sass";
+import * as sass from "sass"
 import {
   firstFound,
-  stripFileExtension,
+  generateRandomId,
   getCleanPath,
-  getHref
-} from "./util.js";
-import fs from "fs/promises";
-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";
-import { minify } from "html-minifier-terser";
-import favicons from "favicons";
-
-const imageSizes = [
-  "640w",
-  "768w",
-  "1024w",
-  "1366w",
-  "1600w",
-  "1920w",
-  "2560w"
-];
-const templateCache = new Map();
+  getHref,
+  slugifyString,
+  stripFileExtension,
+} from "./util.js"
+import fs from "fs/promises"
+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"
+import { minify } from "html-minifier-terser"
+import favicons from "favicons"
+
+const templateCache = new Map()
 
 function createMarkdownRenderer(meta) {
   return marked
@@ -34,142 +27,152 @@ function createMarkdownRenderer(meta) {
     .use({
       renderer: {
         image({ href, title, text }) {
-          const hrefWithoutExt = stripFileExtension(href);
-          const attrs = [`alt="${text}"`];
+          const attrs = [`alt="${text}"`]
 
-          const foundSrcSet = meta.resources.images.find(({ detail }) => {
-            return detail.imageRef === href;
-          });
+          const foundSrcSet = meta.resources.images[slugifyString(href)]
 
           if (foundSrcSet) {
             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(`sizes="(min-width: 800px) 40vw, 100vw"`);
+              .join(", ")
+            const defaultSrc = foundSrcSet.detail.srcSet[0][0]
+            attrs.push(`src="${defaultSrc}"`)
+            attrs.push(`srcset="${srcSetString}"`)
+            attrs.push("sizes=\"(min-width: 800px) 40vw, 100vw\"")
             attrs.push(
-              `style="aspect-ratio: ${foundSrcSet.detail.aspectRatio}"`
-            );
+              `style="aspect-ratio: ${foundSrcSet.detail.aspectRatio}"`,
+            )
           } else {
-            attrs.push(`src="${href}"`);
+            attrs.push(`src="${href}"`)
           }
 
           if (title) {
-            attrs.push(`title="${title}"`);
+            attrs.push(`title="${title}"`)
           }
 
-          return `<img ${attrs.join(" ")} >`;
-        }
-      }
-    });
+          return `<img ${attrs.join(" ")} >`
+        },
+      },
+    })
 }
 
-export async function renderMarkdownWithTemplate(
+export async function renderMarkdownWithTemplate({
   filePath,
   meta,
-  fileOutputDir,
-  fileOutputPath
-) {
-  const content = await fs.readFile(filePath, "utf8");
-  const { data, content: markdown } = matter(content);
-  const templateName = data.template || meta.opts.defaultTemplate;
-  const href = getHref(fileOutputPath, meta);
+  fileOutputPath,
+}) {
+  const content = await fs.readFile(filePath, "utf8")
+  const { data, content: markdown } = matter(content)
+  const templateName = data.template || meta.opts.defaultTemplate
+  const href = getHref(fileOutputPath, meta)
 
   if (!templateCache.has(templateName)) {
-    const templatePath = firstFound(
+    const templatePath = await firstFound(
       meta.opts.templateDirs,
-      `${templateName}.hbs`
-    );
-    if (!templatePath) throw new Error(`Template not found: ${templateName}`);
-    const templateContent = await fs.readFile(templatePath, "utf8");
-    templateCache.set(templateName, handlebars.compile(templateContent));
+      `${templateName}.hbs`,
+    )
+    if (!templatePath) throw new Error(`Template not found: ${templateName}`)
+    const templateContent = await fs.readFile(templatePath, "utf8")
+    templateCache.set(templateName, {
+      path: templatePath,
+      renderer: handlebars.compile(templateContent),
+    })
   }
-
-  const template = templateCache.get(templateName);
-  const renderer = createMarkdownRenderer(meta);
-  const html = template({
+  const template = templateCache.get(templateName)
+  const renderer = createMarkdownRenderer(meta)
+  const html = template.renderer({
     ...data,
     ...meta,
     href,
-    content: renderer(markdown)
-  });
+    content: renderer(markdown),
+  })
   const minifiedHtml = await minify(html, {
     collapseWhitespace: true,
     removeComments: true,
     removeRedundantAttributes: true,
     removeEmptyAttributes: true,
     minifyCSS: true,
-    minifyJS: true
-  });
+    minifyJS: true,
+  })
+
   return {
     detail: { ...data, href },
-    result: minifiedHtml
-  };
+    result: minifiedHtml,
+    deps: {
+      paths: [template.path],
+    },
+  }
 }
 
-export async function compileSass(filePath) {
-  const result = await sass.compileAsync(filePath, { style: "compressed" });
-  return { result: result.css.toString() };
+export async function compileSass({ filePath }) {
+  const result = await sass.compileAsync(filePath, { style: "compressed" })
+  return {
+    result: result.css,
+    deps: {
+      paths: [...result.loadedUrls.map(item => item.pathname)],
+    },
+  }
 }
 
-export async function optimiseSvg(filePath) {
-  const svgString = await fs.readFile(filePath, "utf8");
+export async function optimiseSvg({ filePath }) {
+  const svgString = await fs.readFile(filePath, "utf8")
   const result = optimize(svgString, {
-    plugins: ["preset-default"]
-  });
-  return { result: result.data };
+    plugins: ["preset-default"],
+  })
+  return {
+    result: result.data,
+  }
 }
 
-export async function copy(filePath) {
-  const fileContent = await fs.readFile(filePath, "utf8");
-  return { result: fileContent };
+export async function copy({ filePath }) {
+  const fileContent = await fs.readFile(filePath, "utf8")
+  return { result: fileContent }
 }
 
-export async function optimiseImage(filePath, meta, fileOutputDir) {
-  const sourceExtension = path.extname(filePath);
-  const outputExtension = ".webp";
-  const base = path.basename(filePath, sourceExtension);
+export async function imageToWebP({ filePath, meta, fileOutputDir, config }) {
+  const sourceExtension = path.extname(filePath)
+  const outputExtension = config.outputFileExtension
+  const base = path.basename(filePath, sourceExtension)
 
-  const original = sharp(filePath);
-  const metadata = await original.metadata();
-  const { width, height } = metadata;
+  const original = sharp(filePath)
+  const metadata = await original.metadata()
+  const { width, height } = metadata
 
   if (!width || !height) {
-    throw new Error("Could not determine image dimensions");
+    throw new Error("Could not determine image dimensions")
   }
 
-  const aspectRatio = width / height;
-
+  const aspectRatio = width / height
+  const name = config.uniqueFilenames ? base : `${base}-${generateRandomId()}`
   const srcSet = await Promise.all(
-    imageSizes.map(async size => {
-      const sizeNum = parseInt(size.replace("w", ""), 10);
+    config.imageSizes.map(async size => {
+      const sizeNum = parseInt(size.replace("w", ""), 10)
       const outputFile = path.join(
         fileOutputDir,
-        `${base}-${sizeNum}${outputExtension}`
-      );
+        `${name}-${sizeNum}${outputExtension}`,
+      )
 
       await original
         .clone()
         .resize(sizeNum)
-        .webp({ quality: 80 })
-        .toFile(outputFile);
+        .webp({ quality: config.quality })
+        .toFile(outputFile)
 
-      return [getCleanPath(outputFile, meta), size];
-    })
-  );
+      return [getCleanPath(outputFile, meta), size]
+    }),
+  )
 
-  const imageRef = getCleanPath(path.join(filePath), meta);
+  const imageRef = getCleanPath(path.join(filePath), meta)
 
   return {
     result: srcSet.map(src => src[0]),
     detail: { imageRef, srcSet, aspectRatio },
-    written: true
-  };
+    written: true,
+    ref: imageRef,
+  }
 }
 
-export async function generateFavicons(filePath, meta, fileOutputDir) {
+export async function generateFavicons({ filePath, meta, fileOutputDir }) {
   // Configuration for favicons package
   const configuration = {
     path: getCleanPath(fileOutputDir, meta), // Path for overriding default icons path
@@ -198,43 +201,46 @@ export async function generateFavicons(filePath, meta, fileOutputDir) {
       appleStartup: true,
       favicons: true,
       windows: true,
-      yandex: true
-    }
-  };
+      yandex: true,
+    },
+  }
   try {
-    const response = await favicons(filePath, configuration);
+    const response = await favicons(filePath, configuration)
 
     // Write all generated images to disk
     await Promise.all(
       response.images.map(async image => {
-        const outputPath = path.join(fileOutputDir, image.name);
-        await fs.writeFile(outputPath, image.contents);
-      })
-    );
+        const outputPath = path.join(fileOutputDir, image.name)
+        await fs.writeFile(outputPath, image.contents)
+      }),
+    )
 
     // Write all generated files (manifests, etc.) to disk
     await Promise.all(
       response.files.map(async file => {
-        const outputPath = path.join(fileOutputDir, file.name);
-        await fs.writeFile(outputPath, file.contents);
-      })
-    );
+        const outputPath = path.join(fileOutputDir, file.name)
+        await fs.writeFile(outputPath, file.contents)
+      }),
+    )
 
     // Combine HTML meta tags
-    const htmlMeta = response.html.join("\n    ");
+    const htmlMeta = response.html.join("\n    ")
     return {
-      detail: { htmlMeta },
+      detail: {
+        htmlMeta,
+      },
       result: [
         ...response.images.map(img =>
-          getCleanPath(path.join(fileOutputDir, img.name), meta)
+          getCleanPath(path.join(fileOutputDir, img.name), meta),
         ),
         ...response.files.map(file =>
-          getCleanPath(path.join(fileOutputDir, file.name), meta)
-        )
+          getCleanPath(path.join(fileOutputDir, file.name), meta),
+        ),
       ],
-      written: true
-    };
+      written: true,
+      ref: "metatags",
+    }
   } catch (error) {
-    throw new Error(`Failed to generate favicons: ${error.message}`);
+    throw new Error(`Failed to generate favicons: ${error.message}`)
   }
 }

+ 326 - 37
src/util.js

@@ -1,22 +1,37 @@
-import fs from "fs";
-import os from "node:os";
-import path from "path";
-import { glob } from "glob";
+import fs from "node:fs/promises"
+import os from "node:os"
+import path from "path"
+import { glob } from "glob"
+import { createHash } from "crypto"
+import { createReadStream } from "fs"
+import stableStringify from "json-stable-stringify"
 
-export function readDirectoryRecursively(dir, files = []) {
-  if (!fs.existsSync(dir)) {
-    return files;
+export async function fileExists(filePath) {
+  try {
+    await fs.stat(filePath)
+    return true
+  } catch (err) {
+    if (err.code === "ENOENT") {
+      return false
+    }
+    throw err // re-throw other errors
+  }
+}
+export async function readDirectoryRecursively(dir, files = []) {
+  const exists = await fileExists(dir)
+  if (!exists) {
+    return files
   }
-  const contents = fs.readdirSync(dir, { withFileTypes: true });
+  const contents = await fs.readdir(dir, { withFileTypes: true })
   for (const item of contents) {
-    const itemPath = path.join(dir, item.name);
+    const itemPath = path.join(dir, item.name)
     if (item.isDirectory()) {
-      readDirectoryRecursively(itemPath, files);
+      readDirectoryRecursively(itemPath, files)
     } else {
-      files.push(itemPath);
+      files.push(itemPath)
     }
   }
-  return files;
+  return files
 }
 // type InputConfig
 // {
@@ -29,62 +44,336 @@ export async function readFilesByGlob(globConfigs) {
       const { pattern, ignore, dot } = {
         dot: false,
         ignore: [],
-        ...globConfig
-      };
+        ...globConfig,
+      }
       const matches = await glob(pattern, {
         ignore,
-        dot
-      });
-      return [...(await existingMatches), ...matches];
+        dot,
+      })
+      return [...(await existingMatches), ...matches]
     },
-    []
-  );
-  const files = await matchPromises;
-  return [...new Set(files)];
+    [],
+  )
+  const files = await matchPromises
+  return [...new Set(files)]
 }
 
 export function resolvePath(unresolvedPath) {
-  return path.resolve(unresolvedPath.replace(/^~/, os.homedir()));
+  return path.resolve(unresolvedPath.replace(/^~/, os.homedir()))
 }
 
-export function firstFound(dirs, fileName) {
+export async function firstFound(dirs, fileName) {
   for (const dir of dirs) {
-    const filePath = resolvePath(path.join(dir, fileName));
-    if (fs.existsSync(filePath)) {
-      return filePath;
+    const filePath = resolvePath(path.join(dir, fileName))
+    const exists = await fileExists(filePath)
+    if (exists) {
+      return filePath
     }
   }
-  return null;
+  return null
+}
+
+export function removeCwd(paths) {
+  const cwd = `${process.cwd()}/`
+  return paths.map(path => path.replace(cwd, ""))
 }
 
 export function removeBasePaths(baseDirs, fullPath) {
   return baseDirs.reduce((cleanedPath, dir) => {
-    return cleanedPath.replace(dir, "");
-  }, fullPath);
+    return cleanedPath.replace(dir, "")
+  }, fullPath)
 }
 
 export function replaceFileExtension(filePath, newExtension) {
   if (!newExtension) {
-    return filePath;
+    return filePath
   }
-  return `${stripFileExtension(filePath)}${newExtension}`;
+  return `${stripFileExtension(filePath)}${newExtension}`
 }
 
 export function stripFileExtension(filePath) {
   return path.join(
     path.dirname(filePath),
-    path.basename(filePath, path.extname(filePath))
-  );
+    path.basename(filePath, path.extname(filePath)),
+  )
 }
 
 export function getCleanPath(filePath, meta) {
-  return filePath.replace(meta.opts.runDir, "").replace(meta.opts.baseDir, "/");
+  return filePath.replace(meta.opts.runDir, "").replace(meta.opts.outDir, "/")
 }
 
 export function getHref(filePath, meta) {
-  const route = getCleanPath(filePath, meta);
+  const route = getCleanPath(filePath, meta)
   if (route.includes("index.html")) {
-    return route.replace("index.html", "");
+    return route.replace("index.html", "")
+  }
+  return route.replace(".html", "")
+}
+
+function stringifyPathPart(part) {
+  return typeof part === "symbol" ? part.toString() : String(part)
+}
+
+function trackPropertyAccessDeep(obj, path = [], accessed = new Set()) {
+  return new Proxy(obj, {
+    get(target, prop, receiver) {
+      const fullPath = [...path, prop].map(stringifyPathPart).join(".")
+      const value = Reflect.get(target, prop, receiver)
+
+      if (typeof target === "object" && target.hasOwnProperty(prop)) {
+        accessed.add({ path: fullPath, value })
+      }
+
+      // Recursively wrap if value is an object and not null
+      if (value && typeof value === "object") {
+        return trackPropertyAccessDeep(value, [...path, prop], accessed)
+      }
+
+      return value
+    },
+  })
+}
+
+export function createTrackedObject(obj) {
+  const accessed = new Set()
+  const proxy = trackPropertyAccessDeep(obj, [], accessed)
+  return { proxy, accessed }
+}
+
+export function getDeepestPropertiesForKey(paths, key) {
+  // Sort paths to make prefix comparison easier
+  const sorted = paths.slice().sort((a, b) => {
+    if (a[key] < b[key]) {
+      return -1
+    }
+    if (a[key] > b[key]) {
+      return 1
+    }
+    return 0
+  })
+  const result = []
+
+  for (let i = 0; i < sorted.length; i++) {
+    const current = sorted[i]
+    const next = sorted[i + 1]
+    // If the next path doesn't start with the current + a dot, it's a leaf node
+    const nextKey = next?.[key]
+    const currentKey = current[key]
+    if (nextKey !== currentKey) {
+      if (!next || !next[key].startsWith(current[key] + ".")) {
+        result.push(current)
+      }
+    }
+  }
+
+  return result
+}
+
+export function slugifyString(str) {
+  return str
+    .toLowerCase()
+    .trim()
+    .replace(/[/\\?%*:|"<>]/g, "-") // Replace invalid filename characters
+    .replace(/\s+/g, "-") // Replace whitespace with dashes
+    .replace(/-+/g, "-") // Collapse multiple dashes
+    .replace(/\./g, "-") // Replace dots with dashes
+    .replace(/^-+|-+$/g, "") // Trim leading/trailing dashes
+}
+
+export function getValueAtPath(obj, path) {
+  const parts = path.split(".")
+  let val = obj
+  for (const part of parts) {
+    val = val?.[part]
+    if (val === undefined) break
+  }
+  return val
+}
+
+export function hashObject(obj) {
+  const str = stableStringify(obj)
+  return createHash("md5")
+    .update(str)
+    .digest("hex")
+}
+
+export async function getFileHash(filePath, algorithm = "md5") {
+  return new Promise((resolve, reject) => {
+    const hash = createHash(algorithm)
+    const stream = createReadStream(filePath)
+
+    stream.on("error", reject)
+    stream.on("data", chunk => hash.update(chunk))
+    stream.on("end", () => resolve(hash.digest("hex")))
+  })
+}
+
+export async function checkPathExists(files, baseDir) {
+  if (Array.isArray(files)) {
+    return (await Promise.all(
+      files.map(file => fileExists(path.join(baseDir, file))),
+    )).every(item => !!item)
+  }
+  return fileExists(path.join(baseDir, files))
+}
+
+export function generateRandomId(length = 8) {
+  const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
+  let result = ""
+  for (let i = 0; i < length; i++) {
+    result += chars.charAt(Math.floor(Math.random() * chars.length))
+  }
+  return result
+}
+
+async function getFileHashes(pathDeps) {
+  return Promise.all(
+    Object.keys(pathDeps).map(async filePath => {
+      const hash = await getFileHash(filePath)
+      if (hash !== pathDeps[filePath]) {
+        return Promise.reject({ filePath, hash })
+      }
+
+      return Promise.resolve(pathDeps[filePath])
+    }),
+  )
+}
+
+function getStatePropsHash(state, props) {
+  const stateValues = props.reduce((depmap, dep) => {
+    const value = getValueAtPath(state, dep)
+    return { ...depmap, [dep]: value }
+  }, {})
+  return hashObject(stateValues)
+}
+
+export async function checkCache(name, currentState, opts) {
+  const existingCacheObject = await readCache(opts.cacheDir, name)
+  if (existingCacheObject) {
+    const outFiles = existingCacheObject.taskResult.path
+    const outFilesExist = await checkPathExists(outFiles, opts.outDir)
+    if (outFilesExist) {
+      const stateHash = getStatePropsHash(
+        currentState,
+        existingCacheObject.deps.state.props,
+      )
+      if (stateHash === existingCacheObject.deps.state.hash) {
+        try {
+          await getFileHashes(existingCacheObject.deps.paths)
+          return { hit: true, taskResult: existingCacheObject.taskResult }
+        } catch (e) {
+          const updates = {
+            deps: {
+              paths: [e],
+            },
+          }
+          return { hit: false, reason: "File hash mismatch", updates }
+        }
+      }
+      const updates = {
+        deps: {
+          state: {
+            ...existingCacheObject.deps.state,
+            hash: stateHash,
+          },
+        },
+      }
+      return { hit: false, reason: "State hash mismatch", updates }
+    }
+    if (opts.clean) {
+      await Promise.all(
+        outFiles.map(
+          async outFile =>
+            await fs.rm(path.join(opts.outDir, outFile), { force: true }),
+        ),
+      )
+    }
+    return { hit: false, reason: "Missing output file(s)" }
+  }
+  return { hit: false, reason: "Missing cache file" }
+}
+
+export async function updateCache(
+  cacheDir,
+  name,
+  pathDeps,
+  stateDeps,
+  taskResult,
+  updates,
+) {
+  const cacheDirExists = await fileExists(cacheDir)
+  if (!cacheDirExists) {
+    await fs.mkdir(cacheDir, { recursive: true })
+  }
+  const accessedState = getDeepestPropertiesForKey([...stateDeps], "path")
+  const deps = {
+    paths: [...new Set(removeCwd(pathDeps))],
+    state: accessedState.reduce(
+      (as, { path, value }) => ({ ...as, [path]: value }),
+      {},
+    ),
+  }
+  const statePropsList = Object.keys(deps.state)
+  const updatesStateHash = updates?.deps?.state?.props || []
+  const stateDepsHash =
+    JSON.stringify(statePropsList) === JSON.stringify(updatesStateHash)
+      ? updates?.deps?.state?.hash
+      : hashObject(deps.state)
+
+  const updatesPathsCache =
+    updates?.deps?.paths?.reduce(
+      (pc, { filePath, hash }) => ({
+        ...pc,
+        [filePath]: hash,
+      }),
+      {},
+    ) || {}
+  const pathsCache = (await Promise.all(
+    deps.paths.map(async filePath => {
+      const hash = updatesPathsCache[filePath]
+        ? updatesPathsCache[filePath]
+        : await getFileHash(filePath)
+      return {
+        hash,
+        filePath,
+      }
+    }),
+  )).reduce((pc, { filePath, hash }) => ({ ...pc, [filePath]: hash }), {})
+  const cacheObject = {
+    deps: {
+      state: {
+        hash: stateDepsHash,
+        props: Object.keys(deps.state),
+      },
+      paths: pathsCache,
+    },
+    taskResult,
+  }
+  return await writeCache(cacheDir, name, cacheObject)
+}
+
+async function writeCache(cacheDir, name, cache) {
+  if (!cacheDir) {
+    return false
+  }
+  return fs.writeFile(
+    path.join(cacheDir, `${name}.cache`),
+    JSON.stringify(cache),
+    "utf8",
+  )
+}
+
+async function readCache(cacheDir, name) {
+  if (!cacheDir) {
+    return false
+  }
+  try {
+    const content = await fs.readFile(
+      path.join(cacheDir, `${name}.cache`),
+      "utf8",
+    )
+    return JSON.parse(content)
+  } catch {
+    return false
   }
-  return route.replace(".html", "");
 }