mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Remove dynamic style system and tighten inline style checks
This commit is contained in:
1 parent
861a9036d3
commit
dfa4a98f0a
6 files changed
+40
-295
No files matched your search
@@ -1,253 +0,0 @@
|
||||
const STYLE_TAG_ID = "bitvid-style-system";
|
||||
|
||||
const sheetRegistry = new WeakMap();
|
||||
const styleCache = new Map();
|
||||
const elementClassRegistry = new WeakMap();
|
||||
const classDefinitionRegistry = new Map();
|
||||
|
||||
let classSequence = 0;
|
||||
|
||||
function resolveHTMLElementConstructor(documentRef) {
|
||||
return (
|
||||
documentRef?.defaultView?.HTMLElement ||
|
||||
globalThis.HTMLElement ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function isElementCandidate(element) {
|
||||
return element && typeof element === "object";
|
||||
}
|
||||
|
||||
function isHTMLElement(element, documentRef) {
|
||||
const Constructor = resolveHTMLElementConstructor(documentRef);
|
||||
if (!Constructor) {
|
||||
return false;
|
||||
}
|
||||
return element instanceof Constructor;
|
||||
}
|
||||
|
||||
function ensureDocument(element) {
|
||||
if (element && element.ownerDocument) {
|
||||
return element.ownerDocument;
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
return document;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureStyleSheet(documentRef) {
|
||||
if (!documentRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sheetRegistry.has(documentRef)) {
|
||||
return sheetRegistry.get(documentRef);
|
||||
}
|
||||
|
||||
const root = documentRef.documentElement || documentRef.body;
|
||||
const head = documentRef.head || documentRef.getElementsByTagName("head")[0];
|
||||
if (!head && root) {
|
||||
// If the document does not yet have a <head>, create one so styles attach cleanly.
|
||||
const createdHead = documentRef.createElement("head");
|
||||
documentRef.insertBefore(createdHead, root);
|
||||
}
|
||||
|
||||
const container = documentRef.getElementById(STYLE_TAG_ID);
|
||||
const StyleElementConstructor =
|
||||
documentRef.defaultView?.HTMLStyleElement ||
|
||||
globalThis.HTMLStyleElement ||
|
||||
null;
|
||||
const isStyleElement =
|
||||
(StyleElementConstructor && container instanceof StyleElementConstructor) ||
|
||||
(!StyleElementConstructor && container?.tagName === "STYLE");
|
||||
const styleElement = isStyleElement
|
||||
? container
|
||||
: documentRef.createElement("style");
|
||||
|
||||
styleElement.type = "text/css";
|
||||
styleElement.id = STYLE_TAG_ID;
|
||||
|
||||
if (!styleElement.parentNode) {
|
||||
(documentRef.head || documentRef.documentElement)?.appendChild(styleElement);
|
||||
}
|
||||
|
||||
const sheet = styleElement.sheet;
|
||||
sheetRegistry.set(documentRef, sheet);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function toCssPropertyName(property) {
|
||||
if (!property) {
|
||||
return "";
|
||||
}
|
||||
if (property.startsWith("--")) {
|
||||
return property;
|
||||
}
|
||||
return property
|
||||
.replace(/([A-Z])/g, "-$1")
|
||||
.replace(/_/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function serializeStyleObject(styleObject) {
|
||||
return Object.keys(styleObject)
|
||||
.sort()
|
||||
.map((key) => `${key}:${String(styleObject[key])}`)
|
||||
.join(";");
|
||||
}
|
||||
|
||||
function createCssRule(styleObject) {
|
||||
return Object.keys(styleObject)
|
||||
.sort()
|
||||
.map((key) => `${toCssPropertyName(key)}: ${styleObject[key]};`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getSlotKey(slot) {
|
||||
if (!slot) {
|
||||
return "default";
|
||||
}
|
||||
return String(slot);
|
||||
}
|
||||
|
||||
export function applyDynamicStyles(element, styleObject = {}, options = {}) {
|
||||
if (!isElementCandidate(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = ensureDocument(element);
|
||||
if (!doc || !isHTMLElement(element, doc)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = Object.entries(styleObject).filter(([, value]) => value !== null && value !== undefined);
|
||||
if (!entries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = entries.reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const sheet = ensureStyleSheet(doc);
|
||||
if (!sheet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = serializeStyleObject(normalized);
|
||||
let className = styleCache.get(cacheKey);
|
||||
if (!className) {
|
||||
classSequence += 1;
|
||||
className = `bvds-${classSequence.toString(36)}`;
|
||||
const cssRule = createCssRule(normalized);
|
||||
try {
|
||||
sheet.insertRule(`.${className} { ${cssRule} }`, sheet.cssRules.length);
|
||||
} catch (error) {
|
||||
console.warn("Failed to insert dynamic style", { cssRule, error });
|
||||
return null;
|
||||
}
|
||||
styleCache.set(cacheKey, className);
|
||||
classDefinitionRegistry.set(className, { ...normalized });
|
||||
}
|
||||
|
||||
let slotRegistry = elementClassRegistry.get(element);
|
||||
if (!slotRegistry) {
|
||||
slotRegistry = new Map();
|
||||
elementClassRegistry.set(element, slotRegistry);
|
||||
}
|
||||
|
||||
const slot = getSlotKey(options.slot);
|
||||
const previousClass = slotRegistry.get(slot);
|
||||
if (previousClass && previousClass !== className) {
|
||||
element.classList.remove(previousClass);
|
||||
}
|
||||
|
||||
if (!element.classList.contains(className)) {
|
||||
element.classList.add(className);
|
||||
}
|
||||
|
||||
slotRegistry.set(slot, className);
|
||||
return className;
|
||||
}
|
||||
|
||||
export function removeDynamicStyles(element, options = {}) {
|
||||
if (!isElementCandidate(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = ensureDocument(element);
|
||||
if (!doc || !isHTMLElement(element, doc)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const slot = getSlotKey(options.slot);
|
||||
const slotRegistry = elementClassRegistry.get(element);
|
||||
if (!slotRegistry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const className = slotRegistry.get(slot);
|
||||
if (className) {
|
||||
element.classList.remove(className);
|
||||
slotRegistry.delete(slot);
|
||||
}
|
||||
|
||||
if (!slotRegistry.size) {
|
||||
elementClassRegistry.delete(element);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllDynamicStyles(element) {
|
||||
if (!isElementCandidate(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = ensureDocument(element);
|
||||
if (!doc || !isHTMLElement(element, doc)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const slotRegistry = elementClassRegistry.get(element);
|
||||
if (!slotRegistry) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const className of slotRegistry.values()) {
|
||||
element.classList.remove(className);
|
||||
}
|
||||
|
||||
elementClassRegistry.delete(element);
|
||||
}
|
||||
|
||||
export function getAppliedDynamicStyles(element, options = {}) {
|
||||
if (!isElementCandidate(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = ensureDocument(element);
|
||||
if (!doc || !isHTMLElement(element, doc)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slot = getSlotKey(options.slot);
|
||||
const slotRegistry = elementClassRegistry.get(element);
|
||||
if (!slotRegistry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className = slotRegistry.get(slot);
|
||||
if (!className) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = classDefinitionRegistry.get(className);
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...definition };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ const DIST_SCAN_PREFIXES = new Set([
|
||||
const DIST_SCAN_PREFIXES_ARRAY = [...DIST_SCAN_PREFIXES];
|
||||
|
||||
const IGNORED_FILES = new Set([
|
||||
"js/ui/styleSystem.js",
|
||||
"css/tailwind.generated.css",
|
||||
"js/webtorrent.min.js",
|
||||
"js/webtorrent.min.js.map",
|
||||
@@ -45,6 +44,7 @@ const VIOLATION_ALLOWLIST = new Map([
|
||||
"torrent/dist/beacon.vendor.js",
|
||||
new Set(["Direct .style usage", "style.cssText usage"]),
|
||||
],
|
||||
["js/ui/utils/positionFloatingPanel.js", new Set(["Direct .style usage"])],
|
||||
]);
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
@@ -220,9 +220,7 @@ async function main() {
|
||||
`${violation.file}:${violation.line} — ${violation.label}\n ${violation.snippet}\n`
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
"Inline styles are disallowed. Move styles into tokens, CSS, or styleSystem."
|
||||
);
|
||||
console.error("Inline styles are disallowed. Move styles into tokens or CSS.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@ import { beforeEach, afterEach, describe, test } from 'node:test';
|
||||
|
||||
import { advanceSlide, createCarousel, destroyCarousel } from '../js/blog/carousel.js';
|
||||
import { mountProgressBar } from '../js/blog/progressBar.js';
|
||||
import {
|
||||
createBlogDomEnvironment,
|
||||
createSpy,
|
||||
readDynamicStyleRules,
|
||||
} from './test-helpers/blog-dom.mjs';
|
||||
import { createBlogDomEnvironment, createSpy } from './test-helpers/blog-dom.mjs';
|
||||
|
||||
function assertHasClasses(element, classes, messagePrefix = 'element') {
|
||||
classes.forEach((className) => {
|
||||
assert.ok(
|
||||
element.classList.contains(className),
|
||||
`${messagePrefix} should include the “${className}” class`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function assertNoInlineStyleAttributes(elements, messagePrefix = 'element') {
|
||||
elements.forEach((element, index) => {
|
||||
@@ -208,8 +213,12 @@ describe('progress bar', () => {
|
||||
|
||||
const wrapper = container.querySelector('.blog-carousel__progress');
|
||||
assert.ok(wrapper, 'wrapper element should be appended');
|
||||
assertHasClasses(wrapper, ['blog-carousel__progress', 'blog-progress', 'mt-2'], 'progress wrapper');
|
||||
assert.strictEqual(wrapper.dataset.carouselProgress, 'true');
|
||||
assert.strictEqual(wrapper.getAttribute('data-carousel-progress'), 'true');
|
||||
const meter = wrapper.querySelector('progress.progress--blog');
|
||||
assert.ok(meter, 'progress meter should be created');
|
||||
assertHasClasses(meter, ['progress', 'progress--blog'], 'progress meter');
|
||||
assert.strictEqual(meter.max, 100, 'progress meter should expose a 0-100 range');
|
||||
assert.strictEqual(meter.value, 0, 'progress meter should start at zero');
|
||||
assert.strictEqual(meter.dataset.state, 'idle');
|
||||
@@ -220,12 +229,12 @@ describe('progress bar', () => {
|
||||
'Carousel progress 0% complete',
|
||||
'progress meter should emit accessible status text',
|
||||
);
|
||||
assert.strictEqual(meter.getAttribute('data-progress-meter'), 'true');
|
||||
assert.strictEqual(meter.dataset.progressMeter, 'true');
|
||||
assert.strictEqual(meter.getAttribute('data-variant'), 'blog');
|
||||
|
||||
progress.reset();
|
||||
|
||||
assert.strictEqual(readDynamicStyleRules(env.document), '', 'no dynamic style rules should be injected');
|
||||
assert.strictEqual(env.document.getElementById('bitvid-style-system'), null);
|
||||
|
||||
assertNoInlineStyleAttributes([container, wrapper, meter], 'progress element');
|
||||
});
|
||||
|
||||
@@ -249,9 +258,6 @@ describe('progress bar', () => {
|
||||
assert.strictEqual(meter.value, 100);
|
||||
assert.strictEqual(meter.dataset.state, 'complete');
|
||||
assert.strictEqual(meter.dataset.progress, '100');
|
||||
|
||||
assert.strictEqual(readDynamicStyleRules(env.document), '', 'no dynamic style rules should be injected');
|
||||
assert.strictEqual(env.document.getElementById('bitvid-style-system'), null);
|
||||
});
|
||||
|
||||
test('manual controls support stop, restart, and cleanup', () => {
|
||||
@@ -281,9 +287,11 @@ describe('progress bar', () => {
|
||||
|
||||
progress.destroy();
|
||||
assert.strictEqual(container.children.length, 0, 'destroy should remove wrapper from DOM');
|
||||
|
||||
assert.strictEqual(readDynamicStyleRules(env.document), '', 'no dynamic style rules should be injected');
|
||||
assert.strictEqual(env.document.getElementById('bitvid-style-system'), null);
|
||||
assert.strictEqual(
|
||||
container.querySelector('[data-carousel-progress]'),
|
||||
null,
|
||||
'destroy should remove declarative progress markup',
|
||||
);
|
||||
assertNoInlineStyleAttributes([container], 'progress container');
|
||||
});
|
||||
});
|
||||
@@ -188,8 +188,8 @@ test("MediaLoader clears unsupported lazy targets without inline styles", async
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
div.style.backgroundImage,
|
||||
"",
|
||||
div.hasAttribute("style"),
|
||||
false,
|
||||
"background image styles should not be injected at runtime",
|
||||
);
|
||||
assert.ok(!observer.observed.has(div));
|
||||
|
||||
@@ -3,6 +3,14 @@ import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import positionFloatingPanel from "../js/ui/utils/positionFloatingPanel.js";
|
||||
|
||||
function getInlineStyleValue(element, property) {
|
||||
const styleAttr = element.getAttribute("style") || "";
|
||||
const escapedProperty = property.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||
const pattern = new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i");
|
||||
const match = styleAttr.match(pattern);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
test("flips to top when bottom placement collides with viewport", () => {
|
||||
const dom = new JSDOM(
|
||||
`
|
||||
@@ -54,9 +62,9 @@ test("flips to top when bottom placement collides with viewport", () => {
|
||||
positioner.update();
|
||||
|
||||
assert.equal(panel.dataset.floatingPlacement, "top");
|
||||
assert.equal(panel.style.position, "fixed");
|
||||
assert.equal(panel.style.getPropertyValue("--floating-fallback-top"), "132px");
|
||||
assert.equal(panel.style.getPropertyValue("--floating-fallback-left"), "100px");
|
||||
assert.equal(getInlineStyleValue(panel, "position"), "fixed");
|
||||
assert.equal(getInlineStyleValue(panel, "--floating-fallback-top"), "132px");
|
||||
assert.equal(getInlineStyleValue(panel, "--floating-fallback-left"), "100px");
|
||||
|
||||
positioner.destroy();
|
||||
assert.equal(panel.dataset.floatingPanel, undefined);
|
||||
@@ -118,7 +126,7 @@ test("respects RTL alignment and clamps within the viewport", () => {
|
||||
// In RTL mode, start alignment hugs the right edge of the trigger.
|
||||
assert.equal(panel.dataset.floatingPlacement, "bottom");
|
||||
assert.equal(panel.dataset.floatingDir, "rtl");
|
||||
assert.equal(panel.style.getPropertyValue("--floating-fallback-left"), "140px");
|
||||
assert.equal(getInlineStyleValue(panel, "--floating-fallback-left"), "140px");
|
||||
|
||||
positioner.destroy();
|
||||
});
|
||||
@@ -182,12 +190,12 @@ test("updates when scroll containers move the trigger", async () => {
|
||||
panel.dataset.state = "open";
|
||||
positioner.update();
|
||||
|
||||
assert.equal(panel.style.getPropertyValue("--floating-fallback-top"), "152px");
|
||||
assert.equal(getInlineStyleValue(panel, "--floating-fallback-top"), "152px");
|
||||
assert.ok(scrollHandler, "scroll listener should be registered");
|
||||
|
||||
anchorTop = 60;
|
||||
scrollHandler?.({ type: "scroll" });
|
||||
assert.equal(panel.style.getPropertyValue("--floating-fallback-top"), "112px");
|
||||
assert.equal(getInlineStyleValue(panel, "--floating-fallback-top"), "112px");
|
||||
|
||||
positioner.destroy();
|
||||
window.addEventListener = originalAddEventListener;
|
||||
|
||||
@@ -157,22 +157,6 @@ export function installInlineStyleGuard(document) {
|
||||
};
|
||||
}
|
||||
|
||||
export function readDynamicStyleRules(document) {
|
||||
const styleElement = document.getElementById('bitvid-style-system');
|
||||
const sheet = styleElement?.sheet;
|
||||
if (!sheet) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return Array.from(sheet.cssRules)
|
||||
.map((rule) => rule.cssText)
|
||||
.join('\n');
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function createBlogDomEnvironment(html) {
|
||||
const dom = new JSDOM(html, { url: 'https://example.com' });
|
||||
const { window } = dom;
|
||||
|
||||
Reference in new issue
Block a user