diff --git a/CHANGELOG.md b/CHANGELOG.md
index a75a29e..e2a4336 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [v1.0.4] - 2024-04-11
+
+### Added
+- Add basic key/value cache
+- Add `renderComponentShadow` function to `custom-elements.js` module
+
## [v1.0.3] - 2024-04-05
### Added
diff --git a/Cache.js b/Cache.js
new file mode 100644
index 0000000..d6286b6
--- /dev/null
+++ b/Cache.js
@@ -0,0 +1,79 @@
+const keyMap = {};
+
+let supportsSymbolKeys = true;
+
+try {
+ new WeakMap([[Symbol('test'), null]]);
+} catch {
+ supportsSymbolKeys = false;
+}
+
+const convertKey = supportsSymbolKeys ? key => key : (key) => {
+ if (supportsSymbolKeys || typeof key === 'object') {
+ return key;
+ } else if (typeof key !== 'symbol' || typeof Symbol.keyFor(key) !== 'undefined') {
+ throw new TypeError(`Cache key "${key.toString()}" must be an object or an unregistered symbol.`);
+ } else if (keyMap.hasOwnProperty(key)) {
+ return keyMap[key];
+ } else {
+ const mapped = Object.freeze({ key });
+ keyMap[key] = mapped;
+ return mapped;
+ }
+};
+
+export class Cache {
+ #cache;
+
+ constructor() {
+ this.#cache = new WeakMap();
+ }
+
+ has(key, requireFresh = false) {
+ if (requireFresh) {
+ const result = this.#cache.get(convertKey(key));
+ return typeof result === 'object' && (
+ typeof result.expires !== 'number'
+ || Date.now() < result.expires
+ );
+ } else {
+ return this.#cache.has(convertKey(key));
+ }
+ }
+
+ delete(key) {
+ return this.#cache.delete(convertKey(key));
+ }
+
+ set(key, value, { expires = undefined } = {}) {
+ if (value === undefined) {
+ throw new TypeError('Cannot cache undefined values.');
+ } else if (expires instanceof Date) {
+ this.set(key, value, { expires: expires.getTime() });
+ } else if (typeof expires === 'string') {
+ this.set(key, value, { expires: new Date(expires).getTime() });
+ } else if (typeof expires === 'number' && Date.now() > expires) {
+ throw new TypeError('Attempting to set already expired value.');
+ } else if (Number.isNaN(expires)) {
+ throw new TypeError('Expires is NaN.');
+ } else if (expires !== undefined && typeof expires !== 'number') {
+ throw new TypeError(`${typeof expires} is not a valid type for cache expires.`);
+ } else {
+ this.#cache.set(convertKey(key), Object.freeze({ value, expires, updated: Date.now() }));
+ }
+ }
+
+ get(key, fallback = undefined) {
+ const result = this.#cache.get(convertKey(key));
+
+ if (result === undefined) {
+ return fallback;
+ } else if (typeof result.expires === 'number' && result.expires < Date.now()) {
+ this.delete(key);
+ console.info(`${key.toString()} is expired and has been deleted.`);
+ return fallback;
+ } else {
+ return result.value;
+ }
+ }
+}
diff --git a/custom-elements.js b/custom-elements.js
index 4365188..9a94198 100644
--- a/custom-elements.js
+++ b/custom-elements.js
@@ -3,6 +3,10 @@
*/
import { HTML } from '@shgysk8zer0/consts/mimes.js';
import { registerComponent as reg } from '@aegisjsproject/core/componentRegistry.js';
+import { whenIntersecting } from './intersect.js';
+import { Cache } from './Cache.js';
+
+const componentCache = new Cache();
export const supported = globalThis.customElements instanceof Object;
@@ -88,3 +92,83 @@ export async function getTemplate(src, opts, {
throw new Error(`${resp.url} [${resp.status} ${resp.statusText}]`);
}
}
+
+export async function renderComponentShadow(shadow, {
+ template,
+ styles,
+ content,
+ loading = 'eager',
+ cache = componentCache,
+ key,
+ expires,
+ sanitizer: {
+ elements,
+ attributes,
+ comments = false,
+ dataAttributes = true,
+ ...sanitizer
+ }
+}) {
+ if (! (shadow instanceof ShadowRoot)) {
+ throw new TypeError('Shadow expected to be a `ShadowRoot`.');
+ }
+
+ if (typeof content === 'string') {
+ shadow.host.setHTML(content, { elements, attributes, comments, dataAttributes, ...sanitizer });
+ } else if (content instanceof HTMLTemplateElement) {
+ shadow.host.append(content.content.cloneNode(true));
+ } else if (content instanceof Node) {
+ shadow.host.append(content);
+ } else if (Array.isArray(content)) {
+ shadow.host.append(...content);
+ }
+
+ const cacheKey = typeof key === 'undefined' ? shadow.host.constructor : key;
+
+ if (loading === 'lazy') {
+ await whenIntersecting(shadow.host);
+ }
+
+ if (cache.has(cacheKey, expires !== undefined)) {
+ const { frag, sheets } = await cache.get(cacheKey);
+ shadow.adoptedStyleSheets = sheets;
+ shadow.append(frag.cloneNode(true));
+ } else {
+ cache.set(key, new Promise(async (resolve, reject) => {
+ const frag = document.createDocumentFragment();
+
+ if (typeof template === 'string') {
+ frag.setHTML(template, { elements, attributes, comments, dataAttributes, ...sanitizer });
+ } else if (template instanceof HTMLTemplateElement) {
+ frag.append(template.content.cloneNode(true));
+ } else if (template instanceof Node) {
+ frag.append(template.cloneNode(true));
+ } else {
+ reject(new TypeError('Template must be a string or a Node.'));
+ }
+
+ if (Array.isArray(styles)) {
+ const sheets = await Array.fromAsync(
+ styles,
+ async sheet => sheet instanceof CSSStyleSheet ? sheet : new CSSStyleSheet().replace(sheet)
+ );
+
+ resolve({ frag, sheets });
+ shadow.adoptedStyleSheets = sheets;
+ shadow.append(frag.cloneNode(true));
+ } else if (styles instanceof CSSStyleSheet) {
+ resolve({ frag, sheets: [styles] });
+ shadow.adoptedStyleSheets = [styles];
+ shadow.append(frag.cloneNode(true));
+ } else if (typeof styles === 'string') {
+ const sheet = await new CSSStyleSheet().replace(styles);
+ resolve({ frag, sheets: [sheet] });
+ shadow.adoptedStyleSheets = [sheet];
+ shadow.append(frag.cloneNode(true));
+ }
+ }).catch(err => {
+ console.error(err);
+ cache.delete(cacheKey);
+ }), expires);
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index d7b61ae..7749d9b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@shgysk8zer0/kazoo",
- "version": "1.0.3",
+ "version": "1.0.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@shgysk8zer0/kazoo",
- "version": "1.0.3",
+ "version": "1.0.4",
"funding": [
{
"type": "librepay",
diff --git a/package.json b/package.json
index ebfc49d..a2b96c6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@shgysk8zer0/kazoo",
- "version": "1.0.3",
+ "version": "1.0.4",
"private": false,
"type": "module",
"description": "A JavaScript monorepo for all the things!",
diff --git a/test/index.html b/test/index.html
index 9c92e97..3d80994 100644
--- a/test/index.html
+++ b/test/index.html
@@ -71,7 +71,7 @@
}
}
-
+