diff --git a/CHANGELOG.md b/CHANGELOG.md index 91394a2..c8472a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v0.2.33] - 2026-01-12 + +### Added +- Add registry for `Blob`s as `blob:` URIs +- Add `useScopedStyle()` +- Add `stringify` inside `useScopedStyle()` and convert `Blob`s to `blob:` using `registerBlob()` + +### Deprecated +- Deprecated `createStyleScope()` + ## [v0.2.32] - 2026-01-09 ### Changed diff --git a/blob-registry.js b/blob-registry.js new file mode 100644 index 0000000..a73669d --- /dev/null +++ b/blob-registry.js @@ -0,0 +1,53 @@ +const SOURCE_REGISTRY = new WeakMap(); + +/** + * Create and register a `blob:` URI for a source. + * + * @param {Blob} source The `Blob` to regsiter. + * @returns {string} A `blob:` URI for the Object Source. + * @throws {TypeError} If the `source` is an invalid type. + */ +export function registerBlob(source) { + if (! (source instanceof Blob)) { + throw new TypeError('Expected a `Blob`'); + } else if (SOURCE_REGISTRY.has(source)) { + return SOURCE_REGISTRY.get(source); + } else { + const uri = URL.createObjectURL(source); + SOURCE_REGISTRY.set(source, uri); + return uri; + } +} + +/** + * Revoke an Object URL and remove it from the registry. + * + * @param {any} source The Object registered for a `blob:` URI. + * @returns {boolean} Whether or not the URI was revoked and unregistered. + */ +export function unregisterBlob(source) { + if (SOURCE_REGISTRY.has(source)) { + const uri = SOURCE_REGISTRY.get(source); + URL.revokeObjectURL(uri); + SOURCE_REGISTRY.delete(source); + return true; + } else { + return false; + } +} + +/** + * Get a registered `blob:` URI for a Source Object. + * + * @param {Blob} source The `Blob` the URI is registered to. + * @returns {string|undefined} The corresponding `blob:` URI. + */ +export const getBlobURL = source => SOURCE_REGISTRY.get(source); + +/** + * Check if an Object Source has a registered `blob:` URI. + * + * @param {Blob} source The `Blob` to check for. + * @returns {boolean} Whether or not the source has a registered `blob:` URI. + */ +export const hasBlobURL = source => SOURCE_REGISTRY.has(source); diff --git a/core.js b/core.js index 7385dd7..8878319 100644 --- a/core.js +++ b/core.js @@ -13,7 +13,7 @@ export { } from '@aegisjsproject/callback-registry/callbackRegistry.js'; export { - text, createStyleSheet, createCSSParser, css, lightCSS, darkCSS , + text, createStyleSheet, createCSSParser, css, lightCSS, darkCSS, useScopedStyle, styleSheetToFile, styleSheetToLink, createHTMLParser, html, doc, trustedHTML, htmlUnsafe, docUnsafe, htmlToFile, createTrustedHTMLTemplate, xml, svg, json, math, url, createShadowParser, shadow, styledShadow, el, createBoundParser, adoptStyles, prefixCSSRules, createStyleScope, @@ -36,6 +36,8 @@ export { registerComponent, getRegisteredComponentTags, getRegisteredComponents, } from './componentRegistry.js'; +export { registerBlob, unregisterBlob, getBlobURL, hasBlobURL } from './blob-registry.js'; + export { createComponent, clone } from './component.js'; export * from '@aegisjsproject/state/state.js'; diff --git a/default-policy.js b/default-policy.js index f3c9b9c..5009422 100644 --- a/default-policy.js +++ b/default-policy.js @@ -15,8 +15,7 @@ if (! ('trustedTypes' in globalThis && trustedTypes.createPolicy instanceof Func ...rest } = {}) { const el = document.createElement('div'); - - el.setHTML(input, { elements, attributes, comments, dataAttributes, ...rest }); + el.setHTML(input, { sanitizer: { elements, attributes, comments, dataAttributes, ...rest }}); return el.innerHTML; }, diff --git a/package-lock.json b/package-lock.json index a5a2b18..891766d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aegisjsproject/core", - "version": "0.2.32", + "version": "0.2.33", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@aegisjsproject/core", - "version": "0.2.32", + "version": "0.2.33", "funding": [ { "type": "librepay", diff --git a/package.json b/package.json index 83b5165..ac12283 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aegisjsproject/core", - "version": "0.2.32", + "version": "0.2.33", "description": "A fast, secure, modern, light-weight, and simple JS library for creating web components and more!", "keywords": [ "aegis", diff --git a/parsers.js b/parsers.js index b7eb624..621932c 100644 --- a/parsers.js +++ b/parsers.js @@ -1,6 +1,6 @@ export { text } from './parsers/text.js'; export { - createStyleSheet, createCSSParser, css, lightCSS, prefixCSSRules, + createStyleSheet, createCSSParser, css, lightCSS, prefixCSSRules, useScopedStyle, darkCSS, styleSheetToFile, styleSheetToLink, createBoundParser, adoptStyles, createStyleScope, } from './parsers/css.js'; export { diff --git a/parsers/css.js b/parsers/css.js index 9e68c5f..53df034 100644 --- a/parsers/css.js +++ b/parsers/css.js @@ -1,11 +1,61 @@ import { createStyleSheet, createCSSParser, css, setStyleSheets, addStyleSheets } from '@aegisjsproject/parsers/css.js'; +import { registerBlob } from '../blob-registry.js'; + +function stringify(thing) { + switch(typeof thing) { + case 'string': + case 'number': + case 'bigint': + return thing; + + case 'undefined': + case 'symbol': + return ''; + + case 'object': + if (thing instanceof Blob) { + return `url(${registerBlob(thing)})`; + } else if (thing instanceof URL) { + return `url(${thing.href})`; + } else if (thing === null) { + return ''; + } else { + return thing.toString(); + } + default: + return thing.toString(); + } +} const PREFIX = '_aegis_style_scope_'; /** - * Creates a scoped CSSStyleSheet attached to the provided root and returns a - * tagged template function for generating unique class names within that scope. + * Creates a scoped CSSStyleSheet and returns a tagged template function for generating + * unique class names within that scope. * + * @param {Object} [options] Configuration options. + * @param {string} [options.baseURL] The base URL used to resolve relative URLs in the stylesheet. + * @param {string|MediaList|MediaQueryList} [options.media] The intended media for the stylesheet (e.g., "screen", "print"). + * @param {boolean} [options.disabled] Whether the stylesheet is disabled by default. + * @param {string} [options.prefix] A custom prefix for generated class names. + * @returns {readonly [CSSStyleSheet, (strings: TemplateStringsArray, ...values: any[]) => string]} A tagged template function that + * inserts a new CSS rule and returns the generated class name, along with the `CSSStyleSheet` created. + */ +export function useScopedStyle({ baseURL, media, disabled, prefix = PREFIX } = {}) { + const sheet = new CSSStyleSheet({ baseURL, media: media instanceof MediaQueryList ? media.media : media, disabled }); + + return Object.freeze([sheet, (strings, ...values) => { + const uuid = crypto.randomUUID(); + const className = `${prefix}${uuid}`; + const rule = `.${CSS.escape(prefix) + uuid} { ${String.raw(strings, ...values.map(stringify))} }`; + sheet.insertRule(rule, sheet.cssRules.length); + + return className; + }]); +} + +/** + * @deprecated * @param {Document | ShadowRoot | Element} [root=document] The DOM scope to attach the stylesheet to. * If an Element is passed, its root node (Document or ShadowRoot) is used. * @@ -18,23 +68,10 @@ const PREFIX = '_aegis_style_scope_'; * inserts a new CSS rule and returns the generated class name. */ export function createStyleScope(root = document, { baseURL, media, disabled, prefix = PREFIX } = {}) { - if (root instanceof Element) { - return createStyleScope(root.getRootNode(), { baseURL, media, disabled, prefix }); - } else if (! (root instanceof Document || root instanceof ShadowRoot)) { - throw new TypeError('Root must be a Document, ShadowRoot, or Element.'); - } else { - const sheet = new CSSStyleSheet({ baseURL, media: media instanceof MediaQueryList ? media.media : media, disabled }); - root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]; - - return (strings, ...values) => { - const uuid = crypto.randomUUID(); - const className = `${prefix}${uuid}`; - const rule = `.${CSS.escape(prefix) + uuid} { ${String.raw(strings, ...values)} }`; - sheet.insertRule(rule, sheet.cssRules.length); - - return className; - }; - } + console.warn('`createStyleScope()` is deprecated. Please use `useScopedStyle()` instead.'); + const result = useScopedStyle({ baseURL, media, disabled, prefix }); + root.adoptedStyleSheets = [...document.adoptedStyleSheets, result[0]]; + return result[1]; } export const lightCSS = createCSSParser({ media: '(prefers-color-scheme: light)', baseURL: document.baseURI }); @@ -89,7 +126,7 @@ export function styleSheetToLink(styleSheet) { link.media = styleSheet.media.mediaText; } - link.href = URL.createObjectURL(file); + link.href = registerBlob(file); return link; } diff --git a/stringify.js b/stringify.js index f3e7d21..2998239 100644 --- a/stringify.js +++ b/stringify.js @@ -2,6 +2,7 @@ import { createCallback } from '@aegisjsproject/callback-registry/callbackRegistry.js'; import { registerSignal, registerController } from '@aegisjsproject/callback-registry/callbackRegistry.js'; import { stringifyAttr, createAttribute } from './dom.js'; +import { registerBlob } from './blob-registry.js'; const toData = ([name, val]) => ['data-' + name.replaceAll(/[A-Z]/g, c => `-${c.toLowerCase()}`), val]; @@ -116,7 +117,7 @@ export const stringify = thing => { document.adoptedStyleSheets = [...document.adoptedStyleSheets, thing]; break; } else if (thing instanceof Blob) { - return URL.createObjectURL(thing); + return registerBlob(thing); } else if(thing instanceof Date) { return formatDate(thing); } else if (thing instanceof DOMTokenList) { diff --git a/test/index.js b/test/index.js index c96960b..bf6d1d5 100644 --- a/test/index.js +++ b/test/index.js @@ -1,6 +1,6 @@ import { html, css, replaceStyles, getUniqueSelector, createComponent, closeRegistration, - data, attr, createTrustedHTMLTemplate, createStyleScope, + data, attr, createTrustedHTMLTemplate, useScopedStyle, } from '@aegisjsproject/core'; import { sanitizer as defaultSanitizer } from '@aegisjsproject/sanitizer/config/html.js'; @@ -44,8 +44,10 @@ document.body.setAttribute(fooEvent, FUNCS.debug.log); document.body.dataset[stateKey] = 'bg'; document.body.dataset[stateStyle] = 'background-color'; +const [sheet, style] = useScopedStyle({ media: matchMedia('(min-width: 800px)')}); + replaceStyles(document, ...document.adoptedStyleSheets, reset, baseTheme, lightTheme, darkTheme, btn, btnPrimary, btnDanger, btnWarning, - btnInfo, btnSystemAccent, btnSuccess, btnLink, btnSecondary, + btnInfo, btnSystemAccent, btnSuccess, btnLink, btnSecondary, sheet, css`.${scope} { color: red; } @@ -59,8 +61,6 @@ replaceStyles(document, ...document.adoptedStyleSheets, reset, baseTheme, lightT } `); -const style = createStyleScope(document.body, { media: matchMedia('(min-width: 800px)')}); - const DadJoke = await customElements.whenDefined('dad-joke'); const frag = document.createDocumentFragment(); const h1 = document.createElement('h1'); @@ -83,6 +83,7 @@ try { rgba(20, 20, 20, 0.7) 0%, rgba(20, 20, 20, 0.4) 100% ); + background-image: ${blob}; backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255, 255, 255, 0.08); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);