@hyperfixi/components 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/dist/attrs.d.ts +15 -0
- package/dist/index.cjs +481 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +451 -0
- package/dist/index.js.map +1 -0
- package/dist/register.d.ts +41 -0
- package/dist/scan.d.ts +24 -0
- package/dist/scope-css.d.ts +48 -0
- package/dist/slots.d.ts +15 -0
- package/dist/template-ast.d.ts +43 -0
- package/dist/types.d.ts +25 -0
- package/package.json +64 -0
- package/src/attrs.test.ts +62 -0
- package/src/attrs.ts +80 -0
- package/src/index.ts +110 -0
- package/src/integration.test.ts +609 -0
- package/src/register.ts +308 -0
- package/src/scan.ts +96 -0
- package/src/scope-css.test.ts +80 -0
- package/src/scope-css.ts +87 -0
- package/src/slots.test.ts +55 -0
- package/src/slots.ts +70 -0
- package/src/template-ast.test.ts +82 -0
- package/src/template-ast.ts +147 -0
- package/src/types.ts +29 -0
package/src/register.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template-component registry — builds a Custom Element class for each
|
|
3
|
+
* `<template component="tag-name">` element and registers it via
|
|
4
|
+
* `customElements.define`.
|
|
5
|
+
*
|
|
6
|
+
* v2 render model:
|
|
7
|
+
* - `${expr}` interpolation against `attrs`, with `^var` references rewritten
|
|
8
|
+
* to call `reactive.readCaret(host, name)` so reads are tracked
|
|
9
|
+
* - The render is wrapped in `reactive.createEffect(...)` so when any
|
|
10
|
+
* tracked dep (e.g. `^count`) changes, the template re-stamps and the
|
|
11
|
+
* runtime re-processes the new subtree
|
|
12
|
+
* - Per-instance `_=` init script (from `<template _="...">` or
|
|
13
|
+
* `<script type="text/hyperscript-template" _="...">`) is transferred
|
|
14
|
+
* to the host element so the runtime processes it once via its standard
|
|
15
|
+
* init mechanism
|
|
16
|
+
*
|
|
17
|
+
* v2.1 additions:
|
|
18
|
+
* - `attrs` injected as a hyperscript local inside the init script
|
|
19
|
+
* - `dom-scope="isolated"` set on each instance host
|
|
20
|
+
* - `<style>` blocks lifted into <head> as `@scope (tag-name) { ... }`
|
|
21
|
+
*
|
|
22
|
+
* Deferred (v2.2+):
|
|
23
|
+
* - Reactive re-render of attrs (today attrs values are read at first
|
|
24
|
+
* render; mutating an attribute on the host doesn't re-render)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { RuntimeLike } from './types';
|
|
28
|
+
import { substituteSlots } from './slots';
|
|
29
|
+
import { createAttrsProxy } from './attrs';
|
|
30
|
+
import { reactive } from '@hyperfixi/reactivity';
|
|
31
|
+
import { hyperscript, createContext } from '@hyperfixi/core';
|
|
32
|
+
import { parseTemplate, renderTemplate, type TemplateNode } from './template-ast';
|
|
33
|
+
import { extractStyles, injectScopedStyles } from './scope-css';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Module-level registry of tag names already defined. `customElements.define`
|
|
37
|
+
* throws on duplicate registration, so we dedupe here.
|
|
38
|
+
*/
|
|
39
|
+
const REGISTERED = new Set<string>();
|
|
40
|
+
|
|
41
|
+
interface RegistryOptions {
|
|
42
|
+
runtime?: RuntimeLike;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rewrite `^name` references in an expression so they become calls to a
|
|
47
|
+
* tracked-read helper. Property access continues naturally:
|
|
48
|
+
*
|
|
49
|
+
* ^count → __c('count')
|
|
50
|
+
* ^user.name → __c('user').name
|
|
51
|
+
* ^items.length → __c('items').length
|
|
52
|
+
*
|
|
53
|
+
* Only matches `^` followed by an identifier — bitwise XOR (`a ^ b`) where
|
|
54
|
+
* `b` starts with a digit/punctuation is unaffected. Bitwise XOR with a
|
|
55
|
+
* named operand inside an interpolation is rare enough not to worry about.
|
|
56
|
+
*/
|
|
57
|
+
function rewriteCaretRefs(expr: string): string {
|
|
58
|
+
return expr.replace(/\^([a-zA-Z_][a-zA-Z0-9_]*)/g, "__c('$1')");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Evaluate a `${...}` expression. Supports:
|
|
63
|
+
* - `attrs.X` — read from the component's attrs proxy
|
|
64
|
+
* - `^name` and `^name.path` — read DOM-scoped vars via the reactivity graph
|
|
65
|
+
*
|
|
66
|
+
* Errors silently return empty string (matches upstream tolerance).
|
|
67
|
+
*/
|
|
68
|
+
function evalInterpolation(
|
|
69
|
+
expr: string,
|
|
70
|
+
scope: Record<string, unknown>,
|
|
71
|
+
hostElement: Element
|
|
72
|
+
): unknown {
|
|
73
|
+
const rewritten = rewriteCaretRefs(expr);
|
|
74
|
+
const fn = new Function(
|
|
75
|
+
...Object.keys(scope),
|
|
76
|
+
'__c',
|
|
77
|
+
`"use strict"; try { return (${rewritten}); } catch (e) { return undefined; }`
|
|
78
|
+
);
|
|
79
|
+
return fn(...Object.values(scope), (name: string) => reactive.readCaret(hostElement, name));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Stamp a static text block by replacing each `${...}` with the stringified
|
|
84
|
+
* result of `evalInterpolation`.
|
|
85
|
+
*/
|
|
86
|
+
function interpolate(source: string, scope: Record<string, unknown>, hostElement: Element): string {
|
|
87
|
+
return source.replace(/\$\{([^}]+)\}/g, (_, expr) => {
|
|
88
|
+
try {
|
|
89
|
+
const result = evalInterpolation(expr.trim(), scope, hostElement);
|
|
90
|
+
return result == null ? '' : String(result);
|
|
91
|
+
} catch {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Render a parsed template AST (with `#if`/`#for` directives) against a scope.
|
|
99
|
+
* Routes both `${...}` interpolation and bare-expression evaluation through
|
|
100
|
+
* the same caret-aware path so `^var` works uniformly.
|
|
101
|
+
*/
|
|
102
|
+
function renderAst(
|
|
103
|
+
nodes: TemplateNode[],
|
|
104
|
+
scope: Record<string, unknown>,
|
|
105
|
+
hostElement: Element
|
|
106
|
+
): string {
|
|
107
|
+
return renderTemplate(
|
|
108
|
+
nodes,
|
|
109
|
+
scope,
|
|
110
|
+
(text, s) => interpolate(text, s, hostElement),
|
|
111
|
+
(expr, s) => evalInterpolation(expr.trim(), s, hostElement)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Register a single `<template component="tag-name">` element.
|
|
117
|
+
* Idempotent: returns false (without error) if the tag is already registered.
|
|
118
|
+
*/
|
|
119
|
+
export function registerTemplateComponent(
|
|
120
|
+
templateEl: HTMLTemplateElement,
|
|
121
|
+
options: RegistryOptions = {}
|
|
122
|
+
): boolean {
|
|
123
|
+
const tagName = templateEl.getAttribute('component');
|
|
124
|
+
if (!tagName || !tagName.includes('-')) {
|
|
125
|
+
if (typeof console !== 'undefined') {
|
|
126
|
+
console.error(
|
|
127
|
+
`[@hyperfixi/components] <template component="${tagName}"> must contain a dash`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (REGISTERED.has(tagName) || customElements.get(tagName)) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
REGISTERED.add(tagName);
|
|
136
|
+
|
|
137
|
+
const { html: rawTemplateSource, init: initSource } = readTemplate(templateEl);
|
|
138
|
+
const runtime = options.runtime;
|
|
139
|
+
|
|
140
|
+
// Lift `<style>` blocks out of the template, scope them to this tag, and
|
|
141
|
+
// inject into <head>. The cleaned HTML (without the `<style>` blocks) is
|
|
142
|
+
// what each instance stamps. Idempotent — calling registerTemplateComponent
|
|
143
|
+
// a second time for the same tag is a no-op above; the style injection has
|
|
144
|
+
// its own data-component dedup so re-extraction is harmless either way.
|
|
145
|
+
const { html: templateSource, styles } = extractStyles(rawTemplateSource);
|
|
146
|
+
injectScopedStyles(tagName, styles);
|
|
147
|
+
|
|
148
|
+
// Parse the template AST once at registration time. Each instance reuses
|
|
149
|
+
// this AST and re-renders against its own scope.
|
|
150
|
+
const templateAst = parseTemplate(templateSource);
|
|
151
|
+
|
|
152
|
+
class TemplateComponent extends HTMLElement {
|
|
153
|
+
private _initialized = false;
|
|
154
|
+
private _cleanupFns: Array<() => void> = [];
|
|
155
|
+
|
|
156
|
+
connectedCallback() {
|
|
157
|
+
if (this._initialized) return;
|
|
158
|
+
this._initialized = true;
|
|
159
|
+
|
|
160
|
+
// Mark this instance as a `^var` isolation boundary so descendant
|
|
161
|
+
// caret-var reads/writes don't walk past it into the parent scope.
|
|
162
|
+
// Set BEFORE init runs so the init's own `set ^X to Y` writes land
|
|
163
|
+
// here (findCaretOwner stops at the boundary if no owner is found).
|
|
164
|
+
if (!this.hasAttribute('dom-scope')) {
|
|
165
|
+
this.setAttribute('dom-scope', 'isolated');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Capture slot content (innerHTML) BEFORE we stamp the template, so
|
|
169
|
+
// children written in the page get inserted into `<slot/>` placeholders.
|
|
170
|
+
const slotContent = this.innerHTML;
|
|
171
|
+
this.innerHTML = '';
|
|
172
|
+
|
|
173
|
+
const attrs = createAttrsProxy(this);
|
|
174
|
+
|
|
175
|
+
// Slot substitution happens on the source HTML before AST rendering, so
|
|
176
|
+
// <slot/> placeholders are replaced once per instance against the AST's
|
|
177
|
+
// serialized output. (Slots aren't reactive — content is stamped at
|
|
178
|
+
// first render and stays stable across `^var` re-renders.)
|
|
179
|
+
const renderBody = (): string => {
|
|
180
|
+
const rendered = renderAst(templateAst, { attrs }, this);
|
|
181
|
+
return substituteSlots(rendered, slotContent);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Run init `_=` once via hyperscript.eval. We do NOT put it on the host
|
|
185
|
+
// as an attribute — that would cause every subsequent reactive re-render
|
|
186
|
+
// (which calls hyperscript.process(this)) to re-run init and reset state.
|
|
187
|
+
//
|
|
188
|
+
// We build the context manually so we can pre-populate `attrs` as a
|
|
189
|
+
// local. That makes the upstream pattern `set ^user to attrs.data`
|
|
190
|
+
// work — `attrs` is then resolvable as a hyperscript identifier inside
|
|
191
|
+
// the init script. (Descendants' `_=` attributes go through the
|
|
192
|
+
// standard process path and don't see `attrs` — by design; if you need
|
|
193
|
+
// attrs values in descendants, copy them into `^vars` during init.)
|
|
194
|
+
if (initSource) {
|
|
195
|
+
try {
|
|
196
|
+
const initCtx = createContext(this);
|
|
197
|
+
initCtx.locals.set('attrs', attrs);
|
|
198
|
+
void hyperscript.eval(initSource, initCtx);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (typeof console !== 'undefined') {
|
|
201
|
+
console.error('[@hyperfixi/components] init script failed:', err);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Synchronous first render so callers see content immediately on
|
|
207
|
+
// appendChild — matches v1 semantics. Tracking-aware re-renders happen
|
|
208
|
+
// through the reactive effect below.
|
|
209
|
+
// Note: `hyperscript.process` is the singleton API entry point that
|
|
210
|
+
// compiles + binds `_=` attributes (the per-runtime `Runtime` class
|
|
211
|
+
// does not expose process directly).
|
|
212
|
+
const renderOnce = (): void => {
|
|
213
|
+
this.innerHTML = renderBody();
|
|
214
|
+
try {
|
|
215
|
+
hyperscript.process(this);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (typeof console !== 'undefined') {
|
|
218
|
+
console.error('[@hyperfixi/components] hyperscript.process failed:', err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
renderOnce();
|
|
223
|
+
|
|
224
|
+
// Reactive effect: any `^var` read during render is tracked, and
|
|
225
|
+
// writes to those vars trigger a re-stamp + re-process. Effect
|
|
226
|
+
// initializes via microtask; first run sees same content (Object.is
|
|
227
|
+
// skips handler) but records dependencies for subsequent writes.
|
|
228
|
+
const stopEffect = reactive.createEffect(() => renderBody(), renderOnce, this);
|
|
229
|
+
this._cleanupFns.push(stopEffect);
|
|
230
|
+
|
|
231
|
+
// Register cleanup so disconnect fires teardown of any hyperscript
|
|
232
|
+
// observers bound to children. Uses core's CleanupRegistry via the
|
|
233
|
+
// runtime passed at install time.
|
|
234
|
+
if (runtime) {
|
|
235
|
+
try {
|
|
236
|
+
runtime.getCleanupRegistry().registerCustom(
|
|
237
|
+
this,
|
|
238
|
+
() => {
|
|
239
|
+
for (const fn of this._cleanupFns) {
|
|
240
|
+
try {
|
|
241
|
+
fn();
|
|
242
|
+
} catch {
|
|
243
|
+
/* ignore */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
this._cleanupFns = [];
|
|
247
|
+
},
|
|
248
|
+
'template-component'
|
|
249
|
+
);
|
|
250
|
+
} catch {
|
|
251
|
+
/* getCleanupRegistry missing — skip */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
disconnectedCallback() {
|
|
257
|
+
this._initialized = false;
|
|
258
|
+
for (const fn of this._cleanupFns) {
|
|
259
|
+
try {
|
|
260
|
+
fn();
|
|
261
|
+
} catch {
|
|
262
|
+
/* ignore */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
this._cleanupFns = [];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
customElements.define(tagName, TemplateComponent);
|
|
271
|
+
return true;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
REGISTERED.delete(tagName);
|
|
274
|
+
if (typeof console !== 'undefined') {
|
|
275
|
+
console.error(`[@hyperfixi/components] customElements.define("${tagName}") failed:`, err);
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Read a template definition: extract the body HTML and (if present) the
|
|
283
|
+
* per-instance init script. Init source is read from `_=` (preferred) or
|
|
284
|
+
* `data-init` (the `<script>`-form converter sets this in scan.ts).
|
|
285
|
+
*/
|
|
286
|
+
function readTemplate(templateEl: HTMLTemplateElement): { html: string; init: string | null } {
|
|
287
|
+
const init = templateEl.getAttribute('_') ?? templateEl.getAttribute('data-init');
|
|
288
|
+
let html: string;
|
|
289
|
+
if (templateEl.content && typeof templateEl.content.childNodes !== 'undefined') {
|
|
290
|
+
const container = document.createElement('div');
|
|
291
|
+
for (const child of Array.from(templateEl.content.childNodes)) {
|
|
292
|
+
container.appendChild(child.cloneNode(true));
|
|
293
|
+
}
|
|
294
|
+
html = container.innerHTML;
|
|
295
|
+
} else {
|
|
296
|
+
html = templateEl.innerHTML;
|
|
297
|
+
}
|
|
298
|
+
return { html, init };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Clear the registered-tags set. Intended for test isolation only — custom
|
|
303
|
+
* element registrations cannot be un-defined, so this only affects our
|
|
304
|
+
* idempotency check, not the real registry.
|
|
305
|
+
*/
|
|
306
|
+
export function _resetRegisteredForTest(): void {
|
|
307
|
+
REGISTERED.clear();
|
|
308
|
+
}
|
package/src/scan.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM scanner — finds `<template component="tag-name">` elements and
|
|
3
|
+
* registers them. Also supports `<script type="text/hyperscript-template"
|
|
4
|
+
* component="tag-name">` for upstream compatibility.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RuntimeLike } from './types';
|
|
8
|
+
import { registerTemplateComponent } from './register';
|
|
9
|
+
|
|
10
|
+
interface ScanOptions {
|
|
11
|
+
runtime?: RuntimeLike;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Scan the given root (defaults to `document`) for template definitions and
|
|
16
|
+
* register each as a custom element.
|
|
17
|
+
*
|
|
18
|
+
* Returns the number of new registrations performed.
|
|
19
|
+
*/
|
|
20
|
+
export function scanAndRegister(
|
|
21
|
+
root: ParentNode = typeof document !== 'undefined' ? document : (null as never),
|
|
22
|
+
options: ScanOptions = {}
|
|
23
|
+
): number {
|
|
24
|
+
if (!root) return 0;
|
|
25
|
+
let count = 0;
|
|
26
|
+
|
|
27
|
+
// <template component="tag-name">
|
|
28
|
+
const templates = root.querySelectorAll('template[component]');
|
|
29
|
+
templates.forEach(t => {
|
|
30
|
+
if (registerTemplateComponent(t as HTMLTemplateElement, options)) count++;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// <script type="text/hyperscript-template" component="tag-name" _="init script">
|
|
34
|
+
// Upstream uses this form; we support it for compat. Convert to a
|
|
35
|
+
// synthetic HTMLTemplateElement so register code is shared. Init scripts
|
|
36
|
+
// (`_=`) are preserved so they run once per instance.
|
|
37
|
+
const scripts = root.querySelectorAll('script[type="text/hyperscript-template"][component]');
|
|
38
|
+
scripts.forEach(s => {
|
|
39
|
+
const fake = document.createElement('template');
|
|
40
|
+
const componentAttr = s.getAttribute('component');
|
|
41
|
+
if (componentAttr) fake.setAttribute('component', componentAttr);
|
|
42
|
+
const initScript = s.getAttribute('_');
|
|
43
|
+
if (initScript) fake.setAttribute('data-init', initScript);
|
|
44
|
+
fake.innerHTML = s.textContent ?? '';
|
|
45
|
+
if (registerTemplateComponent(fake, options)) count++;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return count;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start watching the document for dynamically-added template definitions.
|
|
53
|
+
* Returns a disposer that stops the observer.
|
|
54
|
+
*
|
|
55
|
+
* Safe to call in non-DOM environments (returns a no-op disposer).
|
|
56
|
+
*/
|
|
57
|
+
export function watchForTemplates(options: ScanOptions = {}): () => void {
|
|
58
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
|
|
59
|
+
return () => {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const observer = new MutationObserver(mutations => {
|
|
63
|
+
for (const mut of mutations) {
|
|
64
|
+
for (const node of Array.from(mut.addedNodes)) {
|
|
65
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
66
|
+
const el = node as Element;
|
|
67
|
+
// Added node itself
|
|
68
|
+
if (el.tagName === 'TEMPLATE' && el.hasAttribute('component')) {
|
|
69
|
+
registerTemplateComponent(el as HTMLTemplateElement, options);
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
el.tagName === 'SCRIPT' &&
|
|
73
|
+
el.getAttribute('type') === 'text/hyperscript-template' &&
|
|
74
|
+
el.hasAttribute('component')
|
|
75
|
+
) {
|
|
76
|
+
const fake = document.createElement('template');
|
|
77
|
+
const componentAttr = el.getAttribute('component');
|
|
78
|
+
if (componentAttr) fake.setAttribute('component', componentAttr);
|
|
79
|
+
const initScript = el.getAttribute('_');
|
|
80
|
+
if (initScript) fake.setAttribute('data-init', initScript);
|
|
81
|
+
fake.innerHTML = el.textContent ?? '';
|
|
82
|
+
registerTemplateComponent(fake, options);
|
|
83
|
+
}
|
|
84
|
+
// Descendants
|
|
85
|
+
scanAndRegister(el, options);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
observer.observe(document.documentElement || document.body, {
|
|
91
|
+
childList: true,
|
|
92
|
+
subtree: true,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return () => observer.disconnect();
|
|
96
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { extractStyles, injectScopedStyles, _resetInjectedStylesForTest } from './scope-css';
|
|
3
|
+
|
|
4
|
+
describe('extractStyles', () => {
|
|
5
|
+
it('returns the input unchanged when there are no <style> blocks', () => {
|
|
6
|
+
const r = extractStyles('<div>hello</div>');
|
|
7
|
+
expect(r.html).toBe('<div>hello</div>');
|
|
8
|
+
expect(r.styles).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('strips a single <style> block and returns its body', () => {
|
|
12
|
+
const r = extractStyles('<div>hi</div><style>.x { color: red; }</style><p>p</p>');
|
|
13
|
+
expect(r.html).toBe('<div>hi</div><p>p</p>');
|
|
14
|
+
expect(r.styles).toEqual(['.x { color: red; }']);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('strips multiple <style> blocks and returns them in order', () => {
|
|
18
|
+
const r = extractStyles('<style>a{}</style>mid<style>b{}</style>');
|
|
19
|
+
expect(r.html).toBe('mid');
|
|
20
|
+
expect(r.styles).toEqual(['a{}', 'b{}']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('handles attributes on the <style> tag', () => {
|
|
24
|
+
const r = extractStyles('<style type="text/css">.x{}</style>');
|
|
25
|
+
expect(r.styles).toEqual(['.x{}']);
|
|
26
|
+
expect(r.html).toBe('');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('preserves the inner whitespace of style content', () => {
|
|
30
|
+
const css = '\n .btn { padding: 4px; }\n';
|
|
31
|
+
const r = extractStyles(`before<style>${css}</style>after`);
|
|
32
|
+
expect(r.styles).toEqual([css]);
|
|
33
|
+
expect(r.html).toBe('beforeafter');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('injectScopedStyles', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
_resetInjectedStylesForTest();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does nothing when given no styles', () => {
|
|
43
|
+
const before = document.head.querySelectorAll('style[data-component]').length;
|
|
44
|
+
expect(injectScopedStyles('my-tag', [])).toBe(false);
|
|
45
|
+
expect(document.head.querySelectorAll('style[data-component]').length).toBe(before);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('appends a single <style data-component> wrapped in @scope', () => {
|
|
49
|
+
expect(injectScopedStyles('my-tag', ['.btn { color: red; }'])).toBe(true);
|
|
50
|
+
const el = document.head.querySelector('style[data-component="my-tag"]');
|
|
51
|
+
expect(el).toBeTruthy();
|
|
52
|
+
expect(el!.textContent).toContain('@scope (my-tag)');
|
|
53
|
+
expect(el!.textContent).toContain('.btn { color: red; }');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('joins multiple style blocks into one element with separate @scope rules', () => {
|
|
57
|
+
expect(injectScopedStyles('my-card', ['.a {}', '.b {}'])).toBe(true);
|
|
58
|
+
const el = document.head.querySelector('style[data-component="my-card"]');
|
|
59
|
+
expect(el).toBeTruthy();
|
|
60
|
+
const text = el!.textContent ?? '';
|
|
61
|
+
expect(text.match(/@scope \(my-card\)/g)?.length).toBe(2);
|
|
62
|
+
expect(text).toContain('.a {}');
|
|
63
|
+
expect(text).toContain('.b {}');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('is idempotent for the same tag name', () => {
|
|
67
|
+
expect(injectScopedStyles('dedup-tag', ['.x {}'])).toBe(true);
|
|
68
|
+
expect(injectScopedStyles('dedup-tag', ['.x {}'])).toBe(false);
|
|
69
|
+
expect(injectScopedStyles('dedup-tag', ['.y {}'])).toBe(false);
|
|
70
|
+
const matches = document.head.querySelectorAll('style[data-component="dedup-tag"]');
|
|
71
|
+
expect(matches.length).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps distinct components in distinct <style> elements', () => {
|
|
75
|
+
expect(injectScopedStyles('comp-a', ['.a {}'])).toBe(true);
|
|
76
|
+
expect(injectScopedStyles('comp-b', ['.b {}'])).toBe(true);
|
|
77
|
+
expect(document.head.querySelector('style[data-component="comp-a"]')).toBeTruthy();
|
|
78
|
+
expect(document.head.querySelector('style[data-component="comp-b"]')).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/scope-css.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped CSS — lift `<style>` blocks out of component templates and inject
|
|
3
|
+
* them into `<head>` wrapped in `@scope (tag-name) { ... }`.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors upstream _hyperscript 0.9.91's component style-scoping behavior.
|
|
6
|
+
* Two reasons we do this:
|
|
7
|
+
* 1. Without lifting, every instance's innerHTML would contain a copy of
|
|
8
|
+
* the `<style>` block. The browser parses each one — wasteful and a
|
|
9
|
+
* footgun if the user uses non-`@scope` selectors.
|
|
10
|
+
* 2. Wrapping the contents in `@scope (tag-name) { ... }` confines them
|
|
11
|
+
* to that custom-element tree, so styles authored against a generic
|
|
12
|
+
* `.btn` class don't leak globally.
|
|
13
|
+
*
|
|
14
|
+
* Browser support: `@scope` is in Chrome 118+, Safari 17.4+, Firefox 128+.
|
|
15
|
+
* In older browsers, the `@scope` rule is ignored and styles leak globally
|
|
16
|
+
* — graceful degradation; nothing actively breaks.
|
|
17
|
+
*
|
|
18
|
+
* Idempotency: the same component may be (re)scanned multiple times via
|
|
19
|
+
* `componentsPlugin.scan()` followed by `watchForTemplates()`. We dedupe by
|
|
20
|
+
* a `data-component="${tagName}"` attribute on the injected `<style>`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const STYLE_BLOCK_RE = /<style(?:\s[^>]*)?>([\s\S]*?)<\/style\s*>/gi;
|
|
24
|
+
|
|
25
|
+
export interface ExtractResult {
|
|
26
|
+
/** The HTML with all `<style>` blocks removed. */
|
|
27
|
+
html: string;
|
|
28
|
+
/** The raw text content of each removed `<style>` block, in document order. */
|
|
29
|
+
styles: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Strip `<style>...</style>` blocks from `html` and return their text content.
|
|
34
|
+
* Preserves the surrounding HTML otherwise. Tag-attributes on `<style>` are
|
|
35
|
+
* dropped (we re-build the injected element's attributes ourselves).
|
|
36
|
+
*/
|
|
37
|
+
export function extractStyles(html: string): ExtractResult {
|
|
38
|
+
const styles: string[] = [];
|
|
39
|
+
const cleaned = html.replace(STYLE_BLOCK_RE, (_match, body: string) => {
|
|
40
|
+
styles.push(body);
|
|
41
|
+
return '';
|
|
42
|
+
});
|
|
43
|
+
return { html: cleaned, styles };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Inject the given style blocks into `document.head` as a single
|
|
48
|
+
* `<style data-component="${tagName}">` element wrapped in `@scope`. No-op if
|
|
49
|
+
* `styles` is empty or if the injection has already been done for this tag.
|
|
50
|
+
*
|
|
51
|
+
* Returns `true` if injection happened, `false` if it was skipped (already
|
|
52
|
+
* present, no styles, or no document/head available).
|
|
53
|
+
*/
|
|
54
|
+
export function injectScopedStyles(tagName: string, styles: string[]): boolean {
|
|
55
|
+
if (styles.length === 0) return false;
|
|
56
|
+
if (typeof document === 'undefined' || !document.head) return false;
|
|
57
|
+
|
|
58
|
+
const selector = `style[data-component="${cssAttrEscape(tagName)}"]`;
|
|
59
|
+
if (document.head.querySelector(selector)) return false;
|
|
60
|
+
|
|
61
|
+
const wrapped = styles.map(body => `@scope (${tagName}) {\n${body}\n}`).join('\n\n');
|
|
62
|
+
const styleEl = document.createElement('style');
|
|
63
|
+
styleEl.setAttribute('data-component', tagName);
|
|
64
|
+
styleEl.textContent = wrapped;
|
|
65
|
+
document.head.appendChild(styleEl);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Test-only helper: remove any styles previously injected by
|
|
71
|
+
* `injectScopedStyles`. Real usage doesn't need this — once a component is
|
|
72
|
+
* registered, its scoped styles persist for the page's lifetime.
|
|
73
|
+
*/
|
|
74
|
+
export function _resetInjectedStylesForTest(): void {
|
|
75
|
+
if (typeof document === 'undefined' || !document.head) return;
|
|
76
|
+
const injected = document.head.querySelectorAll('style[data-component]');
|
|
77
|
+
injected.forEach(el => el.parentNode?.removeChild(el));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Escape `tagName` for safe use inside a CSS attribute-selector string.
|
|
82
|
+
* Custom-element tag names are restricted by the spec (lowercase ASCII +
|
|
83
|
+
* digit + hyphen + colon + dot + underscore), so this is mostly defensive.
|
|
84
|
+
*/
|
|
85
|
+
function cssAttrEscape(s: string): string {
|
|
86
|
+
return s.replace(/["\\]/g, '\\$&');
|
|
87
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot substitution unit tests.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { substituteSlots } from './slots';
|
|
7
|
+
|
|
8
|
+
describe('substituteSlots', () => {
|
|
9
|
+
it('returns the template unchanged when slot content is empty', () => {
|
|
10
|
+
const tmpl = '<div><slot/></div>';
|
|
11
|
+
expect(substituteSlots(tmpl, '')).toBe(tmpl);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('replaces <slot/> with default content', () => {
|
|
15
|
+
const result = substituteSlots('<div><slot/></div>', '<span>hi</span>');
|
|
16
|
+
expect(result).toContain('<span>hi</span>');
|
|
17
|
+
expect(result).not.toContain('<slot');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('replaces <slot></slot> (explicit close) with default content', () => {
|
|
21
|
+
const result = substituteSlots('<div><slot></slot></div>', 'plain text');
|
|
22
|
+
expect(result).toContain('plain text');
|
|
23
|
+
expect(result).not.toContain('<slot');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('replaces named slots with matching content', () => {
|
|
27
|
+
const tmpl = '<header><slot name="title"/></header><main><slot/></main>';
|
|
28
|
+
const content = '<h1 slot="title">Hello</h1><p>Body</p>';
|
|
29
|
+
const result = substituteSlots(tmpl, content);
|
|
30
|
+
expect(result).toContain('<h1>Hello</h1>'); // slot="title" stripped
|
|
31
|
+
expect(result).toContain('<p>Body</p>');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('omits named-slot output when no matching content provided', () => {
|
|
35
|
+
const tmpl = '<header><slot name="title"/></header><slot/>';
|
|
36
|
+
const content = '<p>Body</p>'; // no slot="title" child
|
|
37
|
+
const result = substituteSlots(tmpl, content);
|
|
38
|
+
expect(result).not.toContain('slot'); // both placeholders removed
|
|
39
|
+
expect(result).toContain('<p>Body</p>');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('handles multiple named slots', () => {
|
|
43
|
+
const tmpl = '<div><slot name="a"/></div><div><slot name="b"/></div>';
|
|
44
|
+
const content = '<x slot="a">A</x><y slot="b">B</y>';
|
|
45
|
+
const result = substituteSlots(tmpl, content);
|
|
46
|
+
expect(result).toContain('<x>A</x>');
|
|
47
|
+
expect(result).toContain('<y>B</y>');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('preserves text nodes in default content', () => {
|
|
51
|
+
const tmpl = '<div><slot/></div>';
|
|
52
|
+
const result = substituteSlots(tmpl, 'plain words');
|
|
53
|
+
expect(result).toContain('plain words');
|
|
54
|
+
});
|
|
55
|
+
});
|