gonia 0.3.2 → 0.3.4

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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared g-bind attribute processing utilities.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { Expression, Context } from './types.js';
7
+ /**
8
+ * Apply a single g-bind attribute value to an element.
9
+ *
10
+ * @param el - The element to update
11
+ * @param targetAttr - The attribute name to set (without g-bind: prefix)
12
+ * @param value - The evaluated value
13
+ */
14
+ export declare function applyBindValue(el: Element, targetAttr: string, value: unknown): void;
15
+ /**
16
+ * Process all g-bind:* attributes on an element.
17
+ * For server-side rendering (one-time evaluation).
18
+ *
19
+ * @param el - The element to process
20
+ * @param ctx - The context for expression evaluation
21
+ * @param decode - Whether to decode HTML entities (for server-side)
22
+ */
23
+ export declare function processBindAttributesOnce(el: Element, ctx: Context, decode?: boolean): void;
24
+ /**
25
+ * Get all g-bind attributes from an element.
26
+ * Used by client to set up reactive bindings.
27
+ *
28
+ * @param el - The element to get bindings from
29
+ * @returns Array of [targetAttr, expression] pairs
30
+ */
31
+ export declare function getBindAttributes(el: Element): Array<[string, Expression]>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared g-bind attribute processing utilities.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { decodeHTMLEntities } from './template-utils.js';
7
+ /**
8
+ * Apply a single g-bind attribute value to an element.
9
+ *
10
+ * @param el - The element to update
11
+ * @param targetAttr - The attribute name to set (without g-bind: prefix)
12
+ * @param value - The evaluated value
13
+ */
14
+ export function applyBindValue(el, targetAttr, value) {
15
+ if (value === null || value === undefined) {
16
+ el.removeAttribute(targetAttr);
17
+ }
18
+ else {
19
+ el.setAttribute(targetAttr, String(value));
20
+ }
21
+ }
22
+ /**
23
+ * Process all g-bind:* attributes on an element.
24
+ * For server-side rendering (one-time evaluation).
25
+ *
26
+ * @param el - The element to process
27
+ * @param ctx - The context for expression evaluation
28
+ * @param decode - Whether to decode HTML entities (for server-side)
29
+ */
30
+ export function processBindAttributesOnce(el, ctx, decode = false) {
31
+ for (const attr of [...el.attributes]) {
32
+ if (attr.name.startsWith('g-bind:')) {
33
+ const targetAttr = attr.name.slice('g-bind:'.length);
34
+ const expr = decode ? decodeHTMLEntities(attr.value) : attr.value;
35
+ const value = ctx.eval(expr);
36
+ applyBindValue(el, targetAttr, value);
37
+ }
38
+ }
39
+ }
40
+ /**
41
+ * Get all g-bind attributes from an element.
42
+ * Used by client to set up reactive bindings.
43
+ *
44
+ * @param el - The element to get bindings from
45
+ * @returns Array of [targetAttr, expression] pairs
46
+ */
47
+ export function getBindAttributes(el) {
48
+ const bindings = [];
49
+ for (const attr of el.attributes) {
50
+ if (attr.name.startsWith('g-bind:')) {
51
+ const targetAttr = attr.name.slice('g-bind:'.length);
52
+ bindings.push([targetAttr, attr.value]);
53
+ }
54
+ }
55
+ return bindings;
56
+ }
@@ -8,10 +8,7 @@ import { Directive, Context } from '../types.js';
8
8
  * Registry of directives by name.
9
9
  */
10
10
  export type DirectiveRegistry = Map<string, Directive<any>>;
11
- /**
12
- * Service registry for dependency injection.
13
- */
14
- export type ServiceRegistry = Map<string, unknown>;
11
+ export type { ServiceRegistry } from '../resolver-config.js';
15
12
  /**
16
13
  * Set context for an element (used by directives that create child contexts).
17
14
  *
@@ -52,3 +49,11 @@ export declare const hydrate: typeof init;
52
49
  * Alias for {@link init}. Use for pure client-side rendering.
53
50
  */
54
51
  export declare const mount: typeof init;
52
+ /**
53
+ * Reset hydration state for testing.
54
+ *
55
+ * @remarks
56
+ * Clears cached selector, disconnects observer, and resets initialized flag.
57
+ * Primarily useful for testing.
58
+ */
59
+ export declare function resetHydration(): void;
@@ -6,13 +6,15 @@
6
6
  import { Mode, DirectivePriority, getDirective, getDirectiveNames } from '../types.js';
7
7
  import { createContext } from '../context.js';
8
8
  import { processNativeSlot } from '../directives/slot.js';
9
- import { getLocalState, registerProvider, resolveFromProviders, registerDIProviders, resolveFromDIProviders } from '../providers.js';
9
+ import { getLocalState, registerProvider, registerDIProviders } from '../providers.js';
10
10
  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
- import { resolveContext } from '../context-registry.js';
14
13
  import { effect } from '../reactivity.js';
15
14
  import { applyAssigns, directiveNeedsScope } from '../directive-utils.js';
15
+ import { getTemplateAttrs } from '../template-utils.js';
16
+ import { applyBindValue, getBindAttributes } from '../bind-utils.js';
17
+ import { createClientResolverConfig } from '../resolver-config.js';
16
18
  // Built-in directives
17
19
  import { text } from '../directives/text.js';
18
20
  import { show } from '../directives/show.js';
@@ -27,6 +29,8 @@ let cachedSelector = null;
27
29
  let initialized = false;
28
30
  /** Registered services */
29
31
  let services = new Map();
32
+ /** Current MutationObserver (for cleanup) */
33
+ let observer = null;
30
34
  /** Context cache by element */
31
35
  const contextCache = new WeakMap();
32
36
  /** Default registry with built-in directives */
@@ -162,30 +166,6 @@ function getContextForElement(el) {
162
166
  export function setElementContext(el, ctx) {
163
167
  contextCache.set(el, ctx);
164
168
  }
165
- /**
166
- * Create resolver config for client-side dependency resolution.
167
- *
168
- * @internal
169
- */
170
- function createClientResolverConfig(el, ctx) {
171
- return {
172
- resolveContext: (key) => resolveContext(el, key),
173
- resolveState: () => findParentScope(el, true) ?? getLocalState(el),
174
- resolveCustom: (name) => {
175
- // Look up in ancestor DI providers first (provide option)
176
- const diProvided = resolveFromDIProviders(el, name);
177
- if (diProvided !== undefined)
178
- return diProvided;
179
- // Look up in global services registry
180
- const service = services.get(name);
181
- if (service !== undefined)
182
- return service;
183
- // Look up in ancestor context providers ($context)
184
- return resolveFromProviders(el, name);
185
- },
186
- mode: 'client'
187
- };
188
- }
189
169
  /**
190
170
  * Process directives on a single element.
191
171
  * Returns a promise if any directive is async, otherwise void.
@@ -209,7 +189,7 @@ function processElement(el, registry) {
209
189
  if (ifDirective) {
210
190
  const expr = el.getAttribute('data-g-if') || '';
211
191
  const ctx = getContextForElement(el);
212
- const config = createClientResolverConfig(el, ctx);
192
+ const config = createClientResolverConfig(el, () => findParentScope(el, true) ?? getLocalState(el), services);
213
193
  const registration = getDirective('g-if');
214
194
  const args = resolveInjectables(ifDirective, expr, el, ctx.eval.bind(ctx), config, registration?.options.using);
215
195
  const result = ifDirective(...args);
@@ -263,26 +243,17 @@ function processElement(el, registry) {
263
243
  }
264
244
  }
265
245
  // Process g-bind:* attributes (dynamic attribute binding with reactivity)
266
- for (const attr of [...el.attributes]) {
267
- if (attr.name.startsWith('g-bind:')) {
268
- const targetAttr = attr.name.slice('g-bind:'.length);
269
- const valueExpr = attr.value;
270
- effect(() => {
271
- const value = ctx.eval(valueExpr);
272
- if (value === null || value === undefined) {
273
- el.removeAttribute(targetAttr);
274
- }
275
- else {
276
- el.setAttribute(targetAttr, String(value));
277
- }
278
- });
279
- }
246
+ for (const [targetAttr, valueExpr] of getBindAttributes(el)) {
247
+ effect(() => {
248
+ const value = ctx.eval(valueExpr);
249
+ applyBindValue(el, targetAttr, value);
250
+ });
280
251
  }
281
252
  // Process directives sequentially, handling async ones properly
282
253
  let chain;
283
254
  for (const { directive, expr, using } of directives) {
284
255
  const processDirective = () => {
285
- const config = createClientResolverConfig(el, ctx);
256
+ const config = createClientResolverConfig(el, () => findParentScope(el, true) ?? getLocalState(el), services);
286
257
  const args = resolveInjectables(directive, expr, el, ctx.eval.bind(ctx), config, using);
287
258
  const result = directive(...args);
288
259
  // Register as provider if directive declares $context
@@ -387,24 +358,32 @@ export function registerService(name, service) {
387
358
  * ```
388
359
  */
389
360
  /**
390
- * Extract template attributes from an element.
361
+ * Build a selector for custom element directives that need processing.
391
362
  *
392
363
  * @internal
393
364
  */
394
- function getTemplateAttrs(el) {
395
- const attrs = {
396
- children: el.innerHTML
397
- };
398
- for (const attr of el.attributes) {
399
- attrs[attr.name] = attr.value;
365
+ function getCustomElementSelector() {
366
+ const selectors = [];
367
+ for (const name of getDirectiveNames()) {
368
+ const registration = getDirective(name);
369
+ if (!registration)
370
+ continue;
371
+ const { options } = registration;
372
+ // Only include directives with templates, scope, provide, or using
373
+ if (options.template || options.scope || options.provide || options.using) {
374
+ selectors.push(name);
375
+ }
400
376
  }
401
- return attrs;
377
+ return selectors.join(',');
402
378
  }
403
379
  /**
404
380
  * Process custom element directives (those with templates).
405
381
  * Directives with templates are web components and must be processed before
406
382
  * attribute directives so their content is rendered first.
407
383
  *
384
+ * Elements are processed in document order (parents before children) to ensure
385
+ * parent scopes are initialized before child expressions are evaluated.
386
+ *
408
387
  * Order for each element:
409
388
  * 1. Create scope (if scope: true)
410
389
  * 2. Call directive function (if fn exists) - initializes state
@@ -414,74 +393,68 @@ function getTemplateAttrs(el) {
414
393
  * @internal
415
394
  */
416
395
  async function processDirectiveElements() {
417
- for (const name of getDirectiveNames()) {
396
+ const selector = getCustomElementSelector();
397
+ if (!selector)
398
+ return;
399
+ // Get all custom elements in document order (parents before children)
400
+ const elements = document.querySelectorAll(selector);
401
+ for (const el of elements) {
402
+ // Skip if already processed
403
+ if (getElementScope(el)) {
404
+ continue;
405
+ }
406
+ // Find the directive registration for this element
407
+ const name = el.tagName.toLowerCase();
418
408
  const registration = getDirective(name);
419
409
  if (!registration) {
420
410
  continue;
421
411
  }
422
412
  const { fn, options } = registration;
423
- // Only process directives with templates (web components),
424
- // scope: true, provide (DI overrides), or using (context dependencies)
425
- if (!options.template && !options.scope && !options.provide && !options.using) {
426
- continue;
427
- }
428
- // Find all elements matching this directive's tag name
429
- const elements = document.querySelectorAll(name);
430
- for (const el of elements) {
431
- // Skip if already processed
432
- if (getElementScope(el)) {
433
- continue;
434
- }
435
- // 1. Create scope if needed
436
- let scope = {};
437
- if (options.scope) {
438
- const parentScope = findParentScope(el);
439
- scope = createElementScope(el, parentScope);
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
- }
413
+ // 1. Create scope if needed
414
+ let scope = {};
415
+ if (options.scope) {
416
+ const parentScope = findParentScope(el);
417
+ scope = createElementScope(el, parentScope);
418
+ // Collect unique directive names on this element for conflict detection
419
+ const directiveNameSet = new Set([name]);
420
+ for (const attr of el.attributes) {
421
+ const attrReg = getDirective(attr.name);
422
+ if (attrReg) {
423
+ directiveNameSet.add(attr.name);
447
424
  }
448
- // Apply assigns with conflict detection
449
- applyAssigns(scope, [...directiveNameSet]);
450
- }
451
- else {
452
- scope = findParentScope(el, true) ?? {};
453
425
  }
454
- // 2. Register DI providers if present (for descendants)
455
- if (options.provide) {
456
- registerDIProviders(el, options.provide);
426
+ // Apply assigns with conflict detection
427
+ applyAssigns(scope, [...directiveNameSet]);
428
+ }
429
+ else {
430
+ scope = findParentScope(el, true) ?? {};
431
+ }
432
+ // 2. Register DI providers if present (for descendants)
433
+ if (options.provide) {
434
+ registerDIProviders(el, options.provide);
435
+ }
436
+ // 3. Call directive function if present (initializes state)
437
+ if (fn) {
438
+ const ctx = createContext(Mode.CLIENT, scope);
439
+ const config = createClientResolverConfig(el, () => scope, services);
440
+ const args = resolveInjectables(fn, '', el, ctx.eval.bind(ctx), config, options.using);
441
+ const result = fn(...args);
442
+ if (result instanceof Promise) {
443
+ await result;
457
444
  }
458
- // 3. Call directive function if present (initializes state)
459
- if (fn) {
460
- const ctx = createContext(Mode.CLIENT, scope);
461
- const config = {
462
- resolveContext: (key) => resolveContext(el, key),
463
- resolveState: () => scope,
464
- mode: 'client'
465
- };
466
- const args = resolveInjectables(fn, '', el, ctx.eval.bind(ctx), config, options.using);
467
- const result = fn(...args);
468
- if (result instanceof Promise) {
469
- await result;
470
- }
445
+ }
446
+ // 4. Render template if present (can query DOM for <template> elements etc)
447
+ if (options.template) {
448
+ const attrs = getTemplateAttrs(el);
449
+ let html;
450
+ if (typeof options.template === 'string') {
451
+ html = options.template;
471
452
  }
472
- // 4. Render template if present (can query DOM for <template> elements etc)
473
- if (options.template) {
474
- const attrs = getTemplateAttrs(el);
475
- let html;
476
- if (typeof options.template === 'string') {
477
- html = options.template;
478
- }
479
- else {
480
- const result = options.template(attrs, el);
481
- html = result instanceof Promise ? await result : result;
482
- }
483
- el.innerHTML = html;
453
+ else {
454
+ const result = options.template(attrs, el);
455
+ html = result instanceof Promise ? await result : result;
484
456
  }
457
+ el.innerHTML = html;
485
458
  }
486
459
  }
487
460
  }
@@ -511,7 +484,11 @@ export async function init(registry) {
511
484
  await Promise.all(promises);
512
485
  }
513
486
  // Set up MutationObserver for dynamic elements
514
- const observer = new MutationObserver((mutations) => {
487
+ // Clean up previous observer if it exists
488
+ if (observer) {
489
+ observer.disconnect();
490
+ }
491
+ observer = new MutationObserver((mutations) => {
515
492
  for (const mutation of mutations) {
516
493
  for (const node of mutation.addedNodes) {
517
494
  if (node.nodeType !== Node.ELEMENT_NODE)
@@ -537,3 +514,19 @@ export const hydrate = init;
537
514
  * Alias for {@link init}. Use for pure client-side rendering.
538
515
  */
539
516
  export const mount = init;
517
+ /**
518
+ * Reset hydration state for testing.
519
+ *
520
+ * @remarks
521
+ * Clears cached selector, disconnects observer, and resets initialized flag.
522
+ * Primarily useful for testing.
523
+ */
524
+ export function resetHydration() {
525
+ cachedSelector = null;
526
+ initialized = false;
527
+ defaultRegistry = null;
528
+ if (observer) {
529
+ observer.disconnect();
530
+ observer = null;
531
+ }
532
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared resolver configuration factory for client and server.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { ContextKey } from './context-registry.js';
7
+ /**
8
+ * Service registry for dependency injection.
9
+ */
10
+ export type ServiceRegistry = Map<string, unknown>;
11
+ /**
12
+ * Resolver configuration for dependency injection.
13
+ */
14
+ export interface ResolverConfig {
15
+ resolveContext: (key: ContextKey<unknown>) => unknown;
16
+ resolveState: () => Record<string, unknown>;
17
+ resolveRootState?: () => Record<string, unknown>;
18
+ resolveCustom: (name: string) => unknown;
19
+ mode: 'client' | 'server';
20
+ }
21
+ /**
22
+ * Create the resolveCustom function shared by client and server.
23
+ *
24
+ * @param el - The element for provider lookup
25
+ * @param services - The global service registry
26
+ * @returns A function that resolves custom dependencies
27
+ */
28
+ export declare function createCustomResolver(el: Element, services: ServiceRegistry): (name: string) => unknown;
29
+ /**
30
+ * Create resolver config for client-side dependency resolution.
31
+ *
32
+ * @param el - The element being processed
33
+ * @param resolveState - Function to resolve the current state
34
+ * @param services - The global service registry
35
+ * @returns Resolver configuration for client mode
36
+ */
37
+ export declare function createClientResolverConfig(el: Element, resolveState: () => Record<string, unknown>, services: ServiceRegistry): ResolverConfig;
38
+ /**
39
+ * Create resolver config for server-side dependency resolution.
40
+ *
41
+ * @param el - The element being processed
42
+ * @param scopeState - The current scope state
43
+ * @param rootState - The root state object
44
+ * @param services - The global service registry
45
+ * @returns Resolver configuration for server mode
46
+ */
47
+ export declare function createServerResolverConfig(el: Element, scopeState: Record<string, unknown>, rootState: Record<string, unknown>, services: ServiceRegistry): ResolverConfig;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared resolver configuration factory for client and server.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { resolveFromProviders, resolveFromDIProviders } from './providers.js';
7
+ import { resolveContext } from './context-registry.js';
8
+ /**
9
+ * Create the resolveCustom function shared by client and server.
10
+ *
11
+ * @param el - The element for provider lookup
12
+ * @param services - The global service registry
13
+ * @returns A function that resolves custom dependencies
14
+ */
15
+ export function createCustomResolver(el, services) {
16
+ return (name) => {
17
+ // Look up in ancestor DI providers first (provide option)
18
+ const diProvided = resolveFromDIProviders(el, name);
19
+ if (diProvided !== undefined)
20
+ return diProvided;
21
+ // Look up in global services registry
22
+ const service = services.get(name);
23
+ if (service !== undefined)
24
+ return service;
25
+ // Look up in ancestor context providers ($context)
26
+ return resolveFromProviders(el, name);
27
+ };
28
+ }
29
+ /**
30
+ * Create resolver config for client-side dependency resolution.
31
+ *
32
+ * @param el - The element being processed
33
+ * @param resolveState - Function to resolve the current state
34
+ * @param services - The global service registry
35
+ * @returns Resolver configuration for client mode
36
+ */
37
+ export function createClientResolverConfig(el, resolveState, services) {
38
+ return {
39
+ resolveContext: (key) => resolveContext(el, key),
40
+ resolveState,
41
+ resolveCustom: createCustomResolver(el, services),
42
+ mode: 'client'
43
+ };
44
+ }
45
+ /**
46
+ * Create resolver config for server-side dependency resolution.
47
+ *
48
+ * @param el - The element being processed
49
+ * @param scopeState - The current scope state
50
+ * @param rootState - The root state object
51
+ * @param services - The global service registry
52
+ * @returns Resolver configuration for server mode
53
+ */
54
+ export function createServerResolverConfig(el, scopeState, rootState, services) {
55
+ return {
56
+ resolveContext: (key) => resolveContext(el, key),
57
+ resolveState: () => scopeState,
58
+ resolveRootState: () => rootState,
59
+ resolveCustom: createCustomResolver(el, services),
60
+ mode: 'server'
61
+ };
62
+ }
package/dist/scope.d.ts CHANGED
@@ -4,6 +4,13 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
  import { Directive, DirectiveOptions } from './types.js';
7
+ /**
8
+ * Clear all element scopes.
9
+ *
10
+ * @remarks
11
+ * Primarily useful for testing.
12
+ */
13
+ export declare function clearElementScopes(): void;
7
14
  /**
8
15
  * Get or create the root scope.
9
16
  *
package/dist/scope.js CHANGED
@@ -10,7 +10,16 @@ import { resolveDependencies } from './inject.js';
10
10
  import { findAncestor } from './dom.js';
11
11
  import { resolveContext } from './context-registry.js';
12
12
  /** WeakMap to store element scopes */
13
- const elementScopes = new WeakMap();
13
+ let elementScopes = new WeakMap();
14
+ /**
15
+ * Clear all element scopes.
16
+ *
17
+ * @remarks
18
+ * Primarily useful for testing.
19
+ */
20
+ export function clearElementScopes() {
21
+ elementScopes = new WeakMap();
22
+ }
14
23
  /** Root scope for top-level directives without explicit parent scope */
15
24
  let rootScope = null;
16
25
  /**
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Server-side rendering with MutationObserver-based directive indexing.
2
+ * Server-side rendering with direct tree walking for directive indexing.
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
@@ -8,10 +8,7 @@ import { Directive } from '../types.js';
8
8
  * Registry of directives by name.
9
9
  */
10
10
  export type DirectiveRegistry = Map<string, Directive>;
11
- /**
12
- * Service registry for dependency injection.
13
- */
14
- export type ServiceRegistry = Map<string, unknown>;
11
+ export type { ServiceRegistry } from '../resolver-config.js';
15
12
  /**
16
13
  * Register a directive in the registry.
17
14
  *
@@ -33,8 +30,8 @@ export declare function registerService(name: string, service: unknown): void;
33
30
  * Render HTML with directives on the server.
34
31
  *
35
32
  * @remarks
36
- * Uses MutationObserver to index elements with directive attributes
37
- * as they are parsed, then executes directives to produce the final HTML.
33
+ * Uses direct tree walking to index elements with directive attributes,
34
+ * then executes directives to produce the final HTML.
38
35
  * Directive attributes are preserved in output for client hydration.
39
36
  * Directives are processed in tree order (parents before children),
40
37
  * with priority used only for multiple directives on the same element.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Server-side rendering with MutationObserver-based directive indexing.
2
+ * Server-side rendering with direct tree walking for directive indexing.
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
@@ -7,28 +7,16 @@ import { Window } from 'happy-dom';
7
7
  import { Mode, DirectivePriority, getDirective, getDirectiveNames } from '../types.js';
8
8
  import { createContext } from '../context.js';
9
9
  import { processNativeSlot } from '../directives/slot.js';
10
- import { registerProvider, resolveFromProviders, registerDIProviders, resolveFromDIProviders } from '../providers.js';
10
+ import { registerProvider, registerDIProviders } from '../providers.js';
11
11
  import { createElementScope, getElementScope } from '../scope.js';
12
12
  import { FOR_PROCESSED_ATTR, FOR_TEMPLATE_ATTR } from '../directives/for.js';
13
13
  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
- import { resolveContext } from '../context-registry.js';
17
16
  import { applyAssigns, directiveNeedsScope } from '../directive-utils.js';
18
- /**
19
- * Decode HTML entities that happy-dom doesn't decode.
20
- *
21
- * @internal
22
- */
23
- function decodeHTMLEntities(str) {
24
- return str
25
- .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
26
- .replace(/&quot;/g, '"')
27
- .replace(/&apos;/g, "'")
28
- .replace(/&lt;/g, '<')
29
- .replace(/&gt;/g, '>')
30
- .replace(/&amp;/g, '&');
31
- }
17
+ import { getTemplateAttrs, hasBindAttributes, decodeHTMLEntities } from '../template-utils.js';
18
+ import { processBindAttributesOnce } from '../bind-utils.js';
19
+ import { createServerResolverConfig } from '../resolver-config.js';
32
20
  /** Registered services */
33
21
  let services = new Map();
34
22
  /**
@@ -68,49 +56,23 @@ function getSelector(localRegistry) {
68
56
  selectors.push('[g-scope]');
69
57
  // Match common g-bind:* attributes for dynamic binding
70
58
  // 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]');
59
+ // Note: happy-dom doesn't need colon escaping (and escaped colons don't work)
60
+ selectors.push('[g-bind:class]');
61
+ selectors.push('[g-bind:style]');
62
+ selectors.push('[g-bind:href]');
63
+ selectors.push('[g-bind:src]');
64
+ selectors.push('[g-bind:id]');
65
+ selectors.push('[g-bind:value]');
66
+ selectors.push('[g-bind:disabled]');
67
+ selectors.push('[g-bind:checked]');
68
+ selectors.push('[g-bind:placeholder]');
69
+ selectors.push('[g-bind:title]');
70
+ selectors.push('[g-bind:alt]');
71
+ selectors.push('[g-bind:name]');
72
+ selectors.push('[g-bind:type]');
84
73
  // Note: Can't do wildcard for data-* attributes in CSS, but hasBindAttributes handles them
85
74
  return selectors.join(',');
86
75
  }
87
- /**
88
- * Get template attributes from an element.
89
- *
90
- * @internal
91
- */
92
- function getTemplateAttrs(el) {
93
- const attrs = {
94
- children: el.innerHTML
95
- };
96
- for (const attr of el.attributes) {
97
- attrs[attr.name] = attr.value;
98
- }
99
- return attrs;
100
- }
101
- /**
102
- * Check if element has any g-bind:* attributes.
103
- *
104
- * @internal
105
- */
106
- function hasBindAttributes(el) {
107
- for (const attr of el.attributes) {
108
- if (attr.name.startsWith('g-bind:')) {
109
- return true;
110
- }
111
- }
112
- return false;
113
- }
114
76
  /**
115
77
  * Register a directive in the registry.
116
78
  *
@@ -149,37 +111,12 @@ function findServerScope(el, rootState) {
149
111
  }
150
112
  return rootState;
151
113
  }
152
- /**
153
- * Create resolver config for server-side dependency resolution.
154
- *
155
- * @internal
156
- */
157
- function createServerResolverConfig(el, scopeState, rootState) {
158
- return {
159
- resolveContext: (key) => resolveContext(el, key),
160
- resolveState: () => scopeState,
161
- resolveRootState: () => rootState,
162
- resolveCustom: (name) => {
163
- // Look up in ancestor DI providers first (provide option)
164
- const diProvided = resolveFromDIProviders(el, name);
165
- if (diProvided !== undefined)
166
- return diProvided;
167
- // Look up in global services registry
168
- const service = services.get(name);
169
- if (service !== undefined)
170
- return service;
171
- // Look up in ancestor context providers ($context)
172
- return resolveFromProviders(el, name);
173
- },
174
- mode: 'server'
175
- };
176
- }
177
114
  /**
178
115
  * Render HTML with directives on the server.
179
116
  *
180
117
  * @remarks
181
- * Uses MutationObserver to index elements with directive attributes
182
- * as they are parsed, then executes directives to produce the final HTML.
118
+ * Uses direct tree walking to index elements with directive attributes,
119
+ * then executes directives to produce the final HTML.
183
120
  * Directive attributes are preserved in output for client hydration.
184
121
  * Directives are processed in tree order (parents before children),
185
122
  * with priority used only for multiple directives on the same element.
@@ -206,149 +143,133 @@ export async function render(html, state, registry) {
206
143
  const window = new Window();
207
144
  const document = window.document;
208
145
  const index = [];
209
- const indexedDirectives = new Map(); // Track indexed (element, directive) pairs
146
+ const indexed = new Set(); // Track which elements have been indexed
210
147
  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
- };
226
- const observer = new window.MutationObserver((mutations) => {
227
- for (const mutation of mutations) {
228
- for (const node of mutation.addedNodes) {
229
- if (node.nodeType !== 1)
230
- continue;
231
- const el = node;
232
- const matches = el.matches(selector) ? [el] : [];
233
- const descendants = [...el.querySelectorAll(selector)];
234
- for (const match of [...matches, ...descendants]) {
235
- // Skip elements inside template content (used as placeholders)
236
- if (match.closest('template')) {
237
- continue;
148
+ /**
149
+ * Index all directive elements in a subtree.
150
+ * Called after innerHTML is set to discover new elements.
151
+ */
152
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
+ function indexTree(root) {
154
+ // Get all matching elements in the subtree
155
+ const elements = root.querySelectorAll(selector);
156
+ for (const match of elements) {
157
+ // Skip if already indexed
158
+ if (indexed.has(match))
159
+ continue;
160
+ indexed.add(match);
161
+ // Skip elements inside template content (used as placeholders)
162
+ if (match.closest('template')) {
163
+ continue;
164
+ }
165
+ // Handle native <slot> elements
166
+ if (match.tagName === 'SLOT') {
167
+ index.push({
168
+ el: match,
169
+ name: 'slot',
170
+ directive: null,
171
+ expr: '',
172
+ priority: DirectivePriority.NORMAL,
173
+ isNativeSlot: true
174
+ });
175
+ continue;
176
+ }
177
+ // Handle g-scope elements that don't have other directives
178
+ if (match.hasAttribute('g-scope')) {
179
+ let hasDirective = false;
180
+ for (const name of getDirectiveNames()) {
181
+ if (match.hasAttribute(name)) {
182
+ hasDirective = true;
183
+ break;
184
+ }
185
+ }
186
+ if (!hasDirective) {
187
+ index.push({
188
+ el: match,
189
+ name: 'scope',
190
+ directive: null,
191
+ expr: '',
192
+ priority: DirectivePriority.STRUCTURAL,
193
+ isNativeSlot: false
194
+ });
195
+ }
196
+ }
197
+ // Handle g-bind:* elements that don't have other directives
198
+ if (hasBindAttributes(match)) {
199
+ let hasDirective = false;
200
+ for (const name of getDirectiveNames()) {
201
+ if (match.hasAttribute(name)) {
202
+ hasDirective = true;
203
+ break;
238
204
  }
239
- // Handle native <slot> elements
240
- if (match.tagName === 'SLOT') {
241
- addToIndex({
205
+ }
206
+ if (!hasDirective && !match.hasAttribute('g-scope')) {
207
+ index.push({
208
+ el: match,
209
+ name: 'bind',
210
+ directive: null,
211
+ expr: '',
212
+ priority: DirectivePriority.NORMAL,
213
+ isNativeSlot: false
214
+ });
215
+ }
216
+ }
217
+ // Check all registered directives from global registry
218
+ const tagName = match.tagName.toLowerCase();
219
+ for (const name of getDirectiveNames()) {
220
+ const registration = getDirective(name);
221
+ if (!registration)
222
+ continue;
223
+ const { fn, options } = registration;
224
+ // Check if this is a custom element directive (tag name matches)
225
+ if (tagName === name) {
226
+ if (options.template || options.scope || options.provide || options.using) {
227
+ index.push({
242
228
  el: match,
243
- name: 'slot',
244
- directive: null,
229
+ name,
230
+ directive: fn,
245
231
  expr: '',
246
- priority: DirectivePriority.NORMAL,
247
- isNativeSlot: true
232
+ priority: fn?.priority ?? DirectivePriority.TEMPLATE,
233
+ isCustomElement: true,
234
+ using: options.using
248
235
  });
249
- continue;
250
- }
251
- // Handle g-scope elements that don't have other directives
252
- // Add a placeholder entry so they get processed
253
- if (match.hasAttribute('g-scope')) {
254
- let hasDirective = false;
255
- for (const name of getDirectiveNames()) {
256
- if (match.hasAttribute(name)) {
257
- hasDirective = true;
258
- break;
259
- }
260
- }
261
- if (!hasDirective) {
262
- addToIndex({
263
- el: match,
264
- name: 'scope',
265
- directive: null,
266
- expr: '',
267
- priority: DirectivePriority.STRUCTURAL,
268
- isNativeSlot: false
269
- });
270
- }
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
- }
293
- // Check all registered directives from global registry
294
- const tagName = match.tagName.toLowerCase();
295
- for (const name of getDirectiveNames()) {
296
- const registration = getDirective(name);
297
- if (!registration)
298
- continue;
299
- const { fn, options } = registration;
300
- // Check if this is a custom element directive (tag name matches)
301
- if (tagName === name) {
302
- if (options.template || options.scope || options.provide || options.using) {
303
- addToIndex({
304
- el: match,
305
- name,
306
- directive: fn,
307
- expr: '',
308
- priority: fn?.priority ?? DirectivePriority.TEMPLATE,
309
- isCustomElement: true,
310
- using: options.using
311
- });
312
- }
313
- }
314
- // Check if this is an attribute directive
315
- const attr = match.getAttribute(name);
316
- if (attr !== null) {
317
- addToIndex({
318
- el: match,
319
- name,
320
- directive: fn,
321
- expr: decodeHTMLEntities(attr),
322
- priority: fn?.priority ?? DirectivePriority.NORMAL,
323
- using: options.using
324
- });
325
- }
326
- }
327
- // Also check local registry for backward compatibility
328
- // Local registry uses short names with g- prefix
329
- for (const [name, directive] of registry) {
330
- const attr = match.getAttribute(`g-${name}`);
331
- if (attr !== null) {
332
- // Skip if already added from global registry
333
- const fullName = `g-${name}`;
334
- if (getDirective(fullName))
335
- continue;
336
- addToIndex({
337
- el: match,
338
- name,
339
- directive,
340
- expr: decodeHTMLEntities(attr),
341
- priority: directive.priority ?? DirectivePriority.NORMAL
342
- });
343
- }
344
236
  }
345
237
  }
238
+ // Check if this is an attribute directive
239
+ const attr = match.getAttribute(name);
240
+ if (attr !== null) {
241
+ index.push({
242
+ el: match,
243
+ name,
244
+ directive: fn,
245
+ expr: decodeHTMLEntities(attr),
246
+ priority: fn?.priority ?? DirectivePriority.NORMAL,
247
+ using: options.using
248
+ });
249
+ }
250
+ }
251
+ // Also check local registry for backward compatibility
252
+ for (const [name, directive] of registry) {
253
+ const attr = match.getAttribute(`g-${name}`);
254
+ if (attr !== null) {
255
+ // Skip if already added from global registry
256
+ const fullName = `g-${name}`;
257
+ if (getDirective(fullName))
258
+ continue;
259
+ index.push({
260
+ el: match,
261
+ name,
262
+ directive,
263
+ expr: decodeHTMLEntities(attr),
264
+ priority: directive.priority ?? DirectivePriority.NORMAL
265
+ });
266
+ }
346
267
  }
347
268
  }
348
- });
349
- observer.observe(document.body, { childList: true, subtree: true });
269
+ }
270
+ // Set HTML and index initial tree
350
271
  document.body.innerHTML = html;
351
- await new Promise(r => setTimeout(r, 0));
272
+ indexTree(document.body);
352
273
  const ctx = createContext(Mode.SERVER, state);
353
274
  const processed = new Set();
354
275
  // Process directives in rounds until no new elements are added
@@ -399,30 +320,6 @@ export async function render(html, state, registry) {
399
320
  const directives = byElement.get(el);
400
321
  // Sort directives on this element by priority (higher first)
401
322
  directives.sort((a, b) => b.priority - a.priority);
402
- // Process g-scope first (inline scope initialization)
403
- const scopeAttr = el.getAttribute('g-scope');
404
- if (scopeAttr) {
405
- const scopeValues = ctx.eval(decodeHTMLEntities(scopeAttr));
406
- if (scopeValues && typeof scopeValues === 'object') {
407
- Object.assign(state, scopeValues);
408
- }
409
- }
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);
414
- for (const attr of [...el.attributes]) {
415
- if (attr.name.startsWith('g-bind:')) {
416
- const targetAttr = attr.name.slice('g-bind:'.length);
417
- const value = bindCtx.eval(decodeHTMLEntities(attr.value));
418
- if (value === null || value === undefined) {
419
- el.removeAttribute(targetAttr);
420
- }
421
- else {
422
- el.setAttribute(targetAttr, String(value));
423
- }
424
- }
425
- }
426
323
  // Collect unique directive names for conflict detection
427
324
  const directiveNameSet = new Set();
428
325
  for (const item of directives) {
@@ -432,6 +329,7 @@ export async function render(html, state, registry) {
432
329
  }
433
330
  const directiveNames = [...directiveNameSet];
434
331
  // Check if any directive needs scope - create once if so
332
+ // Must happen BEFORE g-scope and g-bind so assigns are available
435
333
  let elementScope = null;
436
334
  for (const name of directiveNames) {
437
335
  if (directiveNeedsScope(name)) {
@@ -442,6 +340,19 @@ export async function render(html, state, registry) {
442
340
  break;
443
341
  }
444
342
  }
343
+ // Use element scope if created, otherwise find nearest ancestor
344
+ const scopeState = elementScope ?? findServerScope(el, state);
345
+ const scopeCtx = createContext(Mode.SERVER, scopeState);
346
+ // Process g-scope (inline scope initialization)
347
+ const scopeAttr = el.getAttribute('g-scope');
348
+ if (scopeAttr) {
349
+ const scopeValues = scopeCtx.eval(decodeHTMLEntities(scopeAttr));
350
+ if (scopeValues && typeof scopeValues === 'object') {
351
+ Object.assign(scopeState, scopeValues);
352
+ }
353
+ }
354
+ // Process g-bind:* attributes (dynamic attribute binding)
355
+ processBindAttributesOnce(el, scopeCtx, true);
445
356
  for (const item of directives) {
446
357
  // Check if element was disconnected by a previous directive (e.g., g-for replacing it)
447
358
  if (!item.el.isConnected) {
@@ -466,7 +377,7 @@ export async function render(html, state, registry) {
466
377
  }
467
378
  // Call directive function if present (initializes state)
468
379
  if (fn) {
469
- const config = createServerResolverConfig(item.el, scopeState, state);
380
+ const config = createServerResolverConfig(item.el, scopeState, state, services);
470
381
  const args = resolveInjectables(fn, item.expr, item.el, ctx.eval.bind(ctx), config, options.using);
471
382
  await fn(...args);
472
383
  // Register as context provider if directive declares $context
@@ -477,15 +388,17 @@ export async function render(html, state, registry) {
477
388
  // Render template if present
478
389
  if (options.template) {
479
390
  const attrs = getTemplateAttrs(item.el);
480
- let html;
391
+ let templateHtml;
481
392
  if (typeof options.template === 'string') {
482
- html = options.template;
393
+ templateHtml = options.template;
483
394
  }
484
395
  else {
485
396
  const result = options.template(attrs, item.el);
486
- html = result instanceof Promise ? await result : result;
397
+ templateHtml = result instanceof Promise ? await result : result;
487
398
  }
488
- item.el.innerHTML = html;
399
+ item.el.innerHTML = templateHtml;
400
+ // Index new elements from the template
401
+ indexTree(item.el);
489
402
  }
490
403
  }
491
404
  else if (item.directive === null) {
@@ -502,7 +415,7 @@ export async function render(html, state, registry) {
502
415
  if (options.provide) {
503
416
  registerDIProviders(item.el, options.provide);
504
417
  }
505
- const config = createServerResolverConfig(item.el, scopeState, state);
418
+ const config = createServerResolverConfig(item.el, scopeState, state, services);
506
419
  const args = resolveInjectables(item.directive, item.expr, item.el, ctx.eval.bind(ctx), config, item.using);
507
420
  await item.directive(...args);
508
421
  // Register as context provider if directive declares $context
@@ -511,10 +424,9 @@ export async function render(html, state, registry) {
511
424
  }
512
425
  }
513
426
  }
514
- // Let observer catch new elements
515
- await new Promise(r => setTimeout(r, 0));
516
427
  }
428
+ // Re-index tree to catch elements added by directives (e.g., g-template)
429
+ indexTree(document.body);
517
430
  }
518
- observer.disconnect();
519
431
  return document.body.innerHTML;
520
432
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared template utilities for client and server.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { TemplateAttrs } from './types.js';
7
+ /**
8
+ * Extract template attributes from an element.
9
+ *
10
+ * @param el - The element to extract attributes from
11
+ * @returns An object with all attributes and children innerHTML
12
+ */
13
+ export declare function getTemplateAttrs(el: Element): TemplateAttrs;
14
+ /**
15
+ * Check if element has any g-bind:* attributes.
16
+ *
17
+ * @param el - The element to check
18
+ * @returns True if element has g-bind attributes
19
+ */
20
+ export declare function hasBindAttributes(el: Element): boolean;
21
+ /**
22
+ * Decode HTML entities that happy-dom doesn't decode.
23
+ * Only needed for server-side rendering.
24
+ *
25
+ * @param str - The string with HTML entities
26
+ * @returns The decoded string
27
+ */
28
+ export declare function decodeHTMLEntities(str: string): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared template utilities for client and server.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ /**
7
+ * Extract template attributes from an element.
8
+ *
9
+ * @param el - The element to extract attributes from
10
+ * @returns An object with all attributes and children innerHTML
11
+ */
12
+ export function getTemplateAttrs(el) {
13
+ const attrs = {
14
+ children: el.innerHTML
15
+ };
16
+ for (const attr of el.attributes) {
17
+ attrs[attr.name] = attr.value;
18
+ }
19
+ return attrs;
20
+ }
21
+ /**
22
+ * Check if element has any g-bind:* attributes.
23
+ *
24
+ * @param el - The element to check
25
+ * @returns True if element has g-bind attributes
26
+ */
27
+ export function hasBindAttributes(el) {
28
+ for (const attr of el.attributes) {
29
+ if (attr.name.startsWith('g-bind:')) {
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+ /**
36
+ * Decode HTML entities that happy-dom doesn't decode.
37
+ * Only needed for server-side rendering.
38
+ *
39
+ * @param str - The string with HTML entities
40
+ * @returns The decoded string
41
+ */
42
+ export function decodeHTMLEntities(str) {
43
+ return str
44
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
45
+ .replace(/&quot;/g, '"')
46
+ .replace(/&apos;/g, "'")
47
+ .replace(/&lt;/g, '<')
48
+ .replace(/&gt;/g, '>')
49
+ .replace(/&amp;/g, '&');
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gonia",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "A lightweight, SSR-first reactive UI library with declarative directives",
5
5
  "type": "module",
6
6
  "license": "MIT",