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 @@ } } - +