gonia 0.1.3 → 0.2.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/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, $state) => {
62
- // Initialize state
63
- $state.count = 0;
61
+ const myApp: Directive<['$element', '$scope']> = ($element, $scope) => {
62
+ // Initialize scope
63
+ $scope.count = 0;
64
64
 
65
65
  // Define methods
66
- $state.increment = () => {
67
- $state.count++;
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
@@ -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
- if (directives.length === 0)
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) ?? {};
@@ -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', '$state', '$mode']>;
29
+ export declare const cfor: Directive<['$expr', '$element', '$eval', '$scope', '$mode']>;
@@ -4,8 +4,8 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
  import { directive, DirectivePriority, Mode } from '../types.js';
7
- import { effect, createEffectScope, createScope } from '../reactivity.js';
8
- import { createContext } from '../context.js';
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, $state, mode) {
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
- processClonedElement(clone, $state, scopeAdditions, mode);
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, $state, $mode) {
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, $state, $mode);
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, $state, Mode.CLIENT);
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, $state, Mode.CLIENT);
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', '$state', '$mode'];
199
+ cfor.$inject = ['$expr', '$element', '$eval', '$scope', '$mode'];
264
200
  cfor.priority = DirectivePriority.STRUCTURAL;
265
201
  directive('g-for', cfor);
@@ -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', '$state', '$mode']>;
28
+ export declare const cif: Directive<['$expr', '$element', '$eval', '$scope', '$mode']>;
@@ -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 { createContext } from '../context.js';
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
- * Process directives on a conditionally rendered element.
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 processConditionalElement(el, parentState, mode) {
16
- el.setAttribute(IF_PROCESSED_ATTR, '');
17
- const childScope = createScope(parentState, {});
18
- const childCtx = createContext(mode, childScope);
19
- // Process g-text directives
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, $state, $mode) {
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 remove if false
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
- processConditionalElement($element, $state, $mode);
71
+ $element.setAttribute(IF_PROCESSED_ATTR, '');
72
+ processElementTree($element, $scope, $mode);
99
73
  }
100
74
  return;
101
75
  }
102
- // Client-side: set up reactive effect
103
- const placeholder = $element.ownerDocument.createComment(` g-if: ${$expr} `);
104
- parent.insertBefore(placeholder, $element);
105
- const template = $element.cloneNode(true);
106
- template.removeAttribute('g-if');
107
- $element.remove();
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
- processConditionalElement(renderedElement, $state, Mode.CLIENT);
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', '$state', '$mode'];
134
+ cif.$inject = ['$expr', '$element', '$eval', '$scope', '$mode'];
132
135
  cif.priority = DirectivePriority.STRUCTURAL;
133
136
  directive('g-if', cif);
@@ -43,8 +43,23 @@ export function findRoots(expr) {
43
43
  .replace(/'(?:[^'\\]|\\.)*'/g, '""')
44
44
  .replace(/"(?:[^"\\]|\\.)*"/g, '""')
45
45
  .replace(/`(?:[^`\\$]|\\.|\$(?!\{))*`/g, '""');
46
- const matches = cleaned.match(/(?<![.\w$])\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g) || [];
47
- const roots = [...new Set(matches.filter(m => !JS_KEYWORDS.has(m)))];
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 $state injectable */
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 '$state':
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;
@@ -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
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
6
- import { parseHTML } from 'linkedom';
6
+ import { parseHTML } from 'linkedom/worker';
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
  *
@@ -120,6 +135,10 @@ export async function render(html, state, registry) {
120
135
  const matches = el.matches(selector) ? [el] : [];
121
136
  const descendants = [...el.querySelectorAll(selector)];
122
137
  for (const match of [...matches, ...descendants]) {
138
+ // Skip elements inside template content (used as placeholders)
139
+ if (match.closest('template')) {
140
+ continue;
141
+ }
123
142
  // Handle native <slot> elements
124
143
  if (match.tagName === 'SLOT') {
125
144
  index.push({
@@ -204,6 +223,27 @@ export async function render(html, state, registry) {
204
223
  const directives = byElement.get(el);
205
224
  // Sort directives on this element by priority (higher first)
206
225
  directives.sort((a, b) => b.priority - a.priority);
226
+ // Process g-scope first (inline scope initialization)
227
+ const scopeAttr = el.getAttribute('g-scope');
228
+ if (scopeAttr) {
229
+ const scopeValues = ctx.eval(scopeAttr);
230
+ if (scopeValues && typeof scopeValues === 'object') {
231
+ Object.assign(state, scopeValues);
232
+ }
233
+ }
234
+ // Process g-bind:* attributes (dynamic attribute binding)
235
+ for (const attr of [...el.attributes]) {
236
+ if (attr.name.startsWith('g-bind:')) {
237
+ const targetAttr = attr.name.slice('g-bind:'.length);
238
+ const value = ctx.eval(attr.value);
239
+ if (value === null || value === undefined) {
240
+ el.removeAttribute(targetAttr);
241
+ }
242
+ else {
243
+ el.setAttribute(targetAttr, String(value));
244
+ }
245
+ }
246
+ }
207
247
  for (const item of directives) {
208
248
  // Check if element was disconnected by a previous directive (e.g., g-for replacing it)
209
249
  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
- $state: Record<string, unknown>;
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', '$state']>;
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
- * - `$state`: Local reactive state object (isolated per element)
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', '$state'];
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 `$state` becomes
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 = ($state) => {
182
- * $state.mode = 'dark';
181
+ * const themeProvider: Directive = ($scope) => {
182
+ * $scope.mode = 'dark';
183
183
  * };
184
- * themeProvider.$inject = ['$state'];
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, $state, theme, user) => {
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, $state) => {
376
- * $state.todos = [];
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, $state) => {
53
- * $state.todos = [];
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",
3
+ "version": "0.2.0",
4
4
  "description": "A lightweight, SSR-first reactive UI library with declarative directives",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
  }