Browse Source

Add cache support

Craig Fletcher 5 months ago
parent
commit
8988170eaf
7 changed files with 873 additions and 84 deletions
  1. 410 5
      package-lock.json
  2. 1 0
      package.json
  3. 12 8
      src/defaults.js
  4. 39 16
      src/index.js
  5. 72 13
      src/lib.js
  6. 43 35
      src/processors.js
  7. 296 7
      src/util.js

+ 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",

+ 12 - 8
src/defaults.js

@@ -1,7 +1,7 @@
 import {
   compileSass,
   optimiseSvg,
-  optimiseImage,
+  imageToWebP,
   renderMarkdownWithTemplate,
   copy,
   generateFavicons
@@ -10,7 +10,7 @@ import {
 export const tasks = [
   {
     name: "styles",
-    inputFiles: [{ pattern: "styles/**/*.scss" }],
+    inputFiles: [{ pattern: "styles/**/*.scss", ignore: "**/_*.scss" }],
     stripPaths: ["styles/"],
     outputDir: "static/styles/",
     outputFileExtension: ".css",
@@ -30,7 +30,9 @@ 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",
@@ -55,21 +57,23 @@ export const tasks = [
 ];
 
 export const opts = {
-  baseDir: "dist/",
+  outDir: "dist/",
   runDir: process.cwd(),
+  cacheDir: ".cache",
   defaultTemplate: "default",
   include: {
     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",
+    backgroundColor: "#22242c",
     themeColor: "#f00"
   }
 };

+ 39 - 16
src/index.js

@@ -2,31 +2,54 @@
 
 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 initTime = performance.now()
     const meta = await metaPromise;
-    console.log("Processing task:", task.name)
+    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,
-      },
-    }
+    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!")
+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}`)

+ 72 - 13
src/lib.js

@@ -2,16 +2,27 @@ import {
   readFilesByGlob,
   removeBasePaths,
   resolvePath,
-  replaceFileExtension
+  removeCwd,
+  replaceFileExtension,
+  createTrackedObject,
+  getValueAtPath,
+  slugifyString,
+  hashObject,
+  getFileHash,
+  checkPathExists,
+  checkCache,
+  updateCache,
+  fileExists
 } from "./util.js";
-import fs from "fs";
+import fs from "node:fs/promises";
 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");
-  if (fs.existsSync(configPath)) {
+  const configFileExists = await fileExists(configPath);
+  if (configFileExists) {
     try {
       const config = await import(configPath);
       return config.default || config;
@@ -30,11 +41,13 @@ export async function processFiles(config, meta) {
   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),
@@ -48,30 +61,76 @@ export async function processFiles(config, meta) {
       }
 
       const fileOutputDir = path.dirname(fileOutputPath);
-      if (!fs.existsSync(fileOutputDir)) {
-        fs.mkdirSync(fileOutputDir, { recursive: true });
+      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);
       }
-
-      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;
     })
   );
 }

+ 43 - 35
src/processors.js

@@ -3,7 +3,9 @@ import {
   firstFound,
   stripFileExtension,
   getCleanPath,
-  getHref
+  getHref,
+  slugifyString,
+  generateRandomId
 } from "./util.js";
 import fs from "fs/promises";
 import handlebars from "handlebars";
@@ -16,15 +18,6 @@ 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();
 
 function createMarkdownRenderer(meta) {
@@ -37,9 +30,7 @@ function createMarkdownRenderer(meta) {
           const hrefWithoutExt = stripFileExtension(href);
           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
@@ -66,30 +57,32 @@ function createMarkdownRenderer(meta) {
     });
 }
 
-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);
 
   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));
+    templateCache.set(templateName, {
+      path: templatePath,
+      renderer: handlebars.compile(templateContent)
+    });
   }
-
   const template = templateCache.get(templateName);
   const renderer = createMarkdownRenderer(meta);
-  const html = template({
+  const html = template.renderer({
     ...data,
     ...meta,
     href,
@@ -103,33 +96,44 @@ export async function renderMarkdownWithTemplate(
     minifyCSS: true,
     minifyJS: true
   });
+
   return {
     detail: { ...data, href },
-    result: minifiedHtml
+    result: minifiedHtml,
+    deps: {
+      paths: [template.path]
+    }
   };
 }
 
-export async function compileSass(filePath) {
+export async function compileSass({ filePath }) {
   const result = await sass.compileAsync(filePath, { style: "compressed" });
-  return { result: result.css.toString() };
+  return {
+    result: result.css,
+    deps: {
+      paths: [...result.loadedUrls.map(item => item.pathname)]
+    }
+  };
 }
 
-export async function optimiseSvg(filePath) {
+export async function optimiseSvg({ filePath }) {
   const svgString = await fs.readFile(filePath, "utf8");
   const result = optimize(svgString, {
     plugins: ["preset-default"]
   });
-  return { result: result.data };
+  return {
+    result: result.data
+  };
 }
 
-export async function copy(filePath) {
+export async function copy({ filePath }) {
   const fileContent = await fs.readFile(filePath, "utf8");
   return { result: fileContent };
 }
 
-export async function optimiseImage(filePath, meta, fileOutputDir) {
+export async function imageToWebP({ filePath, meta, fileOutputDir, config }) {
   const sourceExtension = path.extname(filePath);
-  const outputExtension = ".webp";
+  const outputExtension = config.outputFileExtension;
   const base = path.basename(filePath, sourceExtension);
 
   const original = sharp(filePath);
@@ -141,19 +145,19 @@ export async function optimiseImage(filePath, meta, fileOutputDir) {
   }
 
   const aspectRatio = width / height;
-
+  const name = config.uniqueFilenames ? base : `${base}-${generateRandomId()}`;
   const srcSet = await Promise.all(
-    imageSizes.map(async size => {
+    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 })
+        .webp({ quality: config.quality })
         .toFile(outputFile);
 
       return [getCleanPath(outputFile, meta), size];
@@ -165,11 +169,12 @@ export async function optimiseImage(filePath, meta, fileOutputDir) {
   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
@@ -223,7 +228,9 @@ export async function generateFavicons(filePath, meta, fileOutputDir) {
     // Combine HTML meta tags
     const htmlMeta = response.html.join("\n    ");
     return {
-      detail: { htmlMeta },
+      detail: {
+        htmlMeta
+      },
       result: [
         ...response.images.map(img =>
           getCleanPath(path.join(fileOutputDir, img.name), meta)
@@ -232,7 +239,8 @@ export async function generateFavicons(filePath, meta, fileOutputDir) {
           getCleanPath(path.join(fileOutputDir, file.name), meta)
         )
       ],
-      written: true
+      written: true,
+      ref: "metatags"
     };
   } catch (error) {
     throw new Error(`Failed to generate favicons: ${error.message}`);

+ 296 - 7
src/util.js

@@ -1,13 +1,28 @@
-import fs from "fs";
+import fs from "node:fs/promises";
 import os from "node:os";
 import path from "path";
 import { glob } from "glob";
+import { createReadStream } from "fs";
+import { createHash } from "crypto";
+import stableStringify from "json-stable-stringify";
 
-export function readDirectoryRecursively(dir, files = []) {
-  if (!fs.existsSync(dir)) {
+export async function fileExists(filePath) {
+  try {
+    const stats = 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);
     if (item.isDirectory()) {
@@ -47,16 +62,22 @@ export function resolvePath(unresolvedPath) {
   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)) {
+    const exists = await fileExists(filePath);
+    if (exists) {
       return filePath;
     }
   }
   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, "");
@@ -78,7 +99,7 @@ export function stripFileExtension(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) {
@@ -88,3 +109,271 @@ export function getHref(filePath, meta) {
   }
   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 (e) {
+    return false;
+  }
+}