gonia 0.1.2 → 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
@@ -9,7 +9,9 @@ import { processNativeSlot } from '../directives/slot.js';
9
9
  import { getLocalState, registerProvider, resolveFromProviders, registerDIProviders, resolveFromDIProviders } from '../providers.js';
10
10
  import { FOR_PROCESSED_ATTR } from '../directives/for.js';
11
11
  import { findParentScope, createElementScope, getElementScope } from '../scope.js';
12
- import { getInjectables } from '../inject.js';
12
+ import { resolveDependencies as resolveInjectables } from '../inject.js';
13
+ import { resolveContext } from '../context-registry.js';
14
+ import { effect } from '../reactivity.js';
13
15
  // Built-in directives
14
16
  import { text } from '../directives/text.js';
15
17
  import { show } from '../directives/show.js';
@@ -59,6 +61,10 @@ function getSelector(registry) {
59
61
  }
60
62
  // Also match native <slot> elements
61
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]');
62
68
  cachedSelector = directiveSelectors.join(',');
63
69
  }
64
70
  return cachedSelector;
@@ -73,7 +79,14 @@ function getDirectivesForElement(el, registry) {
73
79
  for (const [name, directive] of registry) {
74
80
  const attr = el.getAttribute(`g-${name}`);
75
81
  if (attr !== null) {
76
- directives.push({ name, directive, expr: attr });
82
+ // Look up options from the global directive registry
83
+ const registration = getDirective(`g-${name}`);
84
+ directives.push({
85
+ name,
86
+ directive,
87
+ expr: attr,
88
+ using: registration?.options.using
89
+ });
77
90
  }
78
91
  }
79
92
  // Sort by priority (higher first)
@@ -124,48 +137,28 @@ export function setElementContext(el, ctx) {
124
137
  contextCache.set(el, ctx);
125
138
  }
126
139
  /**
127
- * Resolve dependencies for a directive based on its $inject array.
140
+ * Create resolver config for client-side dependency resolution.
128
141
  *
129
142
  * @internal
130
143
  */
131
- function resolveDependencies(directive, expr, el, ctx) {
132
- const inject = getInjectables(directive);
133
- return inject.map(name => {
134
- switch (name) {
135
- case '$expr':
136
- return expr;
137
- case '$element':
138
- return el;
139
- case '$eval':
140
- return ctx.eval.bind(ctx);
141
- case '$state':
142
- // Find nearest ancestor scope (including self)
143
- return findParentScope(el, true) ?? getLocalState(el);
144
- case '$rootState':
145
- // Deprecated: same as $state now (scoped state)
146
- return findParentScope(el, true) ?? getLocalState(el);
147
- case '$mode':
148
- return Mode.CLIENT;
149
- default: {
150
- // Look up in ancestor DI providers first (provide option)
151
- const diProvided = resolveFromDIProviders(el, name);
152
- if (diProvided !== undefined) {
153
- return diProvided;
154
- }
155
- // Look up in global services registry
156
- const service = services.get(name);
157
- if (service !== undefined) {
158
- return service;
159
- }
160
- // Look up in ancestor context providers ($context)
161
- const contextProvided = resolveFromProviders(el, name);
162
- if (contextProvided !== undefined) {
163
- return contextProvided;
164
- }
165
- throw new Error(`Unknown injectable: ${name}`);
166
- }
167
- }
168
- });
144
+ function createClientResolverConfig(el, ctx) {
145
+ return {
146
+ resolveContext: (key) => resolveContext(el, key),
147
+ resolveState: () => findParentScope(el, true) ?? getLocalState(el),
148
+ resolveCustom: (name) => {
149
+ // Look up in ancestor DI providers first (provide option)
150
+ const diProvided = resolveFromDIProviders(el, name);
151
+ if (diProvided !== undefined)
152
+ return diProvided;
153
+ // Look up in global services registry
154
+ const service = services.get(name);
155
+ if (service !== undefined)
156
+ return service;
157
+ // Look up in ancestor context providers ($context)
158
+ return resolveFromProviders(el, name);
159
+ },
160
+ mode: 'client'
161
+ };
169
162
  }
170
163
  /**
171
164
  * Process directives on a single element.
@@ -184,15 +177,60 @@ function processElement(el, registry) {
184
177
  processNativeSlot(el);
185
178
  return;
186
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
+ }
187
196
  const directives = getDirectivesForElement(el, registry);
188
- 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)
189
201
  return;
190
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
+ }
191
228
  // Process directives sequentially, handling async ones properly
192
229
  let chain;
193
- for (const { directive, expr } of directives) {
230
+ for (const { directive, expr, using } of directives) {
194
231
  const processDirective = () => {
195
- const args = resolveDependencies(directive, expr, el, ctx);
232
+ const config = createClientResolverConfig(el, ctx);
233
+ const args = resolveInjectables(directive, expr, el, ctx.eval.bind(ctx), config, using);
196
234
  const result = directive(...args);
197
235
  // Register as provider if directive declares $context
198
236
  if (directive.$context?.length) {
@@ -330,8 +368,8 @@ async function processDirectiveElements() {
330
368
  }
331
369
  const { fn, options } = registration;
332
370
  // Only process directives with templates (web components),
333
- // scope: true, or provide (DI overrides)
334
- if (!options.template && !options.scope && !options.provide) {
371
+ // scope: true, provide (DI overrides), or using (context dependencies)
372
+ if (!options.template && !options.scope && !options.provide && !options.using) {
335
373
  continue;
336
374
  }
337
375
  // Find all elements matching this directive's tag name
@@ -346,6 +384,10 @@ async function processDirectiveElements() {
346
384
  if (options.scope) {
347
385
  const parentScope = findParentScope(el);
348
386
  scope = createElementScope(el, parentScope);
387
+ // Apply assigned values to scope
388
+ if (options.assign) {
389
+ Object.assign(scope, options.assign);
390
+ }
349
391
  }
350
392
  else {
351
393
  scope = findParentScope(el, true) ?? {};
@@ -357,19 +399,12 @@ async function processDirectiveElements() {
357
399
  // 3. Call directive function if present (initializes state)
358
400
  if (fn) {
359
401
  const ctx = createContext(Mode.CLIENT, scope);
360
- const inject = getInjectables(fn);
361
- const args = inject.map((dep) => {
362
- switch (dep) {
363
- case '$element':
364
- return el;
365
- case '$state':
366
- return scope;
367
- case '$eval':
368
- return ctx.eval.bind(ctx);
369
- default:
370
- return undefined;
371
- }
372
- });
402
+ const config = {
403
+ resolveContext: (key) => resolveContext(el, key),
404
+ resolveState: () => scope,
405
+ mode: 'client'
406
+ };
407
+ const args = resolveInjectables(fn, '', el, ctx.eval.bind(ctx), config, options.using);
373
408
  const result = fn(...args);
374
409
  if (result instanceof Promise) {
375
410
  await result;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Type-safe context registry for sharing data across DOM ancestors/descendants.
3
+ *
4
+ * @remarks
5
+ * Provides a unified system for registering and resolving typed context values
6
+ * on DOM elements. Similar to React's Context or Vue's provide/inject but with
7
+ * full type safety through branded context keys.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ /**
12
+ * A branded context key that provides type safety for context values.
13
+ *
14
+ * @typeParam T - The type of value this context holds
15
+ *
16
+ * @remarks
17
+ * The `__type` property is a phantom type - it doesn't exist at runtime
18
+ * but provides compile-time type checking when registering and resolving.
19
+ */
20
+ export interface ContextKey<T> {
21
+ /** Unique identifier for this context */
22
+ readonly id: symbol;
23
+ /** Debug name for error messages */
24
+ readonly name: string;
25
+ /** Phantom type for TypeScript inference */
26
+ readonly __type?: T;
27
+ }
28
+ /**
29
+ * Create a typed context key.
30
+ *
31
+ * @typeParam T - The type of value this context will hold
32
+ * @param name - Debug name for the context (used in error messages)
33
+ * @returns A unique context key
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * interface UserData {
38
+ * name: string;
39
+ * email: string;
40
+ * }
41
+ *
42
+ * const UserContext = createContextKey<UserData>('User');
43
+ *
44
+ * // Register on an element
45
+ * registerContext(el, UserContext, { name: 'Alice', email: 'alice@example.com' });
46
+ *
47
+ * // Resolve from a descendant - fully typed!
48
+ * const user = resolveContext(childEl, UserContext);
49
+ * // user is UserData | undefined
50
+ * ```
51
+ */
52
+ export declare function createContextKey<T>(name: string): ContextKey<T>;
53
+ /**
54
+ * Register a context value on an element.
55
+ *
56
+ * @typeParam T - The context value type (inferred from key)
57
+ * @param el - The element to register the context on
58
+ * @param key - The context key
59
+ * @param value - The value to store
60
+ *
61
+ * @remarks
62
+ * Descendants can resolve this context using `resolveContext`.
63
+ * If a context with the same key is already registered on this element,
64
+ * it will be overwritten.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * const ThemeContext = createContextKey<{ mode: 'light' | 'dark' }>('Theme');
69
+ *
70
+ * registerContext(rootEl, ThemeContext, { mode: 'dark' });
71
+ * ```
72
+ */
73
+ export declare function registerContext<T>(el: Element, key: ContextKey<T>, value: T): void;
74
+ /**
75
+ * Resolve a context value from an ancestor element.
76
+ *
77
+ * @typeParam T - The context value type (inferred from key)
78
+ * @param el - The element to start searching from
79
+ * @param key - The context key to look for
80
+ * @param includeSelf - Whether to check the element itself (default: false)
81
+ * @returns The context value, or undefined if not found
82
+ *
83
+ * @remarks
84
+ * Walks up the DOM tree from the element's parent (or the element itself
85
+ * if `includeSelf` is true), looking for an ancestor with the specified
86
+ * context registered.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * const ThemeContext = createContextKey<{ mode: 'light' | 'dark' }>('Theme');
91
+ *
92
+ * // Somewhere up the tree, ThemeContext was registered
93
+ * const theme = resolveContext(el, ThemeContext);
94
+ * if (theme) {
95
+ * console.log(theme.mode); // 'light' or 'dark'
96
+ * }
97
+ * ```
98
+ */
99
+ export declare function resolveContext<T>(el: Element, key: ContextKey<T>, includeSelf?: boolean): T | undefined;
100
+ /**
101
+ * Check if a context is registered on an element.
102
+ *
103
+ * @param el - The element to check
104
+ * @param key - The context key
105
+ * @returns True if the context is registered on this specific element
106
+ *
107
+ * @remarks
108
+ * This only checks the element itself, not ancestors.
109
+ * Use `resolveContext` to search up the tree.
110
+ */
111
+ export declare function hasContext<T>(el: Element, key: ContextKey<T>): boolean;
112
+ /**
113
+ * Remove a context from an element.
114
+ *
115
+ * @param el - The element to remove the context from
116
+ * @param key - The context key to remove
117
+ *
118
+ * @remarks
119
+ * Does nothing if the context wasn't registered.
120
+ */
121
+ export declare function removeContext<T>(el: Element, key: ContextKey<T>): void;
122
+ /**
123
+ * Clear all contexts from an element.
124
+ *
125
+ * @param el - The element to clear
126
+ *
127
+ * @remarks
128
+ * Called during element cleanup/removal.
129
+ */
130
+ export declare function clearContexts(el: Element): void;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Type-safe context registry for sharing data across DOM ancestors/descendants.
3
+ *
4
+ * @remarks
5
+ * Provides a unified system for registering and resolving typed context values
6
+ * on DOM elements. Similar to React's Context or Vue's provide/inject but with
7
+ * full type safety through branded context keys.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import { findAncestor } from './dom.js';
12
+ /**
13
+ * Storage for all contexts per element.
14
+ *
15
+ * @internal
16
+ */
17
+ const contexts = new WeakMap();
18
+ /**
19
+ * Create a typed context key.
20
+ *
21
+ * @typeParam T - The type of value this context will hold
22
+ * @param name - Debug name for the context (used in error messages)
23
+ * @returns A unique context key
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * interface UserData {
28
+ * name: string;
29
+ * email: string;
30
+ * }
31
+ *
32
+ * const UserContext = createContextKey<UserData>('User');
33
+ *
34
+ * // Register on an element
35
+ * registerContext(el, UserContext, { name: 'Alice', email: 'alice@example.com' });
36
+ *
37
+ * // Resolve from a descendant - fully typed!
38
+ * const user = resolveContext(childEl, UserContext);
39
+ * // user is UserData | undefined
40
+ * ```
41
+ */
42
+ export function createContextKey(name) {
43
+ return {
44
+ id: Symbol(name),
45
+ name,
46
+ };
47
+ }
48
+ /**
49
+ * Register a context value on an element.
50
+ *
51
+ * @typeParam T - The context value type (inferred from key)
52
+ * @param el - The element to register the context on
53
+ * @param key - The context key
54
+ * @param value - The value to store
55
+ *
56
+ * @remarks
57
+ * Descendants can resolve this context using `resolveContext`.
58
+ * If a context with the same key is already registered on this element,
59
+ * it will be overwritten.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * const ThemeContext = createContextKey<{ mode: 'light' | 'dark' }>('Theme');
64
+ *
65
+ * registerContext(rootEl, ThemeContext, { mode: 'dark' });
66
+ * ```
67
+ */
68
+ export function registerContext(el, key, value) {
69
+ let map = contexts.get(el);
70
+ if (!map) {
71
+ map = new Map();
72
+ contexts.set(el, map);
73
+ }
74
+ map.set(key.id, value);
75
+ }
76
+ /**
77
+ * Resolve a context value from an ancestor element.
78
+ *
79
+ * @typeParam T - The context value type (inferred from key)
80
+ * @param el - The element to start searching from
81
+ * @param key - The context key to look for
82
+ * @param includeSelf - Whether to check the element itself (default: false)
83
+ * @returns The context value, or undefined if not found
84
+ *
85
+ * @remarks
86
+ * Walks up the DOM tree from the element's parent (or the element itself
87
+ * if `includeSelf` is true), looking for an ancestor with the specified
88
+ * context registered.
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * const ThemeContext = createContextKey<{ mode: 'light' | 'dark' }>('Theme');
93
+ *
94
+ * // Somewhere up the tree, ThemeContext was registered
95
+ * const theme = resolveContext(el, ThemeContext);
96
+ * if (theme) {
97
+ * console.log(theme.mode); // 'light' or 'dark'
98
+ * }
99
+ * ```
100
+ */
101
+ export function resolveContext(el, key, includeSelf = false) {
102
+ return findAncestor(el, (e) => {
103
+ const map = contexts.get(e);
104
+ if (map?.has(key.id)) {
105
+ return map.get(key.id);
106
+ }
107
+ return undefined;
108
+ }, includeSelf);
109
+ }
110
+ /**
111
+ * Check if a context is registered on an element.
112
+ *
113
+ * @param el - The element to check
114
+ * @param key - The context key
115
+ * @returns True if the context is registered on this specific element
116
+ *
117
+ * @remarks
118
+ * This only checks the element itself, not ancestors.
119
+ * Use `resolveContext` to search up the tree.
120
+ */
121
+ export function hasContext(el, key) {
122
+ const map = contexts.get(el);
123
+ return map?.has(key.id) ?? false;
124
+ }
125
+ /**
126
+ * Remove a context from an element.
127
+ *
128
+ * @param el - The element to remove the context from
129
+ * @param key - The context key to remove
130
+ *
131
+ * @remarks
132
+ * Does nothing if the context wasn't registered.
133
+ */
134
+ export function removeContext(el, key) {
135
+ const map = contexts.get(el);
136
+ if (map) {
137
+ map.delete(key.id);
138
+ if (map.size === 0) {
139
+ contexts.delete(el);
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * Clear all contexts from an element.
145
+ *
146
+ * @param el - The element to clear
147
+ *
148
+ * @remarks
149
+ * Called during element cleanup/removal.
150
+ */
151
+ export function clearContexts(el) {
152
+ contexts.delete(el);
153
+ }
@@ -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']>;