@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/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
import type { RuntimeLike } from './types';
|
|
7
|
+
interface ScanOptions {
|
|
8
|
+
runtime?: RuntimeLike;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Scan the given root (defaults to `document`) for template definitions and
|
|
12
|
+
* register each as a custom element.
|
|
13
|
+
*
|
|
14
|
+
* Returns the number of new registrations performed.
|
|
15
|
+
*/
|
|
16
|
+
export declare function scanAndRegister(root?: ParentNode, options?: ScanOptions): number;
|
|
17
|
+
/**
|
|
18
|
+
* Start watching the document for dynamically-added template definitions.
|
|
19
|
+
* Returns a disposer that stops the observer.
|
|
20
|
+
*
|
|
21
|
+
* Safe to call in non-DOM environments (returns a no-op disposer).
|
|
22
|
+
*/
|
|
23
|
+
export declare function watchForTemplates(options?: ScanOptions): () => void;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
export interface ExtractResult {
|
|
23
|
+
/** The HTML with all `<style>` blocks removed. */
|
|
24
|
+
html: string;
|
|
25
|
+
/** The raw text content of each removed `<style>` block, in document order. */
|
|
26
|
+
styles: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Strip `<style>...</style>` blocks from `html` and return their text content.
|
|
30
|
+
* Preserves the surrounding HTML otherwise. Tag-attributes on `<style>` are
|
|
31
|
+
* dropped (we re-build the injected element's attributes ourselves).
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractStyles(html: string): ExtractResult;
|
|
34
|
+
/**
|
|
35
|
+
* Inject the given style blocks into `document.head` as a single
|
|
36
|
+
* `<style data-component="${tagName}">` element wrapped in `@scope`. No-op if
|
|
37
|
+
* `styles` is empty or if the injection has already been done for this tag.
|
|
38
|
+
*
|
|
39
|
+
* Returns `true` if injection happened, `false` if it was skipped (already
|
|
40
|
+
* present, no styles, or no document/head available).
|
|
41
|
+
*/
|
|
42
|
+
export declare function injectScopedStyles(tagName: string, styles: string[]): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Test-only helper: remove any styles previously injected by
|
|
45
|
+
* `injectScopedStyles`. Real usage doesn't need this — once a component is
|
|
46
|
+
* registered, its scoped styles persist for the page's lifetime.
|
|
47
|
+
*/
|
|
48
|
+
export declare function _resetInjectedStylesForTest(): void;
|
package/dist/slots.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot substitution — replace `<slot/>` and `<slot name="X"/>` placeholders
|
|
3
|
+
* in a template source string with content provided at instantiation.
|
|
4
|
+
*
|
|
5
|
+
* Port of upstream _hyperscript 0.9.91's `substituteSlots` (src/ext/component.js).
|
|
6
|
+
* Regex-based rather than DOM-based so the template source stays a plain string
|
|
7
|
+
* the render engine can consume.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Replace `<slot name="X"/>` / `<slot name="X"></slot>` with named content,
|
|
11
|
+
* and `<slot/>` / `<slot></slot>` with default content.
|
|
12
|
+
*
|
|
13
|
+
* Returns the template source with all `<slot>` placeholders substituted.
|
|
14
|
+
*/
|
|
15
|
+
export declare function substituteSlots(templateSource: string, slotContent: string): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template AST — parse + render with `#if`/`#else`/`#end`/`#for` directives.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors upstream _hyperscript 0.9.91's component template syntax. Directives
|
|
5
|
+
* are line-oriented: each directive must occupy its own line (modulo
|
|
6
|
+
* surrounding whitespace). `${...}` interpolation can appear anywhere in
|
|
7
|
+
* static text; it's evaluated by the same path used outside directives.
|
|
8
|
+
*
|
|
9
|
+
* Supported:
|
|
10
|
+
* #if <expr> ... [#else] ... #end
|
|
11
|
+
* #for <name> in <expr> ... [#else] ... #end (#else fires on empty)
|
|
12
|
+
*
|
|
13
|
+
* Deferred (v2.1+):
|
|
14
|
+
* #continue — skip the current iteration of the enclosing `#for`
|
|
15
|
+
*/
|
|
16
|
+
export type TemplateNode = {
|
|
17
|
+
kind: 'text';
|
|
18
|
+
content: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'if';
|
|
21
|
+
cond: string;
|
|
22
|
+
then: TemplateNode[];
|
|
23
|
+
else: TemplateNode[];
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'for';
|
|
26
|
+
varName: string;
|
|
27
|
+
iterableExpr: string;
|
|
28
|
+
body: TemplateNode[];
|
|
29
|
+
elseEmpty: TemplateNode[];
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Parse a template body into a sequence of TemplateNodes.
|
|
33
|
+
* Directives are recognized only when they occupy their own line.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseTemplate(source: string): TemplateNode[];
|
|
36
|
+
/**
|
|
37
|
+
* Render a parsed template AST against a scope. `interpText` handles `${...}`
|
|
38
|
+
* interpolation in static blocks; `evalExpr` evaluates the bare expression in
|
|
39
|
+
* `#if <expr>` and `#for <var> in <expr>`. Both are passed in so the renderer
|
|
40
|
+
* stays decoupled from the components plugin's host-element / caret-var
|
|
41
|
+
* specifics — each callsite supplies the appropriate evaluator closure.
|
|
42
|
+
*/
|
|
43
|
+
export declare function renderTemplate(nodes: TemplateNode[], scope: Record<string, unknown>, interpText: (text: string, scope: Record<string, unknown>) => string, evalExpr: (expr: string, scope: Record<string, unknown>) => unknown): string;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight local type stubs — mirror the speech/reactivity pattern of
|
|
3
|
+
* avoiding tight coupling to `@hyperfixi/core` internals.
|
|
4
|
+
*/
|
|
5
|
+
export interface RuntimeLike {
|
|
6
|
+
execute(node: unknown, context: unknown): Promise<unknown>;
|
|
7
|
+
getCleanupRegistry(): {
|
|
8
|
+
registerCustom(element: Element, cleanup: () => void, description?: string): void;
|
|
9
|
+
};
|
|
10
|
+
process?: (root: Element) => void | Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface ASTNode {
|
|
13
|
+
type: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
value?: unknown;
|
|
16
|
+
[k: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface ExecutionContext {
|
|
19
|
+
me?: Element | null;
|
|
20
|
+
result?: unknown;
|
|
21
|
+
it?: unknown;
|
|
22
|
+
globals?: Map<string, unknown>;
|
|
23
|
+
locals?: Map<string, unknown>;
|
|
24
|
+
[k: string]: unknown;
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyperfixi/components",
|
|
3
|
+
"version": "2.4.0",
|
|
4
|
+
"description": "Template-component plugin for hyperfixi — register custom elements from `<template component=\"tag-name\">` definitions (upstream _hyperscript 0.9.90).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "dist/index.cjs",
|
|
8
|
+
"module": "dist/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup && npm run build:types",
|
|
19
|
+
"build:types": "tsc --emitDeclarationOnly --outDir dist --noEmit false",
|
|
20
|
+
"test": "vitest",
|
|
21
|
+
"test:run": "vitest run",
|
|
22
|
+
"test:check": "vitest run --reporter=dot 2>&1 | tail -5",
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@hyperfixi/core": "*",
|
|
27
|
+
"@hyperfixi/reactivity": "*"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^20.0.0",
|
|
31
|
+
"happy-dom": "^20.9.0",
|
|
32
|
+
"tsup": "^8.0.0",
|
|
33
|
+
"typescript": "^5.0.0",
|
|
34
|
+
"vitest": "^4.1.5"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"keywords": [
|
|
42
|
+
"hyperfixi",
|
|
43
|
+
"hyperscript",
|
|
44
|
+
"components",
|
|
45
|
+
"custom-elements",
|
|
46
|
+
"template",
|
|
47
|
+
"plugin",
|
|
48
|
+
"_hyperscript",
|
|
49
|
+
"v0.9.90"
|
|
50
|
+
],
|
|
51
|
+
"author": "LokaScript Contributors",
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "git+https://github.com/codetalcott/hyperfixi.git",
|
|
56
|
+
"directory": "packages/components"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=18.0.0"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `attrs` proxy unit tests.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { createAttrsProxy } from './attrs';
|
|
7
|
+
|
|
8
|
+
describe('createAttrsProxy', () => {
|
|
9
|
+
it('reads a string attribute as-is', () => {
|
|
10
|
+
const el = document.createElement('my-comp');
|
|
11
|
+
el.setAttribute('label', 'Hello');
|
|
12
|
+
const attrs = createAttrsProxy(el);
|
|
13
|
+
expect(attrs.label).toBe('Hello');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('coerces numeric-looking attributes to numbers', () => {
|
|
17
|
+
const el = document.createElement('my-comp');
|
|
18
|
+
el.setAttribute('count', '42');
|
|
19
|
+
el.setAttribute('rate', '1.5');
|
|
20
|
+
el.setAttribute('neg', '-7');
|
|
21
|
+
const attrs = createAttrsProxy(el);
|
|
22
|
+
expect(attrs.count).toBe(42);
|
|
23
|
+
expect(attrs.rate).toBe(1.5);
|
|
24
|
+
expect(attrs.neg).toBe(-7);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('coerces "true" / "false" to booleans', () => {
|
|
28
|
+
const el = document.createElement('my-comp');
|
|
29
|
+
el.setAttribute('open', 'true');
|
|
30
|
+
el.setAttribute('closed', 'false');
|
|
31
|
+
const attrs = createAttrsProxy(el);
|
|
32
|
+
expect(attrs.open).toBe(true);
|
|
33
|
+
expect(attrs.closed).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('maps camelCase prop reads to kebab-case attributes', () => {
|
|
37
|
+
const el = document.createElement('my-comp');
|
|
38
|
+
el.setAttribute('initial-count', '5');
|
|
39
|
+
const attrs = createAttrsProxy(el);
|
|
40
|
+
expect(attrs.initialCount).toBe(5);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns undefined for missing attributes', () => {
|
|
44
|
+
const el = document.createElement('my-comp');
|
|
45
|
+
const attrs = createAttrsProxy(el);
|
|
46
|
+
expect(attrs.missing).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('writes back as kebab-case when prop is camelCase', () => {
|
|
50
|
+
const el = document.createElement('my-comp');
|
|
51
|
+
const attrs = createAttrsProxy(el);
|
|
52
|
+
attrs.initialCount = 10;
|
|
53
|
+
expect(el.getAttribute('initial-count')).toBe('10');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not coerce non-numeric strings that happen to start with digits', () => {
|
|
57
|
+
const el = document.createElement('my-comp');
|
|
58
|
+
el.setAttribute('id', '42px'); // common CSS-like value
|
|
59
|
+
const attrs = createAttrsProxy(el);
|
|
60
|
+
expect(attrs.id).toBe('42px'); // unchanged
|
|
61
|
+
});
|
|
62
|
+
});
|
package/src/attrs.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `attrs` proxy — read component attributes as values on the component scope.
|
|
3
|
+
*
|
|
4
|
+
* Simplified from upstream's hyperscript-expression-evaluating version: for v1
|
|
5
|
+
* we treat attributes as plain strings with a few type coercions (number, bool)
|
|
6
|
+
* and a kebab-case-to-camelCase accessor.
|
|
7
|
+
*
|
|
8
|
+
* `<my-counter initial-count="5">` exposes `attrs.initialCount` = 5 (number).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Coerce attribute string to a typed value.
|
|
13
|
+
* Rules:
|
|
14
|
+
* - "true" → true
|
|
15
|
+
* - "false" → false
|
|
16
|
+
* - number-like strings (e.g. "5", "-3.14") → number
|
|
17
|
+
* - everything else → original string
|
|
18
|
+
*/
|
|
19
|
+
function coerceAttrValue(raw: string | null): unknown {
|
|
20
|
+
if (raw == null) return undefined;
|
|
21
|
+
if (raw === 'true') return true;
|
|
22
|
+
if (raw === 'false') return false;
|
|
23
|
+
// Number check: not empty, fully numeric after Number()
|
|
24
|
+
if (raw !== '' && !Number.isNaN(Number(raw)) && /^-?(\d+\.?\d*|\.\d+)$/.test(raw)) {
|
|
25
|
+
return Number(raw);
|
|
26
|
+
}
|
|
27
|
+
return raw;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Convert camelCase prop name to kebab-case attribute name. Used so that
|
|
32
|
+
* reading `attrs.initialCount` finds the `initial-count="..."` attribute.
|
|
33
|
+
*/
|
|
34
|
+
function camelToKebab(name: string): string {
|
|
35
|
+
return name.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a proxy over a component element's attributes. Getting a property
|
|
40
|
+
* reads and coerces the matching kebab-case attribute. Setting a property
|
|
41
|
+
* writes it back as a string (simple stringification for v1).
|
|
42
|
+
*/
|
|
43
|
+
export function createAttrsProxy(componentEl: Element): Record<string, unknown> {
|
|
44
|
+
return new Proxy({} as Record<string, unknown>, {
|
|
45
|
+
get(_target, prop) {
|
|
46
|
+
if (typeof prop !== 'string' || prop.startsWith('_')) return undefined;
|
|
47
|
+
// Try exact match first (e.g. "role", "data-x"), then kebab-cased form.
|
|
48
|
+
const exact = componentEl.getAttribute(prop);
|
|
49
|
+
if (exact != null) return coerceAttrValue(exact);
|
|
50
|
+
const kebab = componentEl.getAttribute(camelToKebab(prop));
|
|
51
|
+
if (kebab != null) return coerceAttrValue(kebab);
|
|
52
|
+
return undefined;
|
|
53
|
+
},
|
|
54
|
+
set(_target, prop, value) {
|
|
55
|
+
if (typeof prop !== 'string') return false;
|
|
56
|
+
const attrName = componentEl.hasAttribute(prop) ? prop : camelToKebab(prop);
|
|
57
|
+
componentEl.setAttribute(attrName, value == null ? '' : String(value));
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
has(_target, prop) {
|
|
61
|
+
if (typeof prop !== 'string') return false;
|
|
62
|
+
return componentEl.hasAttribute(prop) || componentEl.hasAttribute(camelToKebab(prop));
|
|
63
|
+
},
|
|
64
|
+
ownKeys(_target) {
|
|
65
|
+
return Array.from(componentEl.attributes).map(a => a.name);
|
|
66
|
+
},
|
|
67
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
68
|
+
if (typeof prop !== 'string') return undefined;
|
|
69
|
+
const kebab = camelToKebab(prop);
|
|
70
|
+
if (componentEl.hasAttribute(prop) || componentEl.hasAttribute(kebab)) {
|
|
71
|
+
return {
|
|
72
|
+
enumerable: true,
|
|
73
|
+
configurable: true,
|
|
74
|
+
value: coerceAttrValue(componentEl.getAttribute(prop) ?? componentEl.getAttribute(kebab)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hyperfixi/components — template-component plugin for hyperfixi.
|
|
3
|
+
*
|
|
4
|
+
* Registers custom elements from `<template component="tag-name">` definitions
|
|
5
|
+
* (mirrors upstream _hyperscript 0.9.91's component extension). Users write:
|
|
6
|
+
*
|
|
7
|
+
* ```html
|
|
8
|
+
* <template component="my-counter">
|
|
9
|
+
* <button>Count: ${attrs.initialCount ?? 0}</button>
|
|
10
|
+
* </template>
|
|
11
|
+
*
|
|
12
|
+
* <my-counter initial-count="5"></my-counter>
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Install at app startup:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { createRuntime, installPlugin } from '@hyperfixi/core';
|
|
19
|
+
* import { componentsPlugin } from '@hyperfixi/components';
|
|
20
|
+
*
|
|
21
|
+
* const runtime = createRuntime();
|
|
22
|
+
* installPlugin(runtime, componentsPlugin);
|
|
23
|
+
* // Then scan (typically after DOMContentLoaded):
|
|
24
|
+
* componentsPlugin.scan(document);
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* v2.1 scope:
|
|
28
|
+
* - `<template component="tag-name">` scan + customElements.define
|
|
29
|
+
* - `${attrs.name}` and `${^var}` interpolation (kebab-case attribute →
|
|
30
|
+
* camelCase prop, with Number/Boolean coercion)
|
|
31
|
+
* - `^var` reads tracked via @hyperfixi/reactivity; the template re-stamps
|
|
32
|
+
* when any tracked `^var` changes
|
|
33
|
+
* - Per-instance init script — `<template _="set ^count to 0">` (or `_=`
|
|
34
|
+
* on the upstream `<script type="text/hyperscript-template">` form) runs
|
|
35
|
+
* once on each instance via the runtime's standard init mechanism
|
|
36
|
+
* - `attrs` available as a hyperscript local inside the init script — so
|
|
37
|
+
* `_="set ^user to attrs.data as JSON"` works (descendants don't see
|
|
38
|
+
* attrs; copy via ^vars during init if needed)
|
|
39
|
+
* - `<slot/>` + `<slot name="X"/>` substitution from instantiation children
|
|
40
|
+
* - `#if` / `#for` / `#else` / `#end` template directives
|
|
41
|
+
* - `dom-scope="isolated"` boundary auto-set on each instance — nested
|
|
42
|
+
* components don't leak `^var` reads/writes through each other
|
|
43
|
+
* - `<style>` blocks lifted into <head> wrapped in `@scope (tag-name)` so
|
|
44
|
+
* styles only apply within instances of that tag
|
|
45
|
+
* - disconnectedCallback fires CleanupRegistry teardown
|
|
46
|
+
* - MutationObserver watches for dynamically-added templates
|
|
47
|
+
*
|
|
48
|
+
* v2.2+ deferred:
|
|
49
|
+
* - `#continue` directive in `#for` loops
|
|
50
|
+
* - Reactive array mutation auto-tracking (matches upstream's known limit)
|
|
51
|
+
* - Full parent-scope hyperscript evaluation of attribute values (today
|
|
52
|
+
* `attrs.X` returns the raw string; users `as JSON` to parse)
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import type { HyperfixiPlugin, HyperfixiPluginContext } from '@hyperfixi/core';
|
|
56
|
+
import type { RuntimeLike } from './types';
|
|
57
|
+
import { scanAndRegister, watchForTemplates } from './scan';
|
|
58
|
+
import { registerTemplateComponent } from './register';
|
|
59
|
+
|
|
60
|
+
export { registerTemplateComponent, scanAndRegister, watchForTemplates };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Module-level handle to the runtime captured at install time. Used by `scan`
|
|
64
|
+
* and `watch` to thread the runtime into each registered component for
|
|
65
|
+
* cleanup-registry access and child-processing.
|
|
66
|
+
*/
|
|
67
|
+
let INSTALLED_RUNTIME: RuntimeLike | null = null;
|
|
68
|
+
let STOP_WATCH: (() => void) | null = null;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Plugin object. `install()` captures the runtime; `scan()` / `watch()` can
|
|
72
|
+
* be called any time after install.
|
|
73
|
+
*/
|
|
74
|
+
export const componentsPlugin: HyperfixiPlugin & {
|
|
75
|
+
scan(root?: ParentNode): number;
|
|
76
|
+
watch(): () => void;
|
|
77
|
+
unwatch(): void;
|
|
78
|
+
} = {
|
|
79
|
+
name: '@hyperfixi/components',
|
|
80
|
+
install(ctx: HyperfixiPluginContext) {
|
|
81
|
+
INSTALLED_RUNTIME = ctx.runtime as unknown as RuntimeLike;
|
|
82
|
+
},
|
|
83
|
+
/**
|
|
84
|
+
* Scan `root` (defaults to `document`) for template components and register
|
|
85
|
+
* each. Safe to call before or after install (but install is needed for
|
|
86
|
+
* cleanup-registry hookup).
|
|
87
|
+
*/
|
|
88
|
+
scan(root?: ParentNode): number {
|
|
89
|
+
return scanAndRegister(root, { runtime: INSTALLED_RUNTIME ?? undefined });
|
|
90
|
+
},
|
|
91
|
+
/**
|
|
92
|
+
* Start watching the document for dynamically-added template components.
|
|
93
|
+
* Idempotent — calling twice is a no-op on the second call.
|
|
94
|
+
* Returns a disposer that also clears the module-level handle.
|
|
95
|
+
*/
|
|
96
|
+
watch(): () => void {
|
|
97
|
+
if (STOP_WATCH) return STOP_WATCH;
|
|
98
|
+
const stop = watchForTemplates({ runtime: INSTALLED_RUNTIME ?? undefined });
|
|
99
|
+
STOP_WATCH = () => {
|
|
100
|
+
stop();
|
|
101
|
+
STOP_WATCH = null;
|
|
102
|
+
};
|
|
103
|
+
return STOP_WATCH;
|
|
104
|
+
},
|
|
105
|
+
unwatch(): void {
|
|
106
|
+
if (STOP_WATCH) STOP_WATCH();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export default componentsPlugin;
|