gonia 0.2.6 → 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
+ }
@@ -15,12 +15,11 @@ export type ServiceRegistry = Map<string, unknown>;
15
15
  /**
16
16
  * Register a directive in the registry.
17
17
  *
18
- * @remarks
19
- * Invalidates the cached selector so it will be rebuilt on next render.
20
- *
21
18
  * @param registry - The directive registry
22
- * @param name - Directive name (without g- prefix)
19
+ * @param name - Directive name
23
20
  * @param fn - The directive function
21
+ *
22
+ * @deprecated Use `directive()` from 'gonia' instead to register directives globally.
24
23
  */
25
24
  export declare function registerDirective(registry: DirectiveRegistry, name: string, fn: Directive): void;
26
25
  /**
@@ -4,16 +4,17 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
  import { Window } from 'happy-dom';
7
- import { Mode, DirectivePriority, getDirective } from '../types.js';
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, resolveFromDIProviders } from '../providers.js';
10
+ import { registerProvider, resolveFromProviders, registerDIProviders, resolveFromDIProviders } 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
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
  *
@@ -30,24 +31,56 @@ function decodeHTMLEntities(str) {
30
31
  }
31
32
  /** Registered services */
32
33
  let services = new Map();
33
- const selectorCache = new WeakMap();
34
34
  /**
35
35
  * Build a CSS selector for all registered directives.
36
+ * Uses the global directive registry to support any prefix (g-, l-, v-, etc.).
37
+ * Also includes local registry entries with g- prefix for backward compatibility.
36
38
  *
37
39
  * @internal
38
40
  */
39
- function getSelector(registry) {
40
- let selector = selectorCache.get(registry);
41
- if (!selector) {
42
- const directiveSelectors = [...registry.keys()].map(n => `[g-${n}]`);
43
- // Also match native <slot> elements
44
- directiveSelectors.push('slot');
45
- // Match g-scope for inline scope initialization
46
- directiveSelectors.push('[g-scope]');
47
- selector = directiveSelectors.join(',');
48
- selectorCache.set(registry, selector);
41
+ function getSelector(localRegistry) {
42
+ const selectors = [];
43
+ for (const name of getDirectiveNames()) {
44
+ const registration = getDirective(name);
45
+ if (!registration)
46
+ continue;
47
+ const { options } = registration;
48
+ // Custom element directives - match by tag name
49
+ if (options.template || options.scope || options.provide || options.using) {
50
+ selectors.push(name);
51
+ }
52
+ // All directives can be used as attributes
53
+ selectors.push(`[${name}]`);
54
+ }
55
+ // Add local registry entries with g- prefix (backward compatibility)
56
+ if (localRegistry) {
57
+ for (const name of localRegistry.keys()) {
58
+ const fullName = `g-${name}`;
59
+ // Skip if already in global registry
60
+ if (!getDirective(fullName)) {
61
+ selectors.push(`[${fullName}]`);
62
+ }
63
+ }
49
64
  }
50
- return selector;
65
+ // Also match native <slot> elements
66
+ selectors.push('slot');
67
+ // Match g-scope for inline scope initialization (TODO: make prefix configurable)
68
+ selectors.push('[g-scope]');
69
+ return selectors.join(',');
70
+ }
71
+ /**
72
+ * Get template attributes from an element.
73
+ *
74
+ * @internal
75
+ */
76
+ function getTemplateAttrs(el) {
77
+ const attrs = {
78
+ children: el.innerHTML
79
+ };
80
+ for (const attr of el.attributes) {
81
+ attrs[attr.name] = attr.value;
82
+ }
83
+ return attrs;
51
84
  }
52
85
  /**
53
86
  * Check if element has any g-bind:* attributes.
@@ -65,16 +98,14 @@ function hasBindAttributes(el) {
65
98
  /**
66
99
  * Register a directive in the registry.
67
100
  *
68
- * @remarks
69
- * Invalidates the cached selector so it will be rebuilt on next render.
70
- *
71
101
  * @param registry - The directive registry
72
- * @param name - Directive name (without g- prefix)
102
+ * @param name - Directive name
73
103
  * @param fn - The directive function
104
+ *
105
+ * @deprecated Use `directive()` from 'gonia' instead to register directives globally.
74
106
  */
75
107
  export function registerDirective(registry, name, fn) {
76
108
  registry.set(name, fn);
77
- selectorCache.delete(registry);
78
109
  }
79
110
  /**
80
111
  * Register a service for dependency injection.
@@ -189,8 +220,8 @@ export async function render(html, state, registry) {
189
220
  // Add a placeholder entry so they get processed
190
221
  if (match.hasAttribute('g-scope')) {
191
222
  let hasDirective = false;
192
- for (const [name] of registry) {
193
- if (match.hasAttribute(`g-${name}`)) {
223
+ for (const name of getDirectiveNames()) {
224
+ if (match.hasAttribute(name)) {
194
225
  hasDirective = true;
195
226
  break;
196
227
  }
@@ -206,18 +237,55 @@ export async function render(html, state, registry) {
206
237
  });
207
238
  }
208
239
  }
240
+ // Check all registered directives from global registry
241
+ const tagName = match.tagName.toLowerCase();
242
+ for (const name of getDirectiveNames()) {
243
+ const registration = getDirective(name);
244
+ if (!registration)
245
+ continue;
246
+ const { fn, options } = registration;
247
+ // Check if this is a custom element directive (tag name matches)
248
+ if (tagName === name) {
249
+ if (options.template || options.scope || options.provide || options.using) {
250
+ index.push({
251
+ el: match,
252
+ name,
253
+ directive: fn,
254
+ expr: '',
255
+ priority: fn?.priority ?? DirectivePriority.TEMPLATE,
256
+ isCustomElement: true,
257
+ using: options.using
258
+ });
259
+ }
260
+ }
261
+ // Check if this is an attribute directive
262
+ const attr = match.getAttribute(name);
263
+ if (attr !== null) {
264
+ index.push({
265
+ el: match,
266
+ name,
267
+ directive: fn,
268
+ expr: decodeHTMLEntities(attr),
269
+ priority: fn?.priority ?? DirectivePriority.NORMAL,
270
+ using: options.using
271
+ });
272
+ }
273
+ }
274
+ // Also check local registry for backward compatibility
275
+ // Local registry uses short names with g- prefix
209
276
  for (const [name, directive] of registry) {
210
277
  const attr = match.getAttribute(`g-${name}`);
211
278
  if (attr !== null) {
212
- // Look up options from the global directive registry
213
- const registration = getDirective(`g-${name}`);
279
+ // Skip if already added from global registry
280
+ const fullName = `g-${name}`;
281
+ if (getDirective(fullName))
282
+ continue;
214
283
  index.push({
215
284
  el: match,
216
285
  name,
217
286
  directive,
218
287
  expr: decodeHTMLEntities(attr),
219
- priority: directive.priority ?? DirectivePriority.NORMAL,
220
- using: registration?.options.using
288
+ priority: directive.priority ?? DirectivePriority.NORMAL
221
289
  });
222
290
  }
223
291
  }
@@ -287,10 +355,13 @@ export async function render(html, state, registry) {
287
355
  }
288
356
  }
289
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);
290
361
  for (const attr of [...el.attributes]) {
291
362
  if (attr.name.startsWith('g-bind:')) {
292
363
  const targetAttr = attr.name.slice('g-bind:'.length);
293
- const value = ctx.eval(decodeHTMLEntities(attr.value));
364
+ const value = bindCtx.eval(decodeHTMLEntities(attr.value));
294
365
  if (value === null || value === undefined) {
295
366
  el.removeAttribute(targetAttr);
296
367
  }
@@ -299,6 +370,24 @@ export async function render(html, state, registry) {
299
370
  }
300
371
  }
301
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
+ }
302
391
  for (const item of directives) {
303
392
  // Check if element was disconnected by a previous directive (e.g., g-for replacing it)
304
393
  if (!item.el.isConnected) {
@@ -307,21 +396,57 @@ export async function render(html, state, registry) {
307
396
  if (item.isNativeSlot) {
308
397
  processNativeSlot(item.el);
309
398
  }
399
+ else if (item.isCustomElement) {
400
+ // Custom element directive - must check before directive === null
401
+ // because custom elements can have fn: null (template-only)
402
+ // Custom element directive - process template, scope, etc.
403
+ const registration = getDirective(item.name);
404
+ if (!registration)
405
+ continue;
406
+ const { fn, options } = registration;
407
+ // Use pre-created scope or find existing
408
+ const scopeState = elementScope ?? findServerScope(item.el, state);
409
+ // Register DI providers if present
410
+ if (options.provide) {
411
+ registerDIProviders(item.el, options.provide);
412
+ }
413
+ // Call directive function if present (initializes state)
414
+ if (fn) {
415
+ const config = createServerResolverConfig(item.el, scopeState, state);
416
+ const args = resolveInjectables(fn, item.expr, item.el, ctx.eval.bind(ctx), config, options.using);
417
+ await fn(...args);
418
+ // Register as context provider if directive declares $context
419
+ if (fn.$context?.length) {
420
+ registerProvider(item.el, fn, scopeState);
421
+ }
422
+ }
423
+ // Render template if present
424
+ if (options.template) {
425
+ const attrs = getTemplateAttrs(item.el);
426
+ let html;
427
+ if (typeof options.template === 'string') {
428
+ html = options.template;
429
+ }
430
+ else {
431
+ const result = options.template(attrs, item.el);
432
+ html = result instanceof Promise ? await result : result;
433
+ }
434
+ item.el.innerHTML = html;
435
+ }
436
+ }
310
437
  else if (item.directive === null) {
311
438
  // Placeholder for g-scope - already processed above
312
439
  continue;
313
440
  }
314
441
  else {
315
- // Determine scope for this directive
316
- let scopeState;
317
- if (item.directive.$context?.length) {
318
- // Directives with $context get their own scope to populate
319
- const parentScope = findServerScope(item.el, state);
320
- scopeState = createElementScope(item.el, parentScope);
321
- }
322
- else {
323
- // Other directives use nearest ancestor scope or root
324
- 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);
325
450
  }
326
451
  const config = createServerResolverConfig(item.el, scopeState, state);
327
452
  const args = resolveInjectables(item.directive, item.expr, item.el, ctx.eval.bind(ctx), config, item.using);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * TypeScript Language Service Plugin for Gonia.
3
+ *
4
+ * Provides type inference and validation for directive templates.
5
+ * Analyzes g-model, g-text, etc. in templates and infers $scope types.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import type tslib from 'typescript/lib/tsserverlibrary';
10
+ declare function init(modules: {
11
+ typescript: typeof tslib;
12
+ }): {
13
+ create: (info: tslib.server.PluginCreateInfo) => tslib.LanguageService;
14
+ };
15
+ export = init;
@@ -0,0 +1,518 @@
1
+ "use strict";
2
+ /**
3
+ * TypeScript Language Service Plugin for Gonia.
4
+ *
5
+ * Provides type inference and validation for directive templates.
6
+ * Analyzes g-model, g-text, etc. in templates and infers $scope types.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+ /**
11
+ * Infer types from HTML template based on element context.
12
+ */
13
+ function inferTypesFromTemplate(template) {
14
+ const types = [];
15
+ // Match g-model on various input types
16
+ const inputModelRegex = /<input[^>]*type=["'](\w+)["'][^>]*g-model=["']([^"']+)["'][^>]*>/gi;
17
+ const inputModelRegex2 = /<input[^>]*g-model=["']([^"']+)["'][^>]*type=["'](\w+)["'][^>]*>/gi;
18
+ const genericModelRegex = /<input[^>]*g-model=["']([^"']+)["'][^>]*>/gi;
19
+ const textareaModelRegex = /<textarea[^>]*g-model=["']([^"']+)["'][^>]*>/gi;
20
+ const selectModelRegex = /<select[^>]*g-model=["']([^"']+)["'][^>]*>/gi;
21
+ let match;
22
+ // input with type before g-model
23
+ while ((match = inputModelRegex.exec(template)) !== null) {
24
+ const inputType = match[1].toLowerCase();
25
+ const property = match[2];
26
+ types.push({
27
+ property,
28
+ type: inferTypeFromInputType(inputType),
29
+ source: `<input type="${inputType}"> g-model`
30
+ });
31
+ }
32
+ // input with g-model before type
33
+ while ((match = inputModelRegex2.exec(template)) !== null) {
34
+ const property = match[1];
35
+ const inputType = match[2].toLowerCase();
36
+ types.push({
37
+ property,
38
+ type: inferTypeFromInputType(inputType),
39
+ source: `<input type="${inputType}"> g-model`
40
+ });
41
+ }
42
+ // input without explicit type (defaults to text)
43
+ while ((match = genericModelRegex.exec(template)) !== null) {
44
+ const property = match[1];
45
+ // Skip if already captured by type-specific regex
46
+ if (!types.some(t => t.property === property)) {
47
+ types.push({
48
+ property,
49
+ type: 'string',
50
+ source: '<input> g-model (default text)'
51
+ });
52
+ }
53
+ }
54
+ // textarea
55
+ while ((match = textareaModelRegex.exec(template)) !== null) {
56
+ types.push({
57
+ property: match[1],
58
+ type: 'string',
59
+ source: '<textarea> g-model'
60
+ });
61
+ }
62
+ // select
63
+ while ((match = selectModelRegex.exec(template)) !== null) {
64
+ types.push({
65
+ property: match[1],
66
+ type: 'string',
67
+ source: '<select> g-model'
68
+ });
69
+ }
70
+ // g-scope attributes - extract using balanced brace matching
71
+ const gScopeValues = extractGScopeValues(template);
72
+ for (const scopeValue of gScopeValues) {
73
+ const scopeTypes = parseGScopeTypes(scopeValue);
74
+ for (const scopeType of scopeTypes) {
75
+ // Don't override g-model inferences (they're more specific)
76
+ if (!types.some(t => t.property === scopeType.property)) {
77
+ types.push(scopeType);
78
+ }
79
+ }
80
+ }
81
+ return types;
82
+ }
83
+ function inferTypeFromInputType(inputType) {
84
+ switch (inputType) {
85
+ case 'checkbox':
86
+ case 'radio':
87
+ return 'boolean';
88
+ case 'number':
89
+ case 'range':
90
+ return 'number';
91
+ default:
92
+ return 'string';
93
+ }
94
+ }
95
+ /**
96
+ * Extract g-scope attribute values from template, handling nested quotes and braces.
97
+ */
98
+ function extractGScopeValues(template) {
99
+ const results = [];
100
+ // Find g-scope=" or g-scope='
101
+ let i = 0;
102
+ while (i < template.length) {
103
+ const gScopeMatch = template.slice(i).match(/g-scope=(["'])/);
104
+ if (!gScopeMatch)
105
+ break;
106
+ const quoteChar = gScopeMatch[1];
107
+ const startPos = i + gScopeMatch.index + gScopeMatch[0].length;
108
+ // Now extract until the matching closing quote, respecting nested braces and quotes
109
+ let depth = 0;
110
+ let inString = null;
111
+ let j = startPos;
112
+ let content = '';
113
+ while (j < template.length) {
114
+ const char = template[j];
115
+ if (inString) {
116
+ content += char;
117
+ if (char === inString && template[j - 1] !== '\\') {
118
+ inString = null;
119
+ }
120
+ }
121
+ else if (char === quoteChar && depth === 0) {
122
+ // End of attribute value
123
+ break;
124
+ }
125
+ else if (char === '"' || char === "'" || char === '`') {
126
+ inString = char;
127
+ content += char;
128
+ }
129
+ else if (char === '{') {
130
+ depth++;
131
+ content += char;
132
+ }
133
+ else if (char === '}') {
134
+ depth--;
135
+ content += char;
136
+ }
137
+ else {
138
+ content += char;
139
+ }
140
+ j++;
141
+ }
142
+ if (content.trim()) {
143
+ results.push(content.trim());
144
+ }
145
+ i = j + 1;
146
+ }
147
+ return results;
148
+ }
149
+ /**
150
+ * Infer type from a JavaScript literal value.
151
+ */
152
+ function inferTypeFromLiteral(value) {
153
+ const trimmed = value.trim();
154
+ // Boolean literals
155
+ if (trimmed === 'true' || trimmed === 'false') {
156
+ return 'boolean';
157
+ }
158
+ // Null literal
159
+ if (trimmed === 'null') {
160
+ return 'null';
161
+ }
162
+ // Number literals (including negative, decimal, scientific notation)
163
+ if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(trimmed)) {
164
+ return 'number';
165
+ }
166
+ // String literals (single or double quoted)
167
+ if (/^['"].*['"]$/.test(trimmed)) {
168
+ return 'string';
169
+ }
170
+ // Template literals
171
+ if (/^`.*`$/.test(trimmed)) {
172
+ return 'string';
173
+ }
174
+ // Array literals
175
+ if (trimmed.startsWith('[')) {
176
+ return 'array';
177
+ }
178
+ // Object literals
179
+ if (trimmed.startsWith('{')) {
180
+ return 'object';
181
+ }
182
+ return 'unknown';
183
+ }
184
+ /**
185
+ * Parse g-scope attribute to extract property types.
186
+ * Handles: g-scope="{ count: 0, name: 'Alice', enabled: true }"
187
+ */
188
+ function parseGScopeTypes(scopeExpr) {
189
+ const types = [];
190
+ // Remove outer braces if present
191
+ let expr = scopeExpr.trim();
192
+ if (expr.startsWith('{') && expr.endsWith('}')) {
193
+ expr = expr.slice(1, -1).trim();
194
+ }
195
+ if (!expr)
196
+ return types;
197
+ // Parse property: value pairs, respecting nesting
198
+ let depth = 0;
199
+ let current = '';
200
+ const pairs = [];
201
+ for (const char of expr) {
202
+ if (char === '{' || char === '[' || char === '(') {
203
+ depth++;
204
+ current += char;
205
+ }
206
+ else if (char === '}' || char === ']' || char === ')') {
207
+ depth--;
208
+ current += char;
209
+ }
210
+ else if (char === ',' && depth === 0) {
211
+ pairs.push(current.trim());
212
+ current = '';
213
+ }
214
+ else {
215
+ current += char;
216
+ }
217
+ }
218
+ if (current.trim()) {
219
+ pairs.push(current.trim());
220
+ }
221
+ // Extract property name and value from each pair
222
+ for (const pair of pairs) {
223
+ // Match: property: value or 'property': value or "property": value
224
+ const match = pair.match(/^(['"]?)(\w+)\1\s*:\s*(.+)$/);
225
+ if (match) {
226
+ const property = match[2];
227
+ const value = match[3];
228
+ const type = inferTypeFromLiteral(value);
229
+ types.push({
230
+ property,
231
+ type,
232
+ source: 'g-scope literal'
233
+ });
234
+ }
235
+ }
236
+ return types;
237
+ }
238
+ /**
239
+ * Extract the string value from a template literal or string literal node.
240
+ */
241
+ function getStringValue(node, ts) {
242
+ if (ts.isStringLiteral(node)) {
243
+ return node.text;
244
+ }
245
+ if (ts.isNoSubstitutionTemplateLiteral(node)) {
246
+ return node.text;
247
+ }
248
+ if (ts.isTemplateExpression(node)) {
249
+ // For template expressions with substitutions, just get the head for now
250
+ // This is a simplification - full support would need to evaluate the template
251
+ return node.head.text;
252
+ }
253
+ return null;
254
+ }
255
+ /**
256
+ * Find directive() calls and extract template + function info.
257
+ */
258
+ function findDirectiveCalls(sourceFile, ts) {
259
+ const results = [];
260
+ function visit(node) {
261
+ if (ts.isCallExpression(node)) {
262
+ const expr = node.expression;
263
+ // Check if it's a call to 'directive'
264
+ if (ts.isIdentifier(expr) && expr.text === 'directive') {
265
+ const args = node.arguments;
266
+ // directive(name, fn, options?) or directive(name, options)
267
+ if (args.length >= 2) {
268
+ let fnArg = null;
269
+ let optionsArg = null;
270
+ if (args.length === 2) {
271
+ // Could be directive(name, fn) or directive(name, options)
272
+ const second = args[1];
273
+ if (ts.isObjectLiteralExpression(second)) {
274
+ optionsArg = second;
275
+ }
276
+ else {
277
+ fnArg = second;
278
+ }
279
+ }
280
+ else if (args.length >= 3) {
281
+ fnArg = args[1];
282
+ optionsArg = args[2];
283
+ }
284
+ // Extract template from options
285
+ let template = null;
286
+ if (optionsArg && ts.isObjectLiteralExpression(optionsArg)) {
287
+ for (const prop of optionsArg.properties) {
288
+ if (ts.isPropertyAssignment(prop) &&
289
+ ts.isIdentifier(prop.name) &&
290
+ prop.name.text === 'template') {
291
+ template = getStringValue(prop.initializer, ts);
292
+ break;
293
+ }
294
+ }
295
+ }
296
+ if (template && fnArg) {
297
+ let functionName = '';
298
+ let functionNode = null;
299
+ if (ts.isIdentifier(fnArg)) {
300
+ functionName = fnArg.text;
301
+ // Try to find the function declaration
302
+ // This is simplified - a full implementation would use the type checker
303
+ }
304
+ else if (ts.isFunctionExpression(fnArg) || ts.isArrowFunction(fnArg)) {
305
+ functionName = '<inline>';
306
+ functionNode = fnArg;
307
+ }
308
+ results.push({
309
+ functionName,
310
+ functionNode,
311
+ template,
312
+ callNode: node
313
+ });
314
+ }
315
+ }
316
+ }
317
+ }
318
+ ts.forEachChild(node, visit);
319
+ }
320
+ visit(sourceFile);
321
+ return results;
322
+ }
323
+ /**
324
+ * Get the type of $scope parameter from a function.
325
+ */
326
+ function getScopeParamType(checker, fn, ts) {
327
+ if (!ts.isFunctionExpression(fn) && !ts.isArrowFunction(fn) && !ts.isFunctionDeclaration(fn)) {
328
+ return null;
329
+ }
330
+ const funcType = checker.getTypeAtLocation(fn);
331
+ const signatures = funcType.getCallSignatures();
332
+ if (signatures.length === 0)
333
+ return null;
334
+ const params = signatures[0].getParameters();
335
+ // Look for $scope parameter
336
+ for (const param of params) {
337
+ if (param.name === '$scope') {
338
+ const paramDecl = param.valueDeclaration;
339
+ if (paramDecl) {
340
+ return checker.getTypeAtLocation(paramDecl);
341
+ }
342
+ }
343
+ }
344
+ return null;
345
+ }
346
+ /**
347
+ * Check if a type is compatible with the expected type.
348
+ */
349
+ function isTypeCompatible(checker, actualType, propertyName, expectedType, ts) {
350
+ const prop = actualType.getProperty(propertyName);
351
+ if (!prop) {
352
+ // Property not found - might be using index signature or any
353
+ return { compatible: true, actualTypeName: 'unknown' };
354
+ }
355
+ const propDecl = prop.valueDeclaration;
356
+ if (!propDecl) {
357
+ return { compatible: true, actualTypeName: 'unknown' };
358
+ }
359
+ const propType = checker.getTypeAtLocation(propDecl);
360
+ const propTypeName = checker.typeToString(propType);
361
+ if (expectedType === 'unknown') {
362
+ return { compatible: true, actualTypeName: propTypeName };
363
+ }
364
+ // Check type compatibility
365
+ const typeFlags = propType.flags;
366
+ let isExpectedType = false;
367
+ switch (expectedType) {
368
+ case 'boolean':
369
+ isExpectedType = !!(typeFlags & ts.TypeFlags.BooleanLike);
370
+ break;
371
+ case 'string':
372
+ isExpectedType = !!(typeFlags & ts.TypeFlags.StringLike);
373
+ break;
374
+ case 'number':
375
+ isExpectedType = !!(typeFlags & ts.TypeFlags.NumberLike);
376
+ break;
377
+ case 'null':
378
+ isExpectedType = !!(typeFlags & ts.TypeFlags.Null);
379
+ break;
380
+ case 'array':
381
+ // Check if it's an array type
382
+ isExpectedType = checker.isArrayType(propType) ||
383
+ checker.isTupleType(propType) ||
384
+ propTypeName.endsWith('[]') ||
385
+ propTypeName.startsWith('Array<');
386
+ break;
387
+ case 'object':
388
+ // Object is compatible with most non-primitive types
389
+ isExpectedType = !!(typeFlags & ts.TypeFlags.Object) &&
390
+ !checker.isArrayType(propType);
391
+ break;
392
+ }
393
+ // Also check if it's a union that includes the expected type
394
+ if (!isExpectedType && propType.isUnion()) {
395
+ for (const unionType of propType.types) {
396
+ const unionTypeName = checker.typeToString(unionType);
397
+ switch (expectedType) {
398
+ case 'boolean':
399
+ if (unionType.flags & ts.TypeFlags.BooleanLike)
400
+ isExpectedType = true;
401
+ break;
402
+ case 'string':
403
+ if (unionType.flags & ts.TypeFlags.StringLike)
404
+ isExpectedType = true;
405
+ break;
406
+ case 'number':
407
+ if (unionType.flags & ts.TypeFlags.NumberLike)
408
+ isExpectedType = true;
409
+ break;
410
+ case 'null':
411
+ if (unionType.flags & ts.TypeFlags.Null)
412
+ isExpectedType = true;
413
+ break;
414
+ case 'array':
415
+ if (checker.isArrayType(unionType) ||
416
+ checker.isTupleType(unionType) ||
417
+ unionTypeName.endsWith('[]') ||
418
+ unionTypeName.startsWith('Array<')) {
419
+ isExpectedType = true;
420
+ }
421
+ break;
422
+ case 'object':
423
+ if ((unionType.flags & ts.TypeFlags.Object) && !checker.isArrayType(unionType)) {
424
+ isExpectedType = true;
425
+ }
426
+ break;
427
+ }
428
+ }
429
+ }
430
+ return { compatible: isExpectedType, actualTypeName: propTypeName };
431
+ }
432
+ function init(modules) {
433
+ const ts = modules.typescript;
434
+ function create(info) {
435
+ const log = (msg) => {
436
+ info.project.projectService.logger.info(`[gonia] ${msg}`);
437
+ };
438
+ log('Gonia TypeScript plugin initialized');
439
+ // Create proxy for language service
440
+ const proxy = Object.create(null);
441
+ const oldLS = info.languageService;
442
+ // Copy all methods
443
+ for (const k in oldLS) {
444
+ const key = k;
445
+ proxy[k] = function (...args) {
446
+ return oldLS[key].apply(oldLS, args);
447
+ };
448
+ }
449
+ // Override getSemanticDiagnostics
450
+ proxy.getSemanticDiagnostics = (fileName) => {
451
+ const prior = oldLS.getSemanticDiagnostics(fileName);
452
+ const program = oldLS.getProgram();
453
+ if (!program)
454
+ return prior;
455
+ const sourceFile = program.getSourceFile(fileName);
456
+ if (!sourceFile)
457
+ return prior;
458
+ // Only check .ts/.tsx files
459
+ if (!fileName.endsWith('.ts') && !fileName.endsWith('.tsx')) {
460
+ return prior;
461
+ }
462
+ const checker = program.getTypeChecker();
463
+ const customDiagnostics = [];
464
+ // Find directive() calls
465
+ const directiveCalls = findDirectiveCalls(sourceFile, ts);
466
+ for (const call of directiveCalls) {
467
+ // Infer types from template
468
+ const templateTypes = inferTypesFromTemplate(call.template);
469
+ if (templateTypes.length === 0)
470
+ continue;
471
+ // Get the function node
472
+ let fnNode = call.functionNode;
473
+ if (!fnNode && call.functionName && call.functionName !== '<inline>') {
474
+ // Find the function by name in the file
475
+ ts.forEachChild(sourceFile, (node) => {
476
+ if (ts.isVariableStatement(node)) {
477
+ for (const decl of node.declarationList.declarations) {
478
+ if (ts.isIdentifier(decl.name) && decl.name.text === call.functionName) {
479
+ if (decl.initializer) {
480
+ fnNode = decl.initializer;
481
+ }
482
+ }
483
+ }
484
+ }
485
+ else if (ts.isFunctionDeclaration(node) && node.name?.text === call.functionName) {
486
+ fnNode = node;
487
+ }
488
+ });
489
+ }
490
+ if (!fnNode)
491
+ continue;
492
+ // Get $scope type
493
+ const scopeType = getScopeParamType(checker, fnNode, ts);
494
+ if (!scopeType)
495
+ continue;
496
+ // Check each template-inferred type
497
+ for (const templateType of templateTypes) {
498
+ const { compatible, actualTypeName } = isTypeCompatible(checker, scopeType, templateType.property, templateType.type, ts);
499
+ if (!compatible) {
500
+ customDiagnostics.push({
501
+ file: sourceFile,
502
+ start: call.callNode.getStart(),
503
+ length: call.callNode.getWidth(),
504
+ messageText: `Template expects '$scope.${templateType.property}' to be ${templateType.type} (from ${templateType.source}), but directive declares it as ${actualTypeName}`,
505
+ category: ts.DiagnosticCategory.Error,
506
+ code: 90001,
507
+ source: 'gonia'
508
+ });
509
+ }
510
+ }
511
+ }
512
+ return [...prior, ...customDiagnostics];
513
+ };
514
+ return proxy;
515
+ }
516
+ return { create };
517
+ }
518
+ module.exports = init;
@@ -11,7 +11,52 @@
11
11
  */
12
12
  import { readFileSync } from 'fs';
13
13
  import { glob } from 'tinyglobby';
14
- import { relative } from 'path';
14
+ import { relative, join } from 'path';
15
+ /**
16
+ * Scan dependencies for packages that export gonia directives.
17
+ * Looks for `gonia` field in each dependency's package.json.
18
+ */
19
+ function discoverLibraryDirectives(rootDir) {
20
+ const directives = new Map();
21
+ try {
22
+ const pkgPath = join(rootDir, 'package.json');
23
+ const pkgContent = readFileSync(pkgPath, 'utf-8');
24
+ const pkg = JSON.parse(pkgContent);
25
+ const allDeps = {
26
+ ...pkg.dependencies,
27
+ ...pkg.devDependencies,
28
+ };
29
+ for (const depName of Object.keys(allDeps)) {
30
+ try {
31
+ // Find the dependency's package.json
32
+ const depPkgPath = join(rootDir, 'node_modules', depName, 'package.json');
33
+ const depPkgContent = readFileSync(depPkgPath, 'utf-8');
34
+ const depPkg = JSON.parse(depPkgContent);
35
+ const goniaConfig = depPkg.gonia;
36
+ if (!goniaConfig?.directives)
37
+ continue;
38
+ // Register each directive from this package
39
+ for (const [directiveName, relativePath] of Object.entries(goniaConfig.directives)) {
40
+ // Resolve the module path
41
+ const modulePath = `${depName}/${relativePath.replace(/^\.\//, '')}`;
42
+ directives.set(directiveName, {
43
+ name: directiveName,
44
+ exportName: null, // Will be imported as default or named based on convention
45
+ module: modulePath,
46
+ isBuiltin: false,
47
+ });
48
+ }
49
+ }
50
+ catch {
51
+ // Dependency doesn't have gonia config or couldn't be read - skip
52
+ }
53
+ }
54
+ }
55
+ catch {
56
+ // Could not read root package.json - skip library discovery
57
+ }
58
+ return directives;
59
+ }
15
60
  /**
16
61
  * Map of built-in directive names to their export names from gonia.
17
62
  */
@@ -334,20 +379,30 @@ export function gonia(options = {}) {
334
379
  rootDir = config.root;
335
380
  },
336
381
  async buildStart() {
337
- // Scan directive sources to discover custom directives
382
+ // First, discover directives from installed libraries (lowest priority)
383
+ const libraryDirectives = discoverLibraryDirectives(rootDir);
384
+ for (const [name, info] of libraryDirectives) {
385
+ customDirectives.set(name, info);
386
+ }
387
+ if (isDev && libraryDirectives.size > 0) {
388
+ console.log(`[gonia] Discovered ${libraryDirectives.size} directive(s) from libraries`);
389
+ }
390
+ // Then scan local directive sources (higher priority - will override library)
338
391
  if (directiveSources.length > 0) {
339
392
  const files = await glob(directiveSources, {
340
393
  cwd: rootDir,
341
394
  absolute: true,
342
395
  });
396
+ let localCount = 0;
343
397
  for (const file of files) {
344
398
  const directives = scanFileForDirectives(file);
345
399
  for (const [name, info] of directives) {
346
400
  customDirectives.set(name, info);
401
+ localCount++;
347
402
  }
348
403
  }
349
- if (isDev && customDirectives.size > 0) {
350
- console.log(`[gonia] Discovered ${customDirectives.size} custom directive(s)`);
404
+ if (isDev && localCount > 0) {
405
+ console.log(`[gonia] Discovered ${localCount} local directive(s)`);
351
406
  }
352
407
  }
353
408
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gonia",
3
- "version": "0.2.6",
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",
@@ -43,10 +43,14 @@
43
43
  "./vite": {
44
44
  "types": "./dist/vite/index.d.ts",
45
45
  "import": "./dist/vite/index.js"
46
+ },
47
+ "./ts-plugin": {
48
+ "types": "./dist/ts-plugin/index.d.ts",
49
+ "require": "./dist/ts-plugin/index.js"
46
50
  }
47
51
  },
48
52
  "dependencies": {
49
- "happy-dom": "^17.4.4",
53
+ "happy-dom": "^20.3.9",
50
54
  "tinyglobby": "^0.2.15"
51
55
  },
52
56
  "peerDependencies": {
@@ -60,17 +64,14 @@
60
64
  "devDependencies": {
61
65
  "@types/node": "^25.0.10",
62
66
  "@vitest/coverage-v8": "^4.0.17",
63
- "conventional-changelog-cli": "^5.0.0",
64
67
  "jsdom": "^27.4.0",
65
68
  "typescript": "^5.7.0",
66
69
  "vite": "^6.4.0",
67
70
  "vitest": "^4.0.17"
68
71
  },
69
72
  "scripts": {
70
- "build": "tsc",
73
+ "build": "tsc && tsc -p src/ts-plugin/tsconfig.json",
71
74
  "test": "vitest run",
72
- "test:watch": "vitest",
73
- "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
74
- "changelog:all": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0"
75
+ "test:watch": "vitest"
75
76
  }
76
77
  }