gonia 0.1.3 → 0.2.1
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/README.md +24 -7
- package/dist/client/hydrate.js +54 -1
- package/dist/directives/for.d.ts +1 -1
- package/dist/directives/for.js +12 -76
- package/dist/directives/if.d.ts +4 -1
- package/dist/directives/if.js +69 -66
- package/dist/expression.js +17 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/inject.d.ts +1 -1
- package/dist/inject.js +1 -1
- package/dist/process.d.ts +61 -0
- package/dist/process.js +215 -0
- package/dist/scope.js +4 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/render.js +44 -3
- package/dist/types.d.ts +44 -11
- package/dist/types.js +7 -2
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -58,18 +58,16 @@ hydrate();
|
|
|
58
58
|
```typescript
|
|
59
59
|
import { directive, Directive } from 'gonia';
|
|
60
60
|
|
|
61
|
-
const myApp: Directive = ($element, $
|
|
62
|
-
// Initialize
|
|
63
|
-
$
|
|
61
|
+
const myApp: Directive<['$element', '$scope']> = ($element, $scope) => {
|
|
62
|
+
// Initialize scope
|
|
63
|
+
$scope.count = 0;
|
|
64
64
|
|
|
65
65
|
// Define methods
|
|
66
|
-
$
|
|
67
|
-
$
|
|
66
|
+
$scope.increment = () => {
|
|
67
|
+
$scope.count++;
|
|
68
68
|
};
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
myApp.$inject = ['$element', '$state'];
|
|
72
|
-
|
|
73
71
|
// Register with scope: true to create isolated state
|
|
74
72
|
directive('my-app', myApp, { scope: true });
|
|
75
73
|
```
|
|
@@ -92,6 +90,8 @@ directive('my-app', myApp, { scope: true });
|
|
|
92
90
|
| `g-class` | Dynamic classes | `<div g-class="{ active: isActive }">` |
|
|
93
91
|
| `g-model` | Two-way binding | `<input g-model="name">` |
|
|
94
92
|
| `g-on` | Event handling | `<button g-on="click: handleClick">` |
|
|
93
|
+
| `g-scope` | Inline scope init | `<div g-scope="{ count: 0 }">` |
|
|
94
|
+
| `g-bind:*` | Dynamic attributes | `<a g-bind:href="link">` |
|
|
95
95
|
|
|
96
96
|
## Vite Integration
|
|
97
97
|
|
|
@@ -114,6 +114,23 @@ See the [docs](./docs) folder for detailed documentation:
|
|
|
114
114
|
- [SSR Guide](./docs/ssr.md)
|
|
115
115
|
- [Reactivity](./docs/reactivity.md)
|
|
116
116
|
|
|
117
|
+
## Roadmap
|
|
118
|
+
|
|
119
|
+
### Done
|
|
120
|
+
- [x] Core directives (`g-text`, `g-show`, `g-if`, `g-for`, `g-class`, `g-model`, `g-on`, `g-scope`, `g-bind:*`, `g-html`)
|
|
121
|
+
- [x] Directive options (`scope`, `template`, `assign`, `provide`, `using`)
|
|
122
|
+
- [x] SSR with client hydration
|
|
123
|
+
- [x] Vite plugin with `$inject` transformation
|
|
124
|
+
- [x] Typed context registry
|
|
125
|
+
- [x] Persistent scopes for `g-if` toggles
|
|
126
|
+
|
|
127
|
+
### Planned
|
|
128
|
+
- [ ] Reducer-based two-way bindings (`scope: { prop: '=' }`)
|
|
129
|
+
- [ ] Scoped CSS with automatic class mangling
|
|
130
|
+
- [ ] Async components with suspense boundaries
|
|
131
|
+
- [ ] Browser devtools extension
|
|
132
|
+
- [ ] Transition system for `g-if`/`g-for`
|
|
133
|
+
|
|
117
134
|
## License
|
|
118
135
|
|
|
119
136
|
MIT
|
package/dist/client/hydrate.js
CHANGED
|
@@ -11,6 +11,7 @@ import { FOR_PROCESSED_ATTR } from '../directives/for.js';
|
|
|
11
11
|
import { findParentScope, createElementScope, getElementScope } from '../scope.js';
|
|
12
12
|
import { resolveDependencies as resolveInjectables } from '../inject.js';
|
|
13
13
|
import { resolveContext } from '../context-registry.js';
|
|
14
|
+
import { effect } from '../reactivity.js';
|
|
14
15
|
// Built-in directives
|
|
15
16
|
import { text } from '../directives/text.js';
|
|
16
17
|
import { show } from '../directives/show.js';
|
|
@@ -60,6 +61,10 @@ function getSelector(registry) {
|
|
|
60
61
|
}
|
|
61
62
|
// Also match native <slot> elements
|
|
62
63
|
directiveSelectors.push('slot');
|
|
64
|
+
// Match template placeholders from SSR (g-if with false condition)
|
|
65
|
+
directiveSelectors.push('template[data-g-if]');
|
|
66
|
+
// Match g-scope for inline scope initialization
|
|
67
|
+
directiveSelectors.push('[g-scope]');
|
|
63
68
|
cachedSelector = directiveSelectors.join(',');
|
|
64
69
|
}
|
|
65
70
|
return cachedSelector;
|
|
@@ -172,10 +177,54 @@ function processElement(el, registry) {
|
|
|
172
177
|
processNativeSlot(el);
|
|
173
178
|
return;
|
|
174
179
|
}
|
|
180
|
+
// Handle template placeholders from SSR (g-if with false condition)
|
|
181
|
+
if (el.tagName === 'TEMPLATE' && el.hasAttribute('data-g-if')) {
|
|
182
|
+
const ifDirective = registry.get('if');
|
|
183
|
+
if (ifDirective) {
|
|
184
|
+
const expr = el.getAttribute('data-g-if') || '';
|
|
185
|
+
const ctx = getContextForElement(el);
|
|
186
|
+
const config = createClientResolverConfig(el, ctx);
|
|
187
|
+
const registration = getDirective('g-if');
|
|
188
|
+
const args = resolveInjectables(ifDirective, expr, el, ctx.eval.bind(ctx), config, registration?.options.using);
|
|
189
|
+
const result = ifDirective(...args);
|
|
190
|
+
if (result instanceof Promise) {
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
175
196
|
const directives = getDirectivesForElement(el, registry);
|
|
176
|
-
|
|
197
|
+
const hasScopeAttr = el.hasAttribute('g-scope');
|
|
198
|
+
const hasBindAttrs = [...el.attributes].some(a => a.name.startsWith('g-bind:'));
|
|
199
|
+
// Skip if nothing to process
|
|
200
|
+
if (directives.length === 0 && !hasScopeAttr && !hasBindAttrs)
|
|
177
201
|
return;
|
|
178
202
|
const ctx = getContextForElement(el);
|
|
203
|
+
const scope = findParentScope(el, true) ?? {};
|
|
204
|
+
// Process g-scope first (inline scope initialization)
|
|
205
|
+
if (hasScopeAttr) {
|
|
206
|
+
const scopeAttr = el.getAttribute('g-scope');
|
|
207
|
+
const scopeValues = ctx.eval(scopeAttr);
|
|
208
|
+
if (scopeValues && typeof scopeValues === 'object') {
|
|
209
|
+
Object.assign(scope, scopeValues);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Process g-bind:* attributes (dynamic attribute binding with reactivity)
|
|
213
|
+
for (const attr of [...el.attributes]) {
|
|
214
|
+
if (attr.name.startsWith('g-bind:')) {
|
|
215
|
+
const targetAttr = attr.name.slice('g-bind:'.length);
|
|
216
|
+
const valueExpr = attr.value;
|
|
217
|
+
effect(() => {
|
|
218
|
+
const value = ctx.eval(valueExpr);
|
|
219
|
+
if (value === null || value === undefined) {
|
|
220
|
+
el.removeAttribute(targetAttr);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
el.setAttribute(targetAttr, String(value));
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
179
228
|
// Process directives sequentially, handling async ones properly
|
|
180
229
|
let chain;
|
|
181
230
|
for (const { directive, expr, using } of directives) {
|
|
@@ -335,6 +384,10 @@ async function processDirectiveElements() {
|
|
|
335
384
|
if (options.scope) {
|
|
336
385
|
const parentScope = findParentScope(el);
|
|
337
386
|
scope = createElementScope(el, parentScope);
|
|
387
|
+
// Apply assigned values to scope
|
|
388
|
+
if (options.assign) {
|
|
389
|
+
Object.assign(scope, options.assign);
|
|
390
|
+
}
|
|
338
391
|
}
|
|
339
392
|
else {
|
|
340
393
|
scope = findParentScope(el, true) ?? {};
|
package/dist/directives/for.d.ts
CHANGED
|
@@ -26,4 +26,4 @@ export declare const FOR_TEMPLATE_ATTR = "data-g-for-template";
|
|
|
26
26
|
* <div g-for="(value, key) in object" g-text="key + ': ' + value"></div>
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
-
export declare const cfor: Directive<['$expr', '$element', '$eval', '$
|
|
29
|
+
export declare const cfor: Directive<['$expr', '$element', '$eval', '$scope', '$mode']>;
|
package/dist/directives/for.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
6
|
import { directive, DirectivePriority, Mode } from '../types.js';
|
|
7
|
-
import { effect, createEffectScope
|
|
8
|
-
import {
|
|
7
|
+
import { effect, createEffectScope } from '../reactivity.js';
|
|
8
|
+
import { processElementTree } from '../process.js';
|
|
9
9
|
/**
|
|
10
10
|
* Parse a g-for expression.
|
|
11
11
|
*
|
|
@@ -31,77 +31,10 @@ function parseForExpression(expr) {
|
|
|
31
31
|
export const FOR_PROCESSED_ATTR = 'data-g-for-processed';
|
|
32
32
|
/** Attribute used to mark template content that should be skipped during SSR */
|
|
33
33
|
export const FOR_TEMPLATE_ATTR = 'data-g-for-template';
|
|
34
|
-
/**
|
|
35
|
-
* Process directives on a cloned element within a child scope.
|
|
36
|
-
*/
|
|
37
|
-
function processClonedElement(el, parentState, scopeAdditions, mode) {
|
|
38
|
-
// Mark this element as processed by g-for so hydrate skips it
|
|
39
|
-
el.setAttribute(FOR_PROCESSED_ATTR, '');
|
|
40
|
-
const childScope = createScope(parentState, scopeAdditions);
|
|
41
|
-
const childCtx = createContext(mode, childScope);
|
|
42
|
-
// Process g-text directives
|
|
43
|
-
const textAttr = el.getAttribute('g-text');
|
|
44
|
-
if (textAttr) {
|
|
45
|
-
const value = childCtx.eval(textAttr);
|
|
46
|
-
el.textContent = String(value ?? '');
|
|
47
|
-
}
|
|
48
|
-
// Process g-class directives
|
|
49
|
-
const classAttr = el.getAttribute('g-class');
|
|
50
|
-
if (classAttr) {
|
|
51
|
-
const classObj = childCtx.eval(classAttr);
|
|
52
|
-
if (classObj && typeof classObj === 'object') {
|
|
53
|
-
for (const [className, shouldAdd] of Object.entries(classObj)) {
|
|
54
|
-
if (shouldAdd) {
|
|
55
|
-
el.classList.add(className);
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
el.classList.remove(className);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
// Process g-show directives
|
|
64
|
-
const showAttr = el.getAttribute('g-show');
|
|
65
|
-
if (showAttr) {
|
|
66
|
-
const value = childCtx.eval(showAttr);
|
|
67
|
-
el.style.display = value ? '' : 'none';
|
|
68
|
-
}
|
|
69
|
-
// Process g-on directives (format: "event: handler") - client only
|
|
70
|
-
if (mode === Mode.CLIENT) {
|
|
71
|
-
const onAttr = el.getAttribute('g-on');
|
|
72
|
-
if (onAttr) {
|
|
73
|
-
setupEventHandler(el, onAttr, childCtx, childScope);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
// Process children recursively
|
|
77
|
-
for (const child of el.children) {
|
|
78
|
-
processClonedElement(child, childScope, {}, mode);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Set up an event handler on an element.
|
|
83
|
-
* Expression format: "event: handler"
|
|
84
|
-
*/
|
|
85
|
-
function setupEventHandler(el, expr, ctx, state) {
|
|
86
|
-
const colonIdx = expr.indexOf(':');
|
|
87
|
-
if (colonIdx === -1) {
|
|
88
|
-
console.error(`Invalid g-on expression: ${expr}. Expected "event: handler"`);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
const eventName = expr.slice(0, colonIdx).trim();
|
|
92
|
-
const handlerExpr = expr.slice(colonIdx + 1).trim();
|
|
93
|
-
const handler = (event) => {
|
|
94
|
-
const result = ctx.eval(handlerExpr);
|
|
95
|
-
if (typeof result === 'function') {
|
|
96
|
-
result.call(state, event);
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
el.addEventListener(eventName, handler);
|
|
100
|
-
}
|
|
101
34
|
/**
|
|
102
35
|
* Render loop items (used by both server and client).
|
|
103
36
|
*/
|
|
104
|
-
function renderItems(template, parent, insertAfterNode, parsed, $eval, $
|
|
37
|
+
function renderItems(template, parent, insertAfterNode, parsed, $eval, $scope, mode) {
|
|
105
38
|
const { itemName, indexName, iterableName } = parsed;
|
|
106
39
|
const iterable = $eval(iterableName);
|
|
107
40
|
const renderedElements = [];
|
|
@@ -136,7 +69,10 @@ function renderItems(template, parent, insertAfterNode, parsed, $eval, $state, m
|
|
|
136
69
|
if (indexName) {
|
|
137
70
|
scopeAdditions[indexName] = key;
|
|
138
71
|
}
|
|
139
|
-
|
|
72
|
+
// Mark as processed
|
|
73
|
+
clone.setAttribute(FOR_PROCESSED_ATTR, '');
|
|
74
|
+
// Process with shared utility
|
|
75
|
+
processElementTree(clone, $scope, mode, { scopeAdditions });
|
|
140
76
|
if (insertAfter.nextSibling) {
|
|
141
77
|
parent.insertBefore(clone, insertAfter.nextSibling);
|
|
142
78
|
}
|
|
@@ -177,7 +113,7 @@ function removeSSRItems(templateEl) {
|
|
|
177
113
|
* <div g-for="(value, key) in object" g-text="key + ': ' + value"></div>
|
|
178
114
|
* ```
|
|
179
115
|
*/
|
|
180
|
-
export const cfor = function cfor($expr, $element, $eval, $
|
|
116
|
+
export const cfor = function cfor($expr, $element, $eval, $scope, $mode) {
|
|
181
117
|
const parsed = parseForExpression($expr);
|
|
182
118
|
if (!parsed) {
|
|
183
119
|
console.error(`Invalid g-for expression: ${$expr}`);
|
|
@@ -204,7 +140,7 @@ export const cfor = function cfor($expr, $element, $eval, $state, $mode) {
|
|
|
204
140
|
// Replace original with template wrapper
|
|
205
141
|
parent.replaceChild(templateWrapper, $element);
|
|
206
142
|
// Render items after the template
|
|
207
|
-
renderItems(templateContent, parent, templateWrapper, parsed, $eval, $
|
|
143
|
+
renderItems(templateContent, parent, templateWrapper, parsed, $eval, $scope, $mode);
|
|
208
144
|
return;
|
|
209
145
|
}
|
|
210
146
|
// Client-side: check if hydrating from SSR or fresh render
|
|
@@ -230,7 +166,7 @@ export const cfor = function cfor($expr, $element, $eval, $state, $mode) {
|
|
|
230
166
|
}
|
|
231
167
|
scope = createEffectScope();
|
|
232
168
|
scope.run(() => {
|
|
233
|
-
renderedElements = renderItems(templateContent, parent, templateWrapper, parsed, $eval, $
|
|
169
|
+
renderedElements = renderItems(templateContent, parent, templateWrapper, parsed, $eval, $scope, Mode.CLIENT);
|
|
234
170
|
});
|
|
235
171
|
});
|
|
236
172
|
}
|
|
@@ -255,11 +191,11 @@ export const cfor = function cfor($expr, $element, $eval, $state, $mode) {
|
|
|
255
191
|
}
|
|
256
192
|
scope = createEffectScope();
|
|
257
193
|
scope.run(() => {
|
|
258
|
-
renderedElements = renderItems(templateContent, parent, templateWrapper, parsed, $eval, $
|
|
194
|
+
renderedElements = renderItems(templateContent, parent, templateWrapper, parsed, $eval, $scope, Mode.CLIENT);
|
|
259
195
|
});
|
|
260
196
|
});
|
|
261
197
|
}
|
|
262
198
|
};
|
|
263
|
-
cfor.$inject = ['$expr', '$element', '$eval', '$
|
|
199
|
+
cfor.$inject = ['$expr', '$element', '$eval', '$scope', '$mode'];
|
|
264
200
|
cfor.priority = DirectivePriority.STRUCTURAL;
|
|
265
201
|
directive('g-for', cfor);
|
package/dist/directives/if.d.ts
CHANGED
|
@@ -13,6 +13,9 @@ export declare const IF_PROCESSED_ATTR = "data-g-if-processed";
|
|
|
13
13
|
* Unlike g-show which uses display:none, g-if completely removes
|
|
14
14
|
* the element from the DOM when the condition is falsy.
|
|
15
15
|
*
|
|
16
|
+
* State within the conditional block is preserved across toggles.
|
|
17
|
+
* The scope is anchored to the placeholder, not the rendered element.
|
|
18
|
+
*
|
|
16
19
|
* On server: evaluates once and removes element if false.
|
|
17
20
|
* On client: sets up reactive effect to toggle element.
|
|
18
21
|
*
|
|
@@ -22,4 +25,4 @@ export declare const IF_PROCESSED_ATTR = "data-g-if-processed";
|
|
|
22
25
|
* <div g-if="items.length > 0">Has items</div>
|
|
23
26
|
* ```
|
|
24
27
|
*/
|
|
25
|
-
export declare const cif: Directive<['$expr', '$element', '$eval', '$
|
|
28
|
+
export declare const cif: Directive<['$expr', '$element', '$eval', '$scope', '$mode']>;
|
package/dist/directives/if.js
CHANGED
|
@@ -4,66 +4,30 @@
|
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
6
|
import { directive, DirectivePriority, Mode } from '../types.js';
|
|
7
|
-
import { effect } from '../reactivity.js';
|
|
8
|
-
import {
|
|
9
|
-
import { createScope } from '../reactivity.js';
|
|
7
|
+
import { effect, createScope } from '../reactivity.js';
|
|
8
|
+
import { processElementTree } from '../process.js';
|
|
10
9
|
/** Attribute used to mark elements processed by g-if */
|
|
11
10
|
export const IF_PROCESSED_ATTR = 'data-g-if-processed';
|
|
11
|
+
/** WeakMap to store persistent scopes for g-if placeholders */
|
|
12
|
+
const placeholderScopes = new WeakMap();
|
|
12
13
|
/**
|
|
13
|
-
*
|
|
14
|
+
* Get or create a persistent scope for a g-if placeholder.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* The scope is anchored to the placeholder element, not the rendered content.
|
|
18
|
+
* This allows state to persist across condition toggles.
|
|
19
|
+
*
|
|
20
|
+
* @param placeholder - The template placeholder element
|
|
21
|
+
* @param parentState - The parent scope to inherit from
|
|
22
|
+
* @returns The persistent scope for this g-if block
|
|
14
23
|
*/
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const textAttr = el.getAttribute('g-text');
|
|
21
|
-
if (textAttr) {
|
|
22
|
-
const value = childCtx.eval(textAttr);
|
|
23
|
-
el.textContent = String(value ?? '');
|
|
24
|
-
}
|
|
25
|
-
// Process g-class directives
|
|
26
|
-
const classAttr = el.getAttribute('g-class');
|
|
27
|
-
if (classAttr) {
|
|
28
|
-
const classObj = childCtx.eval(classAttr);
|
|
29
|
-
if (classObj && typeof classObj === 'object') {
|
|
30
|
-
for (const [className, shouldAdd] of Object.entries(classObj)) {
|
|
31
|
-
if (shouldAdd) {
|
|
32
|
-
el.classList.add(className);
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
el.classList.remove(className);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
// Process g-show directives
|
|
41
|
-
const showAttr = el.getAttribute('g-show');
|
|
42
|
-
if (showAttr) {
|
|
43
|
-
const value = childCtx.eval(showAttr);
|
|
44
|
-
el.style.display = value ? '' : 'none';
|
|
45
|
-
}
|
|
46
|
-
// Process g-on directives (format: "event: handler") - client only
|
|
47
|
-
if (mode === Mode.CLIENT) {
|
|
48
|
-
const onAttr = el.getAttribute('g-on');
|
|
49
|
-
if (onAttr) {
|
|
50
|
-
const colonIdx = onAttr.indexOf(':');
|
|
51
|
-
if (colonIdx !== -1) {
|
|
52
|
-
const eventName = onAttr.slice(0, colonIdx).trim();
|
|
53
|
-
const handlerExpr = onAttr.slice(colonIdx + 1).trim();
|
|
54
|
-
el.addEventListener(eventName, (event) => {
|
|
55
|
-
const result = childCtx.eval(handlerExpr);
|
|
56
|
-
if (typeof result === 'function') {
|
|
57
|
-
result.call(childScope, event);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
// Process children recursively
|
|
64
|
-
for (const child of el.children) {
|
|
65
|
-
processConditionalElement(child, childScope, mode);
|
|
24
|
+
function getOrCreateScope(placeholder, parentState) {
|
|
25
|
+
let scope = placeholderScopes.get(placeholder);
|
|
26
|
+
if (!scope) {
|
|
27
|
+
scope = createScope(parentState, {});
|
|
28
|
+
placeholderScopes.set(placeholder, scope);
|
|
66
29
|
}
|
|
30
|
+
return scope;
|
|
67
31
|
}
|
|
68
32
|
/**
|
|
69
33
|
* Conditionally render an element.
|
|
@@ -72,6 +36,9 @@ function processConditionalElement(el, parentState, mode) {
|
|
|
72
36
|
* Unlike g-show which uses display:none, g-if completely removes
|
|
73
37
|
* the element from the DOM when the condition is falsy.
|
|
74
38
|
*
|
|
39
|
+
* State within the conditional block is preserved across toggles.
|
|
40
|
+
* The scope is anchored to the placeholder, not the rendered element.
|
|
41
|
+
*
|
|
75
42
|
* On server: evaluates once and removes element if false.
|
|
76
43
|
* On client: sets up reactive effect to toggle element.
|
|
77
44
|
*
|
|
@@ -81,37 +48,72 @@ function processConditionalElement(el, parentState, mode) {
|
|
|
81
48
|
* <div g-if="items.length > 0">Has items</div>
|
|
82
49
|
* ```
|
|
83
50
|
*/
|
|
84
|
-
export const cif = function cif($expr, $element, $eval, $
|
|
51
|
+
export const cif = function cif($expr, $element, $eval, $scope, $mode) {
|
|
85
52
|
const parent = $element.parentNode;
|
|
86
53
|
if (!parent) {
|
|
87
54
|
return;
|
|
88
55
|
}
|
|
89
|
-
// Server-side: evaluate once and
|
|
56
|
+
// Server-side: evaluate once and leave template placeholder if false
|
|
90
57
|
if ($mode === Mode.SERVER) {
|
|
91
58
|
const condition = $eval($expr);
|
|
92
59
|
if (!condition) {
|
|
60
|
+
// Leave a template marker so client hydration knows where to insert
|
|
61
|
+
const placeholder = $element.ownerDocument.createElement('template');
|
|
62
|
+
placeholder.setAttribute('data-g-if', String($expr));
|
|
63
|
+
// Store original element inside template for hydration
|
|
64
|
+
placeholder.innerHTML = $element.outerHTML;
|
|
65
|
+
parent.insertBefore(placeholder, $element);
|
|
93
66
|
$element.remove();
|
|
94
67
|
}
|
|
95
68
|
else {
|
|
96
69
|
// Process child directives
|
|
97
70
|
$element.removeAttribute('g-if');
|
|
98
|
-
|
|
71
|
+
$element.setAttribute(IF_PROCESSED_ATTR, '');
|
|
72
|
+
processElementTree($element, $scope, $mode);
|
|
99
73
|
}
|
|
100
74
|
return;
|
|
101
75
|
}
|
|
102
|
-
// Client-side:
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
76
|
+
// Client-side: check if this is a template placeholder (from SSR)
|
|
77
|
+
const isTemplatePlaceholder = $element.tagName === 'TEMPLATE' && $element.hasAttribute('data-g-if');
|
|
78
|
+
let placeholder;
|
|
79
|
+
let template;
|
|
80
|
+
if (isTemplatePlaceholder) {
|
|
81
|
+
// Hydrating SSR output - template is the placeholder, content is inside
|
|
82
|
+
placeholder = $element;
|
|
83
|
+
const content = $element.content.firstElementChild
|
|
84
|
+
|| $element.innerHTML;
|
|
85
|
+
if (typeof content === 'string') {
|
|
86
|
+
const temp = $element.ownerDocument.createElement('div');
|
|
87
|
+
temp.innerHTML = content;
|
|
88
|
+
template = temp.firstElementChild;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
template = content.cloneNode(true);
|
|
92
|
+
}
|
|
93
|
+
template.removeAttribute('g-if');
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// Pure client-side - create template placeholder
|
|
97
|
+
placeholder = $element.ownerDocument.createElement('template');
|
|
98
|
+
placeholder.setAttribute('data-g-if', String($expr));
|
|
99
|
+
parent.insertBefore(placeholder, $element);
|
|
100
|
+
template = $element.cloneNode(true);
|
|
101
|
+
template.removeAttribute('g-if');
|
|
102
|
+
$element.remove();
|
|
103
|
+
}
|
|
104
|
+
// Create persistent scope anchored to the placeholder
|
|
105
|
+
const persistentScope = getOrCreateScope(placeholder, $scope);
|
|
108
106
|
let renderedElement = null;
|
|
109
107
|
effect(() => {
|
|
110
108
|
const condition = $eval($expr);
|
|
111
109
|
if (condition) {
|
|
112
110
|
if (!renderedElement) {
|
|
113
111
|
renderedElement = template.cloneNode(true);
|
|
114
|
-
|
|
112
|
+
renderedElement.setAttribute(IF_PROCESSED_ATTR, '');
|
|
113
|
+
// Process with the persistent scope - state survives across toggles
|
|
114
|
+
processElementTree(renderedElement, $scope, Mode.CLIENT, {
|
|
115
|
+
existingScope: persistentScope
|
|
116
|
+
});
|
|
115
117
|
if (placeholder.nextSibling) {
|
|
116
118
|
parent.insertBefore(renderedElement, placeholder.nextSibling);
|
|
117
119
|
}
|
|
@@ -124,10 +126,11 @@ export const cif = function cif($expr, $element, $eval, $state, $mode) {
|
|
|
124
126
|
if (renderedElement) {
|
|
125
127
|
renderedElement.remove();
|
|
126
128
|
renderedElement = null;
|
|
129
|
+
// Scope survives in persistentScope - ready for next render
|
|
127
130
|
}
|
|
128
131
|
}
|
|
129
132
|
});
|
|
130
133
|
};
|
|
131
|
-
cif.$inject = ['$expr', '$element', '$eval', '$
|
|
134
|
+
cif.$inject = ['$expr', '$element', '$eval', '$scope', '$mode'];
|
|
132
135
|
cif.priority = DirectivePriority.STRUCTURAL;
|
|
133
136
|
directive('g-if', cif);
|
package/dist/expression.js
CHANGED
|
@@ -43,8 +43,23 @@ export function findRoots(expr) {
|
|
|
43
43
|
.replace(/'(?:[^'\\]|\\.)*'/g, '""')
|
|
44
44
|
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
45
45
|
.replace(/`(?:[^`\\$]|\\.|\$(?!\{))*`/g, '""');
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// Match identifiers that are not preceded by a dot
|
|
47
|
+
// Use simpler approach: match all identifiers, then filter out property accesses
|
|
48
|
+
const allIdentifiers = cleaned.match(/[a-zA-Z_$][a-zA-Z0-9_$]*/g) || [];
|
|
49
|
+
// Filter to keep only root identifiers (not after a dot)
|
|
50
|
+
const roots = [];
|
|
51
|
+
let lastEnd = 0;
|
|
52
|
+
for (const id of allIdentifiers) {
|
|
53
|
+
const pos = cleaned.indexOf(id, lastEnd);
|
|
54
|
+
// Check if preceded by a dot (with optional whitespace)
|
|
55
|
+
const before = cleaned.slice(0, pos).trimEnd();
|
|
56
|
+
if (!before.endsWith('.')) {
|
|
57
|
+
if (!JS_KEYWORDS.has(id) && !roots.includes(id)) {
|
|
58
|
+
roots.push(id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
lastEnd = pos + id.length;
|
|
62
|
+
}
|
|
48
63
|
return roots;
|
|
49
64
|
}
|
|
50
65
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export { getInjectables, isContextKey } from './inject.js';
|
|
|
19
19
|
export type { Injectable } from './inject.js';
|
|
20
20
|
export { getRootScope, clearRootScope } from './scope.js';
|
|
21
21
|
export { findAncestor } from './dom.js';
|
|
22
|
+
export { processElementDirectives, processElementTree, PROCESSED_ATTR } from './process.js';
|
|
23
|
+
export type { ProcessOptions } from './process.js';
|
|
22
24
|
export { createContextKey, registerContext, resolveContext, hasContext, removeContext, clearContexts } from './context-registry.js';
|
|
23
25
|
export type { ContextKey } from './context-registry.js';
|
|
24
26
|
export * as directives from './directives/index.js';
|
package/dist/index.js
CHANGED
|
@@ -15,5 +15,6 @@ export { findRoots, parseInterpolation } from './expression.js';
|
|
|
15
15
|
export { getInjectables, isContextKey } from './inject.js';
|
|
16
16
|
export { getRootScope, clearRootScope } from './scope.js';
|
|
17
17
|
export { findAncestor } from './dom.js';
|
|
18
|
+
export { processElementDirectives, processElementTree, PROCESSED_ATTR } from './process.js';
|
|
18
19
|
export { createContextKey, registerContext, resolveContext, hasContext, removeContext, clearContexts } from './context-registry.js';
|
|
19
20
|
export * as directives from './directives/index.js';
|
package/dist/inject.d.ts
CHANGED
|
@@ -55,7 +55,7 @@ export declare function getInjectables(fn: InjectableFunction): Injectable[];
|
|
|
55
55
|
export interface DependencyResolverConfig {
|
|
56
56
|
/** Resolve a ContextKey to its value */
|
|
57
57
|
resolveContext: (key: ContextKey<unknown>) => unknown;
|
|
58
|
-
/** Resolve $
|
|
58
|
+
/** Resolve $scope injectable */
|
|
59
59
|
resolveState: () => Record<string, unknown>;
|
|
60
60
|
/** Resolve $rootState injectable (may be same as state) */
|
|
61
61
|
resolveRootState?: () => Record<string, unknown>;
|
package/dist/inject.js
CHANGED
|
@@ -97,7 +97,7 @@ export function resolveDependencies(fn, expr, element, evalFn, config, using) {
|
|
|
97
97
|
return element;
|
|
98
98
|
case '$eval':
|
|
99
99
|
return evalFn;
|
|
100
|
-
case '$
|
|
100
|
+
case '$scope':
|
|
101
101
|
return config.resolveState();
|
|
102
102
|
case '$rootState':
|
|
103
103
|
return config.resolveRootState?.() ?? config.resolveState();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared element processing for structural directives.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Provides a unified way to process directives on elements created by
|
|
6
|
+
* structural directives like g-if and g-for. Supports scope reuse for
|
|
7
|
+
* state preservation across re-renders.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
import { Mode } from './types.js';
|
|
12
|
+
/** Attribute used to mark elements processed by structural directives */
|
|
13
|
+
export declare const PROCESSED_ATTR = "data-g-processed";
|
|
14
|
+
/**
|
|
15
|
+
* Options for processing element directives.
|
|
16
|
+
*/
|
|
17
|
+
export interface ProcessOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Existing scope to use instead of creating a new one.
|
|
20
|
+
* Use this to preserve state across re-renders (e.g., g-if toggle).
|
|
21
|
+
*/
|
|
22
|
+
existingScope?: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Additional properties to add to the scope.
|
|
25
|
+
* Used by g-for to add item/index variables.
|
|
26
|
+
*/
|
|
27
|
+
scopeAdditions?: Record<string, unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* Skip processing structural directives (g-for, g-if).
|
|
30
|
+
* Set to true when processing content inside a structural directive
|
|
31
|
+
* to avoid infinite recursion.
|
|
32
|
+
*/
|
|
33
|
+
skipStructural?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Process directives on an element using registered directives.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* This processes all non-structural directives (g-text, g-class, g-show, g-on, etc.)
|
|
40
|
+
* on an element. For structural directives, use the directives directly.
|
|
41
|
+
*
|
|
42
|
+
* @param el - The element to process
|
|
43
|
+
* @param parentScope - The parent scope for variable resolution
|
|
44
|
+
* @param mode - Server or client mode
|
|
45
|
+
* @param options - Processing options
|
|
46
|
+
* @returns The scope used for this element (for chaining/children)
|
|
47
|
+
*/
|
|
48
|
+
export declare function processElementDirectives(el: Element, parentScope: Record<string, unknown>, mode: Mode, options?: ProcessOptions): Record<string, unknown>;
|
|
49
|
+
/**
|
|
50
|
+
* Process an element tree (element and all descendants).
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* Recursively processes directives on an element and all its children.
|
|
54
|
+
* Each child gets its own scope that inherits from the parent.
|
|
55
|
+
*
|
|
56
|
+
* @param el - The root element to process
|
|
57
|
+
* @param parentScope - The parent scope
|
|
58
|
+
* @param mode - Server or client mode
|
|
59
|
+
* @param options - Processing options (existingScope only applies to root element)
|
|
60
|
+
*/
|
|
61
|
+
export declare function processElementTree(el: Element, parentScope: Record<string, unknown>, mode: Mode, options?: ProcessOptions): void;
|
package/dist/process.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared element processing for structural directives.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Provides a unified way to process directives on elements created by
|
|
6
|
+
* structural directives like g-if and g-for. Supports scope reuse for
|
|
7
|
+
* state preservation across re-renders.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
import { Mode, getDirective } from './types.js';
|
|
12
|
+
import { createContext } from './context.js';
|
|
13
|
+
import { createScope, effect } from './reactivity.js';
|
|
14
|
+
import { resolveDependencies } from './inject.js';
|
|
15
|
+
import { resolveContext } from './context-registry.js';
|
|
16
|
+
import { resolveFromProviders, resolveFromDIProviders } from './providers.js';
|
|
17
|
+
/** Attribute used to mark elements processed by structural directives */
|
|
18
|
+
export const PROCESSED_ATTR = 'data-g-processed';
|
|
19
|
+
/**
|
|
20
|
+
* Create resolver config for dependency resolution.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
function createResolverConfig(el, scope, mode) {
|
|
25
|
+
return {
|
|
26
|
+
resolveContext: (key) => resolveContext(el, key),
|
|
27
|
+
resolveState: () => scope,
|
|
28
|
+
resolveRootState: () => scope,
|
|
29
|
+
resolveCustom: (name) => {
|
|
30
|
+
const diProvided = resolveFromDIProviders(el, name);
|
|
31
|
+
if (diProvided !== undefined)
|
|
32
|
+
return diProvided;
|
|
33
|
+
return resolveFromProviders(el, name);
|
|
34
|
+
},
|
|
35
|
+
mode: mode === Mode.SERVER ? 'server' : 'client'
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Set up an event handler on an element.
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
function setupEventHandler(el, expr, ctx, scope) {
|
|
44
|
+
const colonIdx = expr.indexOf(':');
|
|
45
|
+
if (colonIdx === -1) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const eventName = expr.slice(0, colonIdx).trim();
|
|
49
|
+
const handlerExpr = expr.slice(colonIdx + 1).trim();
|
|
50
|
+
el.addEventListener(eventName, (event) => {
|
|
51
|
+
const result = ctx.eval(handlerExpr);
|
|
52
|
+
if (typeof result === 'function') {
|
|
53
|
+
result.call(scope, event);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Process directives on an element using registered directives.
|
|
59
|
+
*
|
|
60
|
+
* @remarks
|
|
61
|
+
* This processes all non-structural directives (g-text, g-class, g-show, g-on, etc.)
|
|
62
|
+
* on an element. For structural directives, use the directives directly.
|
|
63
|
+
*
|
|
64
|
+
* @param el - The element to process
|
|
65
|
+
* @param parentScope - The parent scope for variable resolution
|
|
66
|
+
* @param mode - Server or client mode
|
|
67
|
+
* @param options - Processing options
|
|
68
|
+
* @returns The scope used for this element (for chaining/children)
|
|
69
|
+
*/
|
|
70
|
+
export function processElementDirectives(el, parentScope, mode, options = {}) {
|
|
71
|
+
const { existingScope, scopeAdditions = {}, skipStructural = true } = options;
|
|
72
|
+
// Mark element as processed
|
|
73
|
+
el.setAttribute(PROCESSED_ATTR, '');
|
|
74
|
+
// Use existing scope or create a new child scope
|
|
75
|
+
const scope = existingScope
|
|
76
|
+
? (Object.keys(scopeAdditions).length > 0
|
|
77
|
+
? createScope(existingScope, scopeAdditions)
|
|
78
|
+
: existingScope)
|
|
79
|
+
: createScope(parentScope, scopeAdditions);
|
|
80
|
+
const ctx = createContext(mode, scope);
|
|
81
|
+
// Process g-scope (inline scope initialization)
|
|
82
|
+
const scopeAttr = el.getAttribute('g-scope');
|
|
83
|
+
if (scopeAttr) {
|
|
84
|
+
const scopeValues = ctx.eval(scopeAttr);
|
|
85
|
+
if (scopeValues && typeof scopeValues === 'object') {
|
|
86
|
+
Object.assign(scope, scopeValues);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Process g-text
|
|
90
|
+
const textAttr = el.getAttribute('g-text');
|
|
91
|
+
if (textAttr) {
|
|
92
|
+
if (mode === Mode.CLIENT) {
|
|
93
|
+
effect(() => {
|
|
94
|
+
const value = ctx.eval(textAttr);
|
|
95
|
+
el.textContent = String(value ?? '');
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const value = ctx.eval(textAttr);
|
|
100
|
+
el.textContent = String(value ?? '');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Process g-class
|
|
104
|
+
const classAttr = el.getAttribute('g-class');
|
|
105
|
+
if (classAttr) {
|
|
106
|
+
const applyClasses = () => {
|
|
107
|
+
const classObj = ctx.eval(classAttr);
|
|
108
|
+
if (classObj && typeof classObj === 'object') {
|
|
109
|
+
for (const [className, shouldAdd] of Object.entries(classObj)) {
|
|
110
|
+
if (shouldAdd) {
|
|
111
|
+
el.classList.add(className);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
el.classList.remove(className);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
if (mode === Mode.CLIENT) {
|
|
120
|
+
effect(applyClasses);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
applyClasses();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Process g-show
|
|
127
|
+
const showAttr = el.getAttribute('g-show');
|
|
128
|
+
if (showAttr) {
|
|
129
|
+
const applyShow = () => {
|
|
130
|
+
const value = ctx.eval(showAttr);
|
|
131
|
+
el.style.display = value ? '' : 'none';
|
|
132
|
+
};
|
|
133
|
+
if (mode === Mode.CLIENT) {
|
|
134
|
+
effect(applyShow);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
applyShow();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Process g-on (client only)
|
|
141
|
+
if (mode === Mode.CLIENT) {
|
|
142
|
+
const onAttr = el.getAttribute('g-on');
|
|
143
|
+
if (onAttr) {
|
|
144
|
+
setupEventHandler(el, onAttr, ctx, scope);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Process g-model (client only)
|
|
148
|
+
if (mode === Mode.CLIENT) {
|
|
149
|
+
const modelAttr = el.getAttribute('g-model');
|
|
150
|
+
if (modelAttr) {
|
|
151
|
+
const registration = getDirective('g-model');
|
|
152
|
+
if (registration?.fn) {
|
|
153
|
+
const config = createResolverConfig(el, scope, mode);
|
|
154
|
+
const args = resolveDependencies(registration.fn, modelAttr, el, ctx.eval.bind(ctx), config, registration.options.using);
|
|
155
|
+
registration.fn(...args);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Process g-html
|
|
160
|
+
const htmlAttr = el.getAttribute('g-html');
|
|
161
|
+
if (htmlAttr) {
|
|
162
|
+
const applyHtml = () => {
|
|
163
|
+
const value = ctx.eval(htmlAttr);
|
|
164
|
+
el.innerHTML = String(value ?? '');
|
|
165
|
+
};
|
|
166
|
+
if (mode === Mode.CLIENT) {
|
|
167
|
+
effect(applyHtml);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
applyHtml();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Process g-bind:* attributes
|
|
174
|
+
const bindAttrs = [...el.attributes].filter(a => a.name.startsWith('g-bind:'));
|
|
175
|
+
for (const attr of bindAttrs) {
|
|
176
|
+
const targetAttr = attr.name.slice('g-bind:'.length);
|
|
177
|
+
const valueExpr = attr.value;
|
|
178
|
+
const applyBinding = () => {
|
|
179
|
+
const value = ctx.eval(valueExpr);
|
|
180
|
+
if (value === null || value === undefined) {
|
|
181
|
+
el.removeAttribute(targetAttr);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
el.setAttribute(targetAttr, String(value));
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
if (mode === Mode.CLIENT) {
|
|
188
|
+
effect(applyBinding);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
applyBinding();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return scope;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Process an element tree (element and all descendants).
|
|
198
|
+
*
|
|
199
|
+
* @remarks
|
|
200
|
+
* Recursively processes directives on an element and all its children.
|
|
201
|
+
* Each child gets its own scope that inherits from the parent.
|
|
202
|
+
*
|
|
203
|
+
* @param el - The root element to process
|
|
204
|
+
* @param parentScope - The parent scope
|
|
205
|
+
* @param mode - Server or client mode
|
|
206
|
+
* @param options - Processing options (existingScope only applies to root element)
|
|
207
|
+
*/
|
|
208
|
+
export function processElementTree(el, parentScope, mode, options = {}) {
|
|
209
|
+
// Process the root element
|
|
210
|
+
const scope = processElementDirectives(el, parentScope, mode, options);
|
|
211
|
+
// Process children recursively (they get fresh child scopes)
|
|
212
|
+
for (const child of el.children) {
|
|
213
|
+
processElementTree(child, scope, mode, { skipStructural: true });
|
|
214
|
+
}
|
|
215
|
+
}
|
package/dist/scope.js
CHANGED
|
@@ -110,6 +110,10 @@ fn, options) {
|
|
|
110
110
|
const parentScope = findParentScope(this);
|
|
111
111
|
// Create this element's scope
|
|
112
112
|
const scope = createElementScope(this, parentScope);
|
|
113
|
+
// Apply assigned values to scope
|
|
114
|
+
if (options.assign) {
|
|
115
|
+
Object.assign(scope, options.assign);
|
|
116
|
+
}
|
|
113
117
|
// Create context for expression evaluation
|
|
114
118
|
const ctx = createContext(Mode.CLIENT, scope);
|
|
115
119
|
// Resolve dependencies using shared resolver
|
package/dist/server/index.d.ts
CHANGED
package/dist/server/index.js
CHANGED
package/dist/server/render.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { Window } from 'happy-dom';
|
|
7
7
|
import { Mode, DirectivePriority, getDirective } from '../types.js';
|
|
8
8
|
import { createContext } from '../context.js';
|
|
9
9
|
import { processNativeSlot } from '../directives/slot.js';
|
|
@@ -26,11 +26,26 @@ function getSelector(registry) {
|
|
|
26
26
|
const directiveSelectors = [...registry.keys()].map(n => `[g-${n}]`);
|
|
27
27
|
// Also match native <slot> elements
|
|
28
28
|
directiveSelectors.push('slot');
|
|
29
|
+
// Match g-scope for inline scope initialization
|
|
30
|
+
directiveSelectors.push('[g-scope]');
|
|
29
31
|
selector = directiveSelectors.join(',');
|
|
30
32
|
selectorCache.set(registry, selector);
|
|
31
33
|
}
|
|
32
34
|
return selector;
|
|
33
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Check if element has any g-bind:* attributes.
|
|
38
|
+
*
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
function hasBindAttributes(el) {
|
|
42
|
+
for (const attr of el.attributes) {
|
|
43
|
+
if (attr.name.startsWith('g-bind:')) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
34
49
|
/**
|
|
35
50
|
* Register a directive in the registry.
|
|
36
51
|
*
|
|
@@ -108,10 +123,11 @@ function createServerResolverConfig(el, rootState) {
|
|
|
108
123
|
* ```
|
|
109
124
|
*/
|
|
110
125
|
export async function render(html, state, registry) {
|
|
111
|
-
const
|
|
126
|
+
const window = new Window();
|
|
127
|
+
const document = window.document;
|
|
112
128
|
const index = [];
|
|
113
129
|
const selector = getSelector(registry);
|
|
114
|
-
const observer = new MutationObserver((mutations) => {
|
|
130
|
+
const observer = new window.MutationObserver((mutations) => {
|
|
115
131
|
for (const mutation of mutations) {
|
|
116
132
|
for (const node of mutation.addedNodes) {
|
|
117
133
|
if (node.nodeType !== 1)
|
|
@@ -120,6 +136,10 @@ export async function render(html, state, registry) {
|
|
|
120
136
|
const matches = el.matches(selector) ? [el] : [];
|
|
121
137
|
const descendants = [...el.querySelectorAll(selector)];
|
|
122
138
|
for (const match of [...matches, ...descendants]) {
|
|
139
|
+
// Skip elements inside template content (used as placeholders)
|
|
140
|
+
if (match.closest('template')) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
123
143
|
// Handle native <slot> elements
|
|
124
144
|
if (match.tagName === 'SLOT') {
|
|
125
145
|
index.push({
|
|
@@ -204,6 +224,27 @@ export async function render(html, state, registry) {
|
|
|
204
224
|
const directives = byElement.get(el);
|
|
205
225
|
// Sort directives on this element by priority (higher first)
|
|
206
226
|
directives.sort((a, b) => b.priority - a.priority);
|
|
227
|
+
// Process g-scope first (inline scope initialization)
|
|
228
|
+
const scopeAttr = el.getAttribute('g-scope');
|
|
229
|
+
if (scopeAttr) {
|
|
230
|
+
const scopeValues = ctx.eval(scopeAttr);
|
|
231
|
+
if (scopeValues && typeof scopeValues === 'object') {
|
|
232
|
+
Object.assign(state, scopeValues);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Process g-bind:* attributes (dynamic attribute binding)
|
|
236
|
+
for (const attr of [...el.attributes]) {
|
|
237
|
+
if (attr.name.startsWith('g-bind:')) {
|
|
238
|
+
const targetAttr = attr.name.slice('g-bind:'.length);
|
|
239
|
+
const value = ctx.eval(attr.value);
|
|
240
|
+
if (value === null || value === undefined) {
|
|
241
|
+
el.removeAttribute(targetAttr);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
el.setAttribute(targetAttr, String(value));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
207
248
|
for (const item of directives) {
|
|
208
249
|
// Check if element was disconnected by a previous directive (e.g., g-for replacing it)
|
|
209
250
|
if (!item.el.isConnected) {
|
package/dist/types.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface InjectableRegistry {
|
|
|
50
50
|
/** Function to evaluate expressions against state */
|
|
51
51
|
$eval: EvalFn;
|
|
52
52
|
/** Local reactive state object (isolated per element) */
|
|
53
|
-
$
|
|
53
|
+
$scope: Record<string, unknown>;
|
|
54
54
|
/** Root reactive state object (shared across all elements) */
|
|
55
55
|
$rootState: Record<string, unknown>;
|
|
56
56
|
/** Template registry for g-template directive */
|
|
@@ -72,7 +72,7 @@ type ContextKeyValue<K> = K extends ContextKey<infer V> ? V : never;
|
|
|
72
72
|
*
|
|
73
73
|
* @example
|
|
74
74
|
* ```ts
|
|
75
|
-
* type Args = MapInjectables<['$element', '$
|
|
75
|
+
* type Args = MapInjectables<['$element', '$scope']>;
|
|
76
76
|
* // => [Element, Record<string, unknown>]
|
|
77
77
|
*
|
|
78
78
|
* // With context keys
|
|
@@ -153,7 +153,7 @@ export interface DirectiveMeta<T = InjectableRegistry> {
|
|
|
153
153
|
* - `$expr`: The expression string from the attribute
|
|
154
154
|
* - `$element`: The target DOM element
|
|
155
155
|
* - `$eval`: Function to evaluate expressions: `(expr) => value`
|
|
156
|
-
* - `$
|
|
156
|
+
* - `$scope`: Local reactive state object (isolated per element)
|
|
157
157
|
* - Any registered service names
|
|
158
158
|
* - Any `ContextKey` for typed context resolution
|
|
159
159
|
* - Any names provided by ancestor directives via `$context`
|
|
@@ -161,7 +161,7 @@ export interface DirectiveMeta<T = InjectableRegistry> {
|
|
|
161
161
|
* @example
|
|
162
162
|
* ```ts
|
|
163
163
|
* // String-based injection
|
|
164
|
-
* myDirective.$inject = ['$element', '$
|
|
164
|
+
* myDirective.$inject = ['$element', '$scope'];
|
|
165
165
|
*
|
|
166
166
|
* // With typed context keys
|
|
167
167
|
* myDirective.$inject = ['$element', SlotContentContext];
|
|
@@ -172,16 +172,16 @@ export interface DirectiveMeta<T = InjectableRegistry> {
|
|
|
172
172
|
* Names this directive exposes as context to descendants.
|
|
173
173
|
*
|
|
174
174
|
* @remarks
|
|
175
|
-
* When a directive declares `$context`, its `$
|
|
175
|
+
* When a directive declares `$context`, its `$scope` becomes
|
|
176
176
|
* available to descendant directives under those names.
|
|
177
177
|
* Useful for passing state through isolate scope boundaries.
|
|
178
178
|
*
|
|
179
179
|
* @example
|
|
180
180
|
* ```ts
|
|
181
|
-
* const themeProvider: Directive = ($
|
|
182
|
-
* $
|
|
181
|
+
* const themeProvider: Directive = ($scope) => {
|
|
182
|
+
* $scope.mode = 'dark';
|
|
183
183
|
* };
|
|
184
|
-
* themeProvider.$inject = ['$
|
|
184
|
+
* themeProvider.$inject = ['$scope'];
|
|
185
185
|
* themeProvider.$context = ['theme'];
|
|
186
186
|
*
|
|
187
187
|
* // Descendants can inject 'theme'
|
|
@@ -338,7 +338,7 @@ export interface DirectiveOptions {
|
|
|
338
338
|
* const ThemeContext = createContextKey<{ mode: 'light' | 'dark' }>('Theme');
|
|
339
339
|
* const UserContext = createContextKey<{ name: string }>('User');
|
|
340
340
|
*
|
|
341
|
-
* directive('themed-greeting', ($element, $
|
|
341
|
+
* directive('themed-greeting', ($element, $scope, theme, user) => {
|
|
342
342
|
* // theme and user are resolved from the using array
|
|
343
343
|
* $element.textContent = `Hello ${user.name}!`;
|
|
344
344
|
* $element.className = theme.mode;
|
|
@@ -348,6 +348,39 @@ export interface DirectiveOptions {
|
|
|
348
348
|
* ```
|
|
349
349
|
*/
|
|
350
350
|
using?: ContextKey<unknown>[];
|
|
351
|
+
/**
|
|
352
|
+
* Values to assign to the directive's scope.
|
|
353
|
+
*
|
|
354
|
+
* @remarks
|
|
355
|
+
* Requires `scope: true`. Assigns the provided values to the
|
|
356
|
+
* directive's scope, making them available in expressions.
|
|
357
|
+
*
|
|
358
|
+
* Useful for injecting external values (like styles) that should
|
|
359
|
+
* be accessible in templates without manual `$scope` assignment.
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```ts
|
|
363
|
+
* import styles from './button.css';
|
|
364
|
+
*
|
|
365
|
+
* directive('my-button', handler, {
|
|
366
|
+
* scope: true,
|
|
367
|
+
* assign: { $styles: styles }
|
|
368
|
+
* });
|
|
369
|
+
*
|
|
370
|
+
* // In template:
|
|
371
|
+
* // <div g-class="$styles.container">...</div>
|
|
372
|
+
* ```
|
|
373
|
+
*/
|
|
374
|
+
assign?: Record<string, unknown>;
|
|
375
|
+
/**
|
|
376
|
+
* Index signature for custom options.
|
|
377
|
+
*
|
|
378
|
+
* @remarks
|
|
379
|
+
* Allows libraries to pass additional options that gonia
|
|
380
|
+
* doesn't process directly. Libraries can create typed
|
|
381
|
+
* wrapper functions around `directive()` for type safety.
|
|
382
|
+
*/
|
|
383
|
+
[key: string]: unknown;
|
|
351
384
|
}
|
|
352
385
|
/** Registered directive with options */
|
|
353
386
|
export interface DirectiveRegistration {
|
|
@@ -372,8 +405,8 @@ export interface DirectiveRegistration {
|
|
|
372
405
|
* @example
|
|
373
406
|
* ```ts
|
|
374
407
|
* // Directive with behavior
|
|
375
|
-
* directive('todo-app', ($element, $
|
|
376
|
-
* $
|
|
408
|
+
* directive('todo-app', ($element, $scope) => {
|
|
409
|
+
* $scope.todos = [];
|
|
377
410
|
* }, { scope: true });
|
|
378
411
|
*
|
|
379
412
|
* // Template-only directive
|
package/dist/types.js
CHANGED
|
@@ -49,8 +49,8 @@ const directiveRegistry = new Map();
|
|
|
49
49
|
* @example
|
|
50
50
|
* ```ts
|
|
51
51
|
* // Directive with behavior
|
|
52
|
-
* directive('todo-app', ($element, $
|
|
53
|
-
* $
|
|
52
|
+
* directive('todo-app', ($element, $scope) => {
|
|
53
|
+
* $scope.todos = [];
|
|
54
54
|
* }, { scope: true });
|
|
55
55
|
*
|
|
56
56
|
* // Template-only directive
|
|
@@ -61,6 +61,11 @@ const directiveRegistry = new Map();
|
|
|
61
61
|
*/
|
|
62
62
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
63
|
export function directive(name, fn, options = {}) {
|
|
64
|
+
// Validate: assign requires scope: true
|
|
65
|
+
if (options.assign && !options.scope) {
|
|
66
|
+
throw new Error(`Directive '${name}': 'assign' requires 'scope: true'. ` +
|
|
67
|
+
`To modify parent scope, use $scope in your directive function.`);
|
|
68
|
+
}
|
|
64
69
|
directiveRegistry.set(name, { fn, options });
|
|
65
70
|
// Register as custom element if name contains hyphen and scope is true
|
|
66
71
|
if (fn && name.includes('-') && options.scope && typeof customElements !== 'undefined') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gonia",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "A lightweight, SSR-first reactive UI library with declarative directives",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
}
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"
|
|
49
|
+
"happy-dom": "^17.4.4",
|
|
50
50
|
"tinyglobby": "^0.2.15"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^25.0.10",
|
|
62
62
|
"@vitest/coverage-v8": "^4.0.17",
|
|
63
|
+
"conventional-changelog-cli": "^5.0.0",
|
|
63
64
|
"jsdom": "^27.4.0",
|
|
64
65
|
"typescript": "^5.7.0",
|
|
65
66
|
"vite": "^6.4.0",
|
|
@@ -68,6 +69,8 @@
|
|
|
68
69
|
"scripts": {
|
|
69
70
|
"build": "tsc",
|
|
70
71
|
"test": "vitest run",
|
|
71
|
-
"test:watch": "vitest"
|
|
72
|
+
"test:watch": "vitest",
|
|
73
|
+
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
|
|
74
|
+
"changelog:all": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0"
|
|
72
75
|
}
|
|
73
76
|
}
|