-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcss.ts
More file actions
153 lines (143 loc) · 4.85 KB
/
Copy pathcss.ts
File metadata and controls
153 lines (143 loc) · 4.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
/**
* `css` tagged template literal helper for component styles.
*
* Produces a {@link ComponentStyles} payload that can be assigned to
* {@link ComponentDefinition.styles}. When the host environment supports
* Constructable Stylesheets, the underlying CSS is shared across all
* instances via `document.adoptedStyleSheets`; otherwise it falls back to a
* traditional `<style>` element rendered inside the shadow root.
*
* Interpolated values are HTML/CSS-escaped to prevent stylesheet injection.
*
* @example
* ```ts
* import { component, css, html } from '@bquery/bquery/component';
*
* const accent = '#0066cc';
* component('themed-card', {
* styles: css`
* :host { display: block; padding: 1rem; }
* h2 { color: ${accent}; }
* `,
* render() { return html`<h2>Hello</h2>`; },
* });
* ```
*
* @module bquery/component
*/
const COMPONENT_STYLES_MARKER: unique symbol = Symbol('bquery.componentStyles');
/**
* Opaque payload returned by {@link css}.
*
* It coerces to its underlying string via `toString()` so that existing
* `styles: string` consumers continue to work unchanged.
*/
export interface ComponentStyles {
readonly [COMPONENT_STYLES_MARKER]: true;
readonly text: string;
toString(): string;
/**
* Construct a shared `CSSStyleSheet` (cached per text) for use with
* `adoptedStyleSheets`. Returns `null` in environments without support.
*/
toAdoptableSheet(): CSSStyleSheet | null;
}
/**
* Type guard: is the given value a {@link ComponentStyles} payload?
*/
export const isComponentStyles = (value: unknown): value is ComponentStyles => {
return (
typeof value === 'object' &&
value !== null &&
(value as ComponentStyles)[COMPONENT_STYLES_MARKER] === true
);
};
/**
* Sanitize an interpolated value for safe embedding into CSS.
*
* - Numbers and booleans are converted via `String()`.
* - Strings have characters that could escape a CSS value, attribute, or
* comment sanitized by stripping NUL bytes, `<` / `</` prefixes, and the
* `/* … *\/` comment sequence, then escaping quotes, braces, semicolons,
* and backslashes.
* - `null` / `undefined` collapse to the empty string.
* - {@link ComponentStyles} interpolations are inlined as plain text so
* utility partials can be composed.
*/
const escapeCssValue = (value: unknown): string => {
if (value == null) return '';
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (isComponentStyles(value)) return value.text;
const str = String(value);
const sanitized = str
.replace(/\u0000/g, '')
.replace(/\/\*/g, '')
.replace(/\*\//g, '')
.replace(/<\/?/g, '');
return sanitized.replace(/["'{};\\]/g, (char) => `\\${char.codePointAt(0)?.toString(16)} `);
};
const sheetCache = new WeakMap<typeof globalThis, Map<string, CSSStyleSheet>>();
const supportsConstructableStylesheets = (ctor: unknown): ctor is typeof CSSStyleSheet => {
if (typeof ctor !== 'function') return false;
const proto = (ctor as { prototype?: { replaceSync?: unknown } }).prototype;
return typeof proto?.replaceSync === 'function';
};
const getOrCreateSheet = (text: string): CSSStyleSheet | null => {
const ctor = (globalThis as unknown as { CSSStyleSheet?: typeof CSSStyleSheet }).CSSStyleSheet;
if (!supportsConstructableStylesheets(ctor)) return null;
let cache = sheetCache.get(globalThis);
if (!cache) {
cache = new Map();
sheetCache.set(globalThis, cache);
}
let sheet = cache.get(text);
if (!sheet) {
try {
sheet = new ctor();
(sheet as CSSStyleSheet & { replaceSync(text: string): void }).replaceSync(text);
cache.set(text, sheet);
} catch {
return null;
}
}
return sheet;
};
const makeStyles = (text: string): ComponentStyles => ({
[COMPONENT_STYLES_MARKER]: true,
text,
toString(): string {
return text;
},
toAdoptableSheet(): CSSStyleSheet | null {
return getOrCreateSheet(text);
},
});
/**
* Tagged template literal that produces a {@link ComponentStyles} payload.
*/
export const css = (strings: TemplateStringsArray, ...values: unknown[]): ComponentStyles => {
let text = '';
for (let i = 0; i < strings.length; i += 1) {
text += strings[i];
if (i < values.length) text += escapeCssValue(values[i]);
}
return makeStyles(text);
};
/**
* Attempt to apply a `ComponentStyles` payload to a shadow root via
* `adoptedStyleSheets`. Returns `true` on success; on failure (no support, or
* sheet construction failed) callers should fall back to a `<style>` element.
*/
export const applyAdoptedStyles = (root: ShadowRoot, styles: ComponentStyles): boolean => {
const sheet = styles.toAdoptableSheet();
if (!sheet) return false;
try {
const current = root.adoptedStyleSheets ?? [];
if (!current.includes(sheet)) {
root.adoptedStyleSheets = [...current, sheet];
}
return true;
} catch {
return false;
}
};