@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/slots.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* Partition raw slot content into named and default parts.
|
|
12
|
+
* Named = elements with a `slot="name"` attribute.
|
|
13
|
+
* Default = all other children (elements or text).
|
|
14
|
+
*/
|
|
15
|
+
function partitionSlotContent(slotContent: string): {
|
|
16
|
+
named: Record<string, string>;
|
|
17
|
+
defaultContent: string;
|
|
18
|
+
} {
|
|
19
|
+
const named: Record<string, string> = {};
|
|
20
|
+
const defaultParts: string[] = [];
|
|
21
|
+
|
|
22
|
+
if (typeof document === 'undefined') {
|
|
23
|
+
// Non-browser fallback: treat everything as default content.
|
|
24
|
+
return { named, defaultContent: slotContent };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tmp = document.createElement('div');
|
|
28
|
+
tmp.innerHTML = slotContent;
|
|
29
|
+
|
|
30
|
+
for (const child of Array.from(tmp.childNodes)) {
|
|
31
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
32
|
+
const el = child as Element;
|
|
33
|
+
const slotName = el.getAttribute('slot');
|
|
34
|
+
if (slotName) {
|
|
35
|
+
el.removeAttribute('slot');
|
|
36
|
+
if (!named[slotName]) named[slotName] = '';
|
|
37
|
+
named[slotName] += (el as Element & { outerHTML: string }).outerHTML;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
defaultParts.push((el as Element & { outerHTML: string }).outerHTML);
|
|
41
|
+
} else if (child.nodeType === Node.TEXT_NODE) {
|
|
42
|
+
defaultParts.push(child.textContent ?? '');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { named, defaultContent: defaultParts.join('') };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Replace `<slot name="X"/>` / `<slot name="X"></slot>` with named content,
|
|
51
|
+
* and `<slot/>` / `<slot></slot>` with default content.
|
|
52
|
+
*
|
|
53
|
+
* Returns the template source with all `<slot>` placeholders substituted.
|
|
54
|
+
*/
|
|
55
|
+
export function substituteSlots(templateSource: string, slotContent: string): string {
|
|
56
|
+
if (!slotContent) return templateSource;
|
|
57
|
+
const { named, defaultContent } = partitionSlotContent(slotContent);
|
|
58
|
+
|
|
59
|
+
// Named slots first: <slot name="X"/> or <slot name="X"></slot>.
|
|
60
|
+
let source = templateSource.replace(
|
|
61
|
+
/<slot\s+name\s*=\s*["']([^"']+)["']\s*\/?\s*>(\s*<\/slot>)?/g,
|
|
62
|
+
(_match, name) => named[name as string] ?? ''
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Default slots: <slot/> or <slot></slot> (after named, so named-with-no-name
|
|
66
|
+
// attribute doesn't accidentally swallow default slot content).
|
|
67
|
+
source = source.replace(/<slot\s*\/?\s*>(\s*<\/slot>)?/g, defaultContent);
|
|
68
|
+
|
|
69
|
+
return source;
|
|
70
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseTemplate, renderTemplate } from './template-ast';
|
|
3
|
+
|
|
4
|
+
const noopInterp = (t: string): string => t;
|
|
5
|
+
|
|
6
|
+
describe('parseTemplate', () => {
|
|
7
|
+
it('parses plain text as a single text node', () => {
|
|
8
|
+
const ast = parseTemplate('hello world');
|
|
9
|
+
expect(ast).toEqual([{ kind: 'text', content: 'hello world' }]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('parses a simple #if/#end', () => {
|
|
13
|
+
const ast = parseTemplate('before\n#if cond\ninner\n#end\nafter');
|
|
14
|
+
expect(ast.length).toBe(3);
|
|
15
|
+
expect(ast[0]).toEqual({ kind: 'text', content: 'before' });
|
|
16
|
+
expect(ast[1].kind).toBe('if');
|
|
17
|
+
if (ast[1].kind === 'if') {
|
|
18
|
+
expect(ast[1].cond).toBe('cond');
|
|
19
|
+
expect(ast[1].then).toEqual([{ kind: 'text', content: 'inner' }]);
|
|
20
|
+
expect(ast[1].else).toEqual([]);
|
|
21
|
+
}
|
|
22
|
+
expect(ast[2]).toEqual({ kind: 'text', content: 'after' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('parses #if/#else/#end', () => {
|
|
26
|
+
const ast = parseTemplate('#if cond\nT\n#else\nF\n#end');
|
|
27
|
+
expect(ast.length).toBe(1);
|
|
28
|
+
expect(ast[0].kind).toBe('if');
|
|
29
|
+
if (ast[0].kind === 'if') {
|
|
30
|
+
expect(ast[0].then).toEqual([{ kind: 'text', content: 'T' }]);
|
|
31
|
+
expect(ast[0].else).toEqual([{ kind: 'text', content: 'F' }]);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('parses #for/#end', () => {
|
|
36
|
+
const ast = parseTemplate('#for item in items\nbody\n#end');
|
|
37
|
+
expect(ast.length).toBe(1);
|
|
38
|
+
expect(ast[0].kind).toBe('for');
|
|
39
|
+
if (ast[0].kind === 'for') {
|
|
40
|
+
expect(ast[0].varName).toBe('item');
|
|
41
|
+
expect(ast[0].iterableExpr).toBe('items');
|
|
42
|
+
expect(ast[0].body).toEqual([{ kind: 'text', content: 'body' }]);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('renderTemplate', () => {
|
|
48
|
+
it('renders text with interpolation passthrough', () => {
|
|
49
|
+
const ast = parseTemplate('hello world');
|
|
50
|
+
const out = renderTemplate(ast, {}, noopInterp, () => undefined);
|
|
51
|
+
expect(out).toBe('hello world');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('renders the then-branch when condition is truthy', () => {
|
|
55
|
+
const ast = parseTemplate('#if cond\nyes\n#else\nno\n#end');
|
|
56
|
+
const out = renderTemplate(ast, {}, noopInterp, expr => expr === 'cond');
|
|
57
|
+
expect(out).toBe('yes');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('renders the else-branch when condition is falsy', () => {
|
|
61
|
+
const ast = parseTemplate('#if cond\nyes\n#else\nno\n#end');
|
|
62
|
+
const out = renderTemplate(ast, {}, noopInterp, () => false);
|
|
63
|
+
expect(out).toBe('no');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('renders #for body once per item', () => {
|
|
67
|
+
const ast = parseTemplate('#for item in items\n${item}\n#end');
|
|
68
|
+
const out = renderTemplate(
|
|
69
|
+
ast,
|
|
70
|
+
{},
|
|
71
|
+
(t, s) => t.replace('${item}', String(s.item ?? '')),
|
|
72
|
+
expr => (expr === 'items' ? ['a', 'b', 'c'] : undefined)
|
|
73
|
+
);
|
|
74
|
+
expect(out).toBe('a\nb\nc');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders #for #else when iterable is empty', () => {
|
|
78
|
+
const ast = parseTemplate('#for item in items\n${item}\n#else\nempty\n#end');
|
|
79
|
+
const out = renderTemplate(ast, {}, noopInterp, expr => (expr === 'items' ? [] : undefined));
|
|
80
|
+
expect(out).toBe('empty');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
|
|
17
|
+
export type TemplateNode =
|
|
18
|
+
| { kind: 'text'; content: string }
|
|
19
|
+
| { kind: 'if'; cond: string; then: TemplateNode[]; else: TemplateNode[] }
|
|
20
|
+
| {
|
|
21
|
+
kind: 'for';
|
|
22
|
+
varName: string;
|
|
23
|
+
iterableExpr: string;
|
|
24
|
+
body: TemplateNode[];
|
|
25
|
+
elseEmpty: TemplateNode[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const IF_RE = /^#if\s+(.+)$/;
|
|
29
|
+
const ELSE_RE = /^#else\s*$/;
|
|
30
|
+
const END_RE = /^#end\s*$/;
|
|
31
|
+
const FOR_RE = /^#for\s+([A-Za-z_$][\w$]*)\s+in\s+(.+)$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a template body into a sequence of TemplateNodes.
|
|
35
|
+
* Directives are recognized only when they occupy their own line.
|
|
36
|
+
*/
|
|
37
|
+
export function parseTemplate(source: string): TemplateNode[] {
|
|
38
|
+
const lines = source.split('\n');
|
|
39
|
+
const cursor = { i: 0 };
|
|
40
|
+
|
|
41
|
+
function parseBlock(stop: (line: string) => boolean): TemplateNode[] {
|
|
42
|
+
const nodes: TemplateNode[] = [];
|
|
43
|
+
let textBuffer: string[] = [];
|
|
44
|
+
|
|
45
|
+
const flushText = (): void => {
|
|
46
|
+
if (textBuffer.length > 0) {
|
|
47
|
+
nodes.push({ kind: 'text', content: textBuffer.join('\n') });
|
|
48
|
+
textBuffer = [];
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
while (cursor.i < lines.length) {
|
|
53
|
+
const line = lines[cursor.i];
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
|
|
56
|
+
if (stop(trimmed)) {
|
|
57
|
+
flushText();
|
|
58
|
+
return nodes;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ifMatch = IF_RE.exec(trimmed);
|
|
62
|
+
const forMatch = FOR_RE.exec(trimmed);
|
|
63
|
+
|
|
64
|
+
if (ifMatch) {
|
|
65
|
+
flushText();
|
|
66
|
+
const cond = ifMatch[1].trim();
|
|
67
|
+
cursor.i++;
|
|
68
|
+
const thenBlock = parseBlock(l => ELSE_RE.test(l) || END_RE.test(l));
|
|
69
|
+
let elseBlock: TemplateNode[] = [];
|
|
70
|
+
if (cursor.i < lines.length && ELSE_RE.test(lines[cursor.i].trim())) {
|
|
71
|
+
cursor.i++;
|
|
72
|
+
elseBlock = parseBlock(l => END_RE.test(l));
|
|
73
|
+
}
|
|
74
|
+
if (cursor.i < lines.length && END_RE.test(lines[cursor.i].trim())) cursor.i++;
|
|
75
|
+
nodes.push({ kind: 'if', cond, then: thenBlock, else: elseBlock });
|
|
76
|
+
} else if (forMatch) {
|
|
77
|
+
flushText();
|
|
78
|
+
const varName = forMatch[1];
|
|
79
|
+
const iterableExpr = forMatch[2].trim();
|
|
80
|
+
cursor.i++;
|
|
81
|
+
const bodyBlock = parseBlock(l => ELSE_RE.test(l) || END_RE.test(l));
|
|
82
|
+
let elseEmptyBlock: TemplateNode[] = [];
|
|
83
|
+
if (cursor.i < lines.length && ELSE_RE.test(lines[cursor.i].trim())) {
|
|
84
|
+
cursor.i++;
|
|
85
|
+
elseEmptyBlock = parseBlock(l => END_RE.test(l));
|
|
86
|
+
}
|
|
87
|
+
if (cursor.i < lines.length && END_RE.test(lines[cursor.i].trim())) cursor.i++;
|
|
88
|
+
nodes.push({
|
|
89
|
+
kind: 'for',
|
|
90
|
+
varName,
|
|
91
|
+
iterableExpr,
|
|
92
|
+
body: bodyBlock,
|
|
93
|
+
elseEmpty: elseEmptyBlock,
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
textBuffer.push(line);
|
|
97
|
+
cursor.i++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
flushText();
|
|
101
|
+
return nodes;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return parseBlock(() => false);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render a parsed template AST against a scope. `interpText` handles `${...}`
|
|
109
|
+
* interpolation in static blocks; `evalExpr` evaluates the bare expression in
|
|
110
|
+
* `#if <expr>` and `#for <var> in <expr>`. Both are passed in so the renderer
|
|
111
|
+
* stays decoupled from the components plugin's host-element / caret-var
|
|
112
|
+
* specifics — each callsite supplies the appropriate evaluator closure.
|
|
113
|
+
*/
|
|
114
|
+
export function renderTemplate(
|
|
115
|
+
nodes: TemplateNode[],
|
|
116
|
+
scope: Record<string, unknown>,
|
|
117
|
+
interpText: (text: string, scope: Record<string, unknown>) => string,
|
|
118
|
+
evalExpr: (expr: string, scope: Record<string, unknown>) => unknown
|
|
119
|
+
): string {
|
|
120
|
+
return nodes
|
|
121
|
+
.map(node => {
|
|
122
|
+
if (node.kind === 'text') {
|
|
123
|
+
return interpText(node.content, scope);
|
|
124
|
+
}
|
|
125
|
+
if (node.kind === 'if') {
|
|
126
|
+
const value = evalExpr(node.cond, scope);
|
|
127
|
+
const branch = value ? node.then : node.else;
|
|
128
|
+
return renderTemplate(branch, scope, interpText, evalExpr);
|
|
129
|
+
}
|
|
130
|
+
// for
|
|
131
|
+
const iterable = evalExpr(node.iterableExpr, scope) as Iterable<unknown> | null | undefined;
|
|
132
|
+
const items =
|
|
133
|
+
iterable != null &&
|
|
134
|
+
typeof (iterable as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'
|
|
135
|
+
? Array.from(iterable as Iterable<unknown>)
|
|
136
|
+
: [];
|
|
137
|
+
if (items.length === 0) {
|
|
138
|
+
return renderTemplate(node.elseEmpty, scope, interpText, evalExpr);
|
|
139
|
+
}
|
|
140
|
+
return items
|
|
141
|
+
.map(item =>
|
|
142
|
+
renderTemplate(node.body, { ...scope, [node.varName]: item }, interpText, evalExpr)
|
|
143
|
+
)
|
|
144
|
+
.join('\n');
|
|
145
|
+
})
|
|
146
|
+
.join('\n');
|
|
147
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight local type stubs — mirror the speech/reactivity pattern of
|
|
3
|
+
* avoiding tight coupling to `@hyperfixi/core` internals.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface RuntimeLike {
|
|
7
|
+
execute(node: unknown, context: unknown): Promise<unknown>;
|
|
8
|
+
getCleanupRegistry(): {
|
|
9
|
+
registerCustom(element: Element, cleanup: () => void, description?: string): void;
|
|
10
|
+
};
|
|
11
|
+
// Optional — core's Runtime has this for re-processing a subtree.
|
|
12
|
+
process?: (root: Element) => void | Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ASTNode {
|
|
16
|
+
type: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
value?: unknown;
|
|
19
|
+
[k: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExecutionContext {
|
|
23
|
+
me?: Element | null;
|
|
24
|
+
result?: unknown;
|
|
25
|
+
it?: unknown;
|
|
26
|
+
globals?: Map<string, unknown>;
|
|
27
|
+
locals?: Map<string, unknown>;
|
|
28
|
+
[k: string]: unknown;
|
|
29
|
+
}
|