gonia 0.3.0 → 0.3.2

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,35 @@ 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 unique directive names for conflict detection
232
+ const directiveNameSet = new Set();
233
+ for (const { name } of directives) {
234
+ const fullName = `g-${name}`;
235
+ const isNew = !directiveNameSet.has(fullName);
236
+ directiveNameSet.add(fullName);
237
+ // Only process first occurrence
238
+ if (!isNew)
239
+ continue;
240
+ const registration = getDirective(fullName);
241
+ if (!directiveCreatedScope && directiveNeedsScope(fullName)) {
242
+ // Create a new scope that inherits from parent
243
+ scope = createElementScope(el, scope);
244
+ directiveCreatedScope = true;
245
+ }
246
+ // Register DI providers if present
247
+ if (registration?.options.provide) {
248
+ registerDIProviders(el, registration.options.provide);
249
+ }
250
+ }
251
+ // Apply assigns with conflict detection
252
+ if (directiveCreatedScope) {
253
+ applyAssigns(scope, [...directiveNameSet]);
254
+ }
255
+ const ctx = createContext(Mode.CLIENT, scope);
256
+ contextCache.set(el, ctx);
204
257
  // Process g-scope first (inline scope initialization)
205
258
  if (hasScopeAttr) {
206
259
  const scopeAttr = el.getAttribute('g-scope');
@@ -384,10 +437,16 @@ async function processDirectiveElements() {
384
437
  if (options.scope) {
385
438
  const parentScope = findParentScope(el);
386
439
  scope = createElementScope(el, parentScope);
387
- // Apply assigned values to scope
388
- if (options.assign) {
389
- Object.assign(scope, options.assign);
440
+ // Collect unique directive names on this element for conflict detection
441
+ const directiveNameSet = new Set([name]);
442
+ for (const attr of el.attributes) {
443
+ const attrReg = getDirective(attr.name);
444
+ if (attrReg) {
445
+ directiveNameSet.add(attr.name);
446
+ }
390
447
  }
448
+ // Apply assigns with conflict detection
449
+ applyAssigns(scope, [...directiveNameSet]);
391
450
  }
392
451
  else {
393
452
  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
  *
@@ -65,6 +66,22 @@ function getSelector(localRegistry) {
65
66
  selectors.push('slot');
66
67
  // Match g-scope for inline scope initialization (TODO: make prefix configurable)
67
68
  selectors.push('[g-scope]');
69
+ // Match common g-bind:* attributes for dynamic binding
70
+ // These need to be indexed so their expressions can be evaluated with proper scope
71
+ selectors.push('[g-bind\\:class]');
72
+ selectors.push('[g-bind\\:style]');
73
+ selectors.push('[g-bind\\:href]');
74
+ selectors.push('[g-bind\\:src]');
75
+ selectors.push('[g-bind\\:id]');
76
+ selectors.push('[g-bind\\:value]');
77
+ selectors.push('[g-bind\\:disabled]');
78
+ selectors.push('[g-bind\\:checked]');
79
+ selectors.push('[g-bind\\:placeholder]');
80
+ selectors.push('[g-bind\\:title]');
81
+ selectors.push('[g-bind\\:alt]');
82
+ selectors.push('[g-bind\\:name]');
83
+ selectors.push('[g-bind\\:type]');
84
+ // Note: Can't do wildcard for data-* attributes in CSS, but hasBindAttributes handles them
68
85
  return selectors.join(',');
69
86
  }
70
87
  /**
@@ -189,7 +206,23 @@ export async function render(html, state, registry) {
189
206
  const window = new Window();
190
207
  const document = window.document;
191
208
  const index = [];
209
+ const indexedDirectives = new Map(); // Track indexed (element, directive) pairs
192
210
  const selector = getSelector(registry);
211
+ // Helper to add to index only if not already indexed for this (element, directive) pair
212
+ const addToIndex = (item) => {
213
+ const existing = indexedDirectives.get(item.el);
214
+ if (existing?.has(item.name)) {
215
+ return false; // Already indexed
216
+ }
217
+ if (!existing) {
218
+ indexedDirectives.set(item.el, new Set([item.name]));
219
+ }
220
+ else {
221
+ existing.add(item.name);
222
+ }
223
+ index.push(item);
224
+ return true;
225
+ };
193
226
  const observer = new window.MutationObserver((mutations) => {
194
227
  for (const mutation of mutations) {
195
228
  for (const node of mutation.addedNodes) {
@@ -205,7 +238,7 @@ export async function render(html, state, registry) {
205
238
  }
206
239
  // Handle native <slot> elements
207
240
  if (match.tagName === 'SLOT') {
208
- index.push({
241
+ addToIndex({
209
242
  el: match,
210
243
  name: 'slot',
211
244
  directive: null,
@@ -226,7 +259,7 @@ export async function render(html, state, registry) {
226
259
  }
227
260
  }
228
261
  if (!hasDirective) {
229
- index.push({
262
+ addToIndex({
230
263
  el: match,
231
264
  name: 'scope',
232
265
  directive: null,
@@ -236,6 +269,27 @@ export async function render(html, state, registry) {
236
269
  });
237
270
  }
238
271
  }
272
+ // Handle g-bind:* elements that don't have other directives
273
+ // Add a placeholder so they get processed for dynamic attribute binding
274
+ if (hasBindAttributes(match)) {
275
+ let hasDirective = false;
276
+ for (const name of getDirectiveNames()) {
277
+ if (match.hasAttribute(name)) {
278
+ hasDirective = true;
279
+ break;
280
+ }
281
+ }
282
+ if (!hasDirective && !match.hasAttribute('g-scope')) {
283
+ addToIndex({
284
+ el: match,
285
+ name: 'bind',
286
+ directive: null,
287
+ expr: '',
288
+ priority: DirectivePriority.NORMAL,
289
+ isNativeSlot: false
290
+ });
291
+ }
292
+ }
239
293
  // Check all registered directives from global registry
240
294
  const tagName = match.tagName.toLowerCase();
241
295
  for (const name of getDirectiveNames()) {
@@ -246,7 +300,7 @@ export async function render(html, state, registry) {
246
300
  // Check if this is a custom element directive (tag name matches)
247
301
  if (tagName === name) {
248
302
  if (options.template || options.scope || options.provide || options.using) {
249
- index.push({
303
+ addToIndex({
250
304
  el: match,
251
305
  name,
252
306
  directive: fn,
@@ -260,7 +314,7 @@ export async function render(html, state, registry) {
260
314
  // Check if this is an attribute directive
261
315
  const attr = match.getAttribute(name);
262
316
  if (attr !== null) {
263
- index.push({
317
+ addToIndex({
264
318
  el: match,
265
319
  name,
266
320
  directive: fn,
@@ -279,7 +333,7 @@ export async function render(html, state, registry) {
279
333
  const fullName = `g-${name}`;
280
334
  if (getDirective(fullName))
281
335
  continue;
282
- index.push({
336
+ addToIndex({
283
337
  el: match,
284
338
  name,
285
339
  directive,
@@ -354,10 +408,13 @@ export async function render(html, state, registry) {
354
408
  }
355
409
  }
356
410
  // Process g-bind:* attributes (dynamic attribute binding)
411
+ // Use the nearest ancestor scope for evaluation
412
+ const bindScope = findServerScope(el, state);
413
+ const bindCtx = createContext(Mode.SERVER, bindScope);
357
414
  for (const attr of [...el.attributes]) {
358
415
  if (attr.name.startsWith('g-bind:')) {
359
416
  const targetAttr = attr.name.slice('g-bind:'.length);
360
- const value = ctx.eval(decodeHTMLEntities(attr.value));
417
+ const value = bindCtx.eval(decodeHTMLEntities(attr.value));
361
418
  if (value === null || value === undefined) {
362
419
  el.removeAttribute(targetAttr);
363
420
  }
@@ -366,6 +423,25 @@ export async function render(html, state, registry) {
366
423
  }
367
424
  }
368
425
  }
426
+ // Collect unique directive names for conflict detection
427
+ const directiveNameSet = new Set();
428
+ for (const item of directives) {
429
+ if (!item.isNativeSlot && item.directive !== null) {
430
+ directiveNameSet.add(item.name);
431
+ }
432
+ }
433
+ const directiveNames = [...directiveNameSet];
434
+ // Check if any directive needs scope - create once if so
435
+ let elementScope = null;
436
+ for (const name of directiveNames) {
437
+ if (directiveNeedsScope(name)) {
438
+ const parentScope = findServerScope(el, state);
439
+ elementScope = createElementScope(el, parentScope);
440
+ // Apply all assigns with conflict detection
441
+ applyAssigns(elementScope, directiveNames);
442
+ break;
443
+ }
444
+ }
369
445
  for (const item of directives) {
370
446
  // Check if element was disconnected by a previous directive (e.g., g-for replacing it)
371
447
  if (!item.el.isConnected) {
@@ -382,24 +458,13 @@ export async function render(html, state, registry) {
382
458
  if (!registration)
383
459
  continue;
384
460
  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
461
+ // Use pre-created scope or find existing
462
+ const scopeState = elementScope ?? findServerScope(item.el, state);
463
+ // Register DI providers if present
399
464
  if (options.provide) {
400
465
  registerDIProviders(item.el, options.provide);
401
466
  }
402
- // 3. Call directive function if present (initializes state)
467
+ // Call directive function if present (initializes state)
403
468
  if (fn) {
404
469
  const config = createServerResolverConfig(item.el, scopeState, state);
405
470
  const args = resolveInjectables(fn, item.expr, item.el, ctx.eval.bind(ctx), config, options.using);
@@ -409,7 +474,7 @@ export async function render(html, state, registry) {
409
474
  registerProvider(item.el, fn, scopeState);
410
475
  }
411
476
  }
412
- // 4. Render template if present
477
+ // Render template if present
413
478
  if (options.template) {
414
479
  const attrs = getTemplateAttrs(item.el);
415
480
  let html;
@@ -428,17 +493,14 @@ export async function render(html, state, registry) {
428
493
  continue;
429
494
  }
430
495
  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);
496
+ // Attribute directive - use pre-created scope or find existing
497
+ const scopeState = elementScope ?? findServerScope(item.el, state);
498
+ // Get registration options
499
+ const registration = getDirective(item.name);
500
+ const options = registration?.options ?? {};
501
+ // Register DI providers if present
502
+ if (options.provide) {
503
+ registerDIProviders(item.el, options.provide);
442
504
  }
443
505
  const config = createServerResolverConfig(item.el, scopeState, state);
444
506
  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.2",
4
4
  "description": "A lightweight, SSR-first reactive UI library with declarative directives",
5
5
  "type": "module",
6
6
  "license": "MIT",