gonia 0.3.0 → 0.3.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.
@@ -12,6 +12,7 @@ import { findParentScope, createElementScope, getElementScope } from '../scope.j
12
12
  import { resolveDependencies as resolveInjectables } from '../inject.js';
13
13
  import { resolveContext } from '../context-registry.js';
14
14
  import { effect } from '../reactivity.js';
15
+ import { applyAssigns, directiveNeedsScope } from '../directive-utils.js';
15
16
  // Built-in directives
16
17
  import { text } from '../directives/text.js';
17
18
  import { show } from '../directives/show.js';
@@ -56,15 +57,22 @@ function getDefaultRegistry() {
56
57
  function getSelector(registry) {
57
58
  if (!cachedSelector) {
58
59
  const directiveSelectors = [];
60
+ // Include directives from passed registry
59
61
  for (const name of registry.keys()) {
60
62
  directiveSelectors.push(`[g-${name}]`);
61
63
  }
64
+ // Include directives from global registry
65
+ for (const name of getDirectiveNames()) {
66
+ directiveSelectors.push(`[${name}]`);
67
+ }
62
68
  // Also match native <slot> elements
63
69
  directiveSelectors.push('slot');
64
70
  // Match template placeholders from SSR (g-if with false condition)
65
71
  directiveSelectors.push('template[data-g-if]');
66
72
  // Match g-scope for inline scope initialization
67
73
  directiveSelectors.push('[g-scope]');
74
+ // Match g-bind:* for attribute bindings
75
+ directiveSelectors.push('[g-bind\\:class]');
68
76
  cachedSelector = directiveSelectors.join(',');
69
77
  }
70
78
  return cachedSelector;
@@ -76,10 +84,10 @@ function getSelector(registry) {
76
84
  */
77
85
  function getDirectivesForElement(el, registry) {
78
86
  const directives = [];
87
+ // Check local registry (built-in directives with g- prefix)
79
88
  for (const [name, directive] of registry) {
80
89
  const attr = el.getAttribute(`g-${name}`);
81
90
  if (attr !== null) {
82
- // Look up options from the global directive registry
83
91
  const registration = getDirective(`g-${name}`);
84
92
  directives.push({
85
93
  name,
@@ -89,6 +97,24 @@ function getDirectivesForElement(el, registry) {
89
97
  });
90
98
  }
91
99
  }
100
+ // Check global registry (custom directives)
101
+ for (const name of getDirectiveNames()) {
102
+ // Skip if already matched via local registry
103
+ if (directives.some(d => `g-${d.name}` === name))
104
+ continue;
105
+ const attr = el.getAttribute(name);
106
+ if (attr !== null) {
107
+ const registration = getDirective(name);
108
+ if (registration?.fn) {
109
+ directives.push({
110
+ name: name.replace(/^g-/, ''),
111
+ directive: registration.fn,
112
+ expr: attr,
113
+ using: registration.options.using
114
+ });
115
+ }
116
+ }
117
+ }
92
118
  // Sort by priority (higher first)
93
119
  directives.sort((a, b) => {
94
120
  const priorityA = a.directive.priority ?? DirectivePriority.NORMAL;
@@ -199,8 +225,31 @@ function processElement(el, registry) {
199
225
  // Skip if nothing to process
200
226
  if (directives.length === 0 && !hasScopeAttr && !hasBindAttrs)
201
227
  return;
202
- const ctx = getContextForElement(el);
203
- const scope = findParentScope(el, true) ?? {};
228
+ // Check if any directive needs a scope
229
+ let scope = findParentScope(el, true) ?? {};
230
+ let directiveCreatedScope = false;
231
+ // Collect full directive names for conflict detection
232
+ const directiveFullNames = [];
233
+ for (const { name } of directives) {
234
+ const fullName = `g-${name}`;
235
+ directiveFullNames.push(fullName);
236
+ const registration = getDirective(fullName);
237
+ if (!directiveCreatedScope && directiveNeedsScope(fullName)) {
238
+ // Create a new scope that inherits from parent
239
+ scope = createElementScope(el, scope);
240
+ directiveCreatedScope = true;
241
+ }
242
+ // Register DI providers if present
243
+ if (registration?.options.provide) {
244
+ registerDIProviders(el, registration.options.provide);
245
+ }
246
+ }
247
+ // Apply assigns with conflict detection
248
+ if (directiveCreatedScope) {
249
+ applyAssigns(scope, directiveFullNames);
250
+ }
251
+ const ctx = createContext(Mode.CLIENT, scope);
252
+ contextCache.set(el, ctx);
204
253
  // Process g-scope first (inline scope initialization)
205
254
  if (hasScopeAttr) {
206
255
  const scopeAttr = el.getAttribute('g-scope');
@@ -384,10 +433,16 @@ async function processDirectiveElements() {
384
433
  if (options.scope) {
385
434
  const parentScope = findParentScope(el);
386
435
  scope = createElementScope(el, parentScope);
387
- // Apply assigned values to scope
388
- if (options.assign) {
389
- Object.assign(scope, options.assign);
436
+ // Collect all directive names on this element for conflict detection
437
+ const directiveNames = [name];
438
+ for (const attr of el.attributes) {
439
+ const attrReg = getDirective(attr.name);
440
+ if (attrReg) {
441
+ directiveNames.push(attr.name);
442
+ }
390
443
  }
444
+ // Apply assigns with conflict detection
445
+ applyAssigns(scope, directiveNames);
391
446
  }
392
447
  else {
393
448
  scope = findParentScope(el, true) ?? {};
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared utilities for directive processing (client and server).
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ /**
7
+ * Information about an assign conflict.
8
+ */
9
+ export interface AssignConflict {
10
+ key: string;
11
+ directives: string[];
12
+ priorities: number[];
13
+ }
14
+ /**
15
+ * Result of resolving assigns from multiple directives.
16
+ */
17
+ export interface ResolvedAssigns {
18
+ /** The merged assign values (higher priority wins) */
19
+ values: Record<string, unknown>;
20
+ /** Warnings for different-priority conflicts */
21
+ warnings: string[];
22
+ }
23
+ /**
24
+ * Resolve assign values from multiple directives on the same element.
25
+ *
26
+ * @param directiveNames - Names of directives on the element
27
+ * @throws Error if same-priority directives conflict on an assign key
28
+ * @returns Resolved assigns and any warnings
29
+ */
30
+ export declare function resolveAssigns(directiveNames: string[]): ResolvedAssigns;
31
+ /**
32
+ * Apply resolved assigns to a scope, logging any warnings.
33
+ *
34
+ * @param scope - The scope to apply assigns to
35
+ * @param directiveNames - Names of directives on the element
36
+ * @returns The scope with assigns applied
37
+ */
38
+ export declare function applyAssigns(scope: Record<string, unknown>, directiveNames: string[]): Record<string, unknown>;
39
+ /**
40
+ * Check if a directive should create/use a scope based on its options.
41
+ */
42
+ export declare function directiveNeedsScope(name: string): boolean;
43
+ /**
44
+ * Get directive options with defaults.
45
+ */
46
+ export declare function getDirectiveOptions(name: string): import("./types.js").DirectiveOptions;
47
+ /**
48
+ * Get directive priority.
49
+ */
50
+ export declare function getDirectivePriority(name: string): number;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shared utilities for directive processing (client and server).
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { getDirective, DirectivePriority } from './types.js';
7
+ /**
8
+ * Resolve assign values from multiple directives on the same element.
9
+ *
10
+ * @param directiveNames - Names of directives on the element
11
+ * @throws Error if same-priority directives conflict on an assign key
12
+ * @returns Resolved assigns and any warnings
13
+ */
14
+ export function resolveAssigns(directiveNames) {
15
+ const assignsByKey = new Map();
16
+ // Collect all assigns grouped by key
17
+ for (const name of directiveNames) {
18
+ const registration = getDirective(name);
19
+ if (!registration?.options.assign)
20
+ continue;
21
+ const priority = registration.fn?.priority ?? DirectivePriority.NORMAL;
22
+ for (const [key, value] of Object.entries(registration.options.assign)) {
23
+ if (!assignsByKey.has(key)) {
24
+ assignsByKey.set(key, []);
25
+ }
26
+ assignsByKey.get(key).push({ directive: name, priority, value });
27
+ }
28
+ }
29
+ const values = {};
30
+ const warnings = [];
31
+ // Check for conflicts and resolve
32
+ for (const [key, sources] of assignsByKey) {
33
+ if (sources.length === 1) {
34
+ values[key] = sources[0].value;
35
+ continue;
36
+ }
37
+ // Group by priority
38
+ const byPriority = new Map();
39
+ for (const source of sources) {
40
+ if (!byPriority.has(source.priority)) {
41
+ byPriority.set(source.priority, []);
42
+ }
43
+ byPriority.get(source.priority).push(source);
44
+ }
45
+ // Check for same-priority conflicts
46
+ for (const [priority, group] of byPriority) {
47
+ if (group.length > 1) {
48
+ const names = group.map(s => s.directive).join(', ');
49
+ throw new Error(`Conflicting assign key "${key}" at same priority (${priority}) between directives: ${names}`);
50
+ }
51
+ }
52
+ // Different priorities - highest wins, emit warning
53
+ const sorted = sources.sort((a, b) => b.priority - a.priority);
54
+ const winner = sorted[0];
55
+ const losers = sorted.slice(1);
56
+ values[key] = winner.value;
57
+ for (const loser of losers) {
58
+ warnings.push(`Directive "${winner.directive}" (priority ${winner.priority}) overrides assign key "${key}" from "${loser.directive}" (priority ${loser.priority})`);
59
+ }
60
+ }
61
+ return { values, warnings };
62
+ }
63
+ /**
64
+ * Apply resolved assigns to a scope, logging any warnings.
65
+ *
66
+ * @param scope - The scope to apply assigns to
67
+ * @param directiveNames - Names of directives on the element
68
+ * @returns The scope with assigns applied
69
+ */
70
+ export function applyAssigns(scope, directiveNames) {
71
+ const { values, warnings } = resolveAssigns(directiveNames);
72
+ for (const warning of warnings) {
73
+ console.warn(`[gonia] ${warning}`);
74
+ }
75
+ Object.assign(scope, values);
76
+ return scope;
77
+ }
78
+ /**
79
+ * Check if a directive should create/use a scope based on its options.
80
+ */
81
+ export function directiveNeedsScope(name) {
82
+ const registration = getDirective(name);
83
+ if (!registration)
84
+ return false;
85
+ const { options, fn } = registration;
86
+ return !!(options.scope || options.assign || fn?.$context?.length);
87
+ }
88
+ /**
89
+ * Get directive options with defaults.
90
+ */
91
+ export function getDirectiveOptions(name) {
92
+ const registration = getDirective(name);
93
+ return registration?.options ?? {};
94
+ }
95
+ /**
96
+ * Get directive priority.
97
+ */
98
+ export function getDirectivePriority(name) {
99
+ const registration = getDirective(name);
100
+ return registration?.fn?.priority ?? DirectivePriority.NORMAL;
101
+ }
@@ -14,6 +14,7 @@ import { IF_PROCESSED_ATTR } from '../directives/if.js';
14
14
  import { PROCESSED_ATTR } from '../process.js';
15
15
  import { resolveDependencies as resolveInjectables } from '../inject.js';
16
16
  import { resolveContext } from '../context-registry.js';
17
+ import { applyAssigns, directiveNeedsScope } from '../directive-utils.js';
17
18
  /**
18
19
  * Decode HTML entities that happy-dom doesn't decode.
19
20
  *
@@ -354,10 +355,13 @@ export async function render(html, state, registry) {
354
355
  }
355
356
  }
356
357
  // Process g-bind:* attributes (dynamic attribute binding)
358
+ // Use the nearest ancestor scope for evaluation
359
+ const bindScope = findServerScope(el, state);
360
+ const bindCtx = createContext(Mode.SERVER, bindScope);
357
361
  for (const attr of [...el.attributes]) {
358
362
  if (attr.name.startsWith('g-bind:')) {
359
363
  const targetAttr = attr.name.slice('g-bind:'.length);
360
- const value = ctx.eval(decodeHTMLEntities(attr.value));
364
+ const value = bindCtx.eval(decodeHTMLEntities(attr.value));
361
365
  if (value === null || value === undefined) {
362
366
  el.removeAttribute(targetAttr);
363
367
  }
@@ -366,6 +370,24 @@ export async function render(html, state, registry) {
366
370
  }
367
371
  }
368
372
  }
373
+ // Collect all directive names for conflict detection
374
+ const directiveNames = [];
375
+ for (const item of directives) {
376
+ if (!item.isNativeSlot && item.directive !== null) {
377
+ directiveNames.push(item.name);
378
+ }
379
+ }
380
+ // Check if any directive needs scope - create once if so
381
+ let elementScope = null;
382
+ for (const name of directiveNames) {
383
+ if (directiveNeedsScope(name)) {
384
+ const parentScope = findServerScope(el, state);
385
+ elementScope = createElementScope(el, parentScope);
386
+ // Apply all assigns with conflict detection
387
+ applyAssigns(elementScope, directiveNames);
388
+ break;
389
+ }
390
+ }
369
391
  for (const item of directives) {
370
392
  // Check if element was disconnected by a previous directive (e.g., g-for replacing it)
371
393
  if (!item.el.isConnected) {
@@ -382,24 +404,13 @@ export async function render(html, state, registry) {
382
404
  if (!registration)
383
405
  continue;
384
406
  const { fn, options } = registration;
385
- // 1. Create scope if needed
386
- let scopeState;
387
- if (options.scope) {
388
- const parentScope = findServerScope(item.el, state);
389
- scopeState = createElementScope(item.el, parentScope);
390
- // Apply assigned values to scope
391
- if (options.assign) {
392
- Object.assign(scopeState, options.assign);
393
- }
394
- }
395
- else {
396
- scopeState = findServerScope(item.el, state);
397
- }
398
- // 2. Register DI providers if present
407
+ // Use pre-created scope or find existing
408
+ const scopeState = elementScope ?? findServerScope(item.el, state);
409
+ // Register DI providers if present
399
410
  if (options.provide) {
400
411
  registerDIProviders(item.el, options.provide);
401
412
  }
402
- // 3. Call directive function if present (initializes state)
413
+ // Call directive function if present (initializes state)
403
414
  if (fn) {
404
415
  const config = createServerResolverConfig(item.el, scopeState, state);
405
416
  const args = resolveInjectables(fn, item.expr, item.el, ctx.eval.bind(ctx), config, options.using);
@@ -409,7 +420,7 @@ export async function render(html, state, registry) {
409
420
  registerProvider(item.el, fn, scopeState);
410
421
  }
411
422
  }
412
- // 4. Render template if present
423
+ // Render template if present
413
424
  if (options.template) {
414
425
  const attrs = getTemplateAttrs(item.el);
415
426
  let html;
@@ -428,17 +439,14 @@ export async function render(html, state, registry) {
428
439
  continue;
429
440
  }
430
441
  else {
431
- // Attribute directive
432
- // Determine scope for this directive
433
- let scopeState;
434
- if (item.directive.$context?.length) {
435
- // Directives with $context get their own scope to populate
436
- const parentScope = findServerScope(item.el, state);
437
- scopeState = createElementScope(item.el, parentScope);
438
- }
439
- else {
440
- // Other directives use nearest ancestor scope or root
441
- scopeState = findServerScope(item.el, state);
442
+ // Attribute directive - use pre-created scope or find existing
443
+ const scopeState = elementScope ?? findServerScope(item.el, state);
444
+ // Get registration options
445
+ const registration = getDirective(item.name);
446
+ const options = registration?.options ?? {};
447
+ // Register DI providers if present
448
+ if (options.provide) {
449
+ registerDIProviders(item.el, options.provide);
442
450
  }
443
451
  const config = createServerResolverConfig(item.el, scopeState, state);
444
452
  const args = resolveInjectables(item.directive, item.expr, item.el, ctx.eval.bind(ctx), config, item.using);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gonia",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A lightweight, SSR-first reactive UI library with declarative directives",
5
5
  "type": "module",
6
6
  "license": "MIT",