From 9956d4dfb60d358a25c1760a5882925c237e900a Mon Sep 17 00:00:00 2001 From: Chris Zuber Date: Mon, 12 Jan 2026 10:12:52 -0800 Subject: [PATCH] Add media blob utilities and deprecate createStyleScope Introduces media.js with functions to register, revoke, and query blob URLs for Blob objects, and integrates these utilities into CSS and stringification logic. Adds useScopedStyle for creating scoped stylesheets, deprecates createStyleScope in favor of useScopedStyle, and updates related imports and tests accordingly. Also improves CSS template value handling to support Blob and URL objects. --- CHANGELOG.md | 10 ++++++ blob-registry.js | 53 ++++++++++++++++++++++++++++++++ core.js | 4 ++- default-policy.js | 3 +- package-lock.json | 4 +-- package.json | 2 +- parsers.js | 2 +- parsers/css.js | 77 +++++++++++++++++++++++++++++++++++------------ stringify.js | 3 +- test/index.js | 9 +++--- 10 files changed, 135 insertions(+), 32 deletions(-) create mode 100644 blob-registry.js 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);