gonia 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,10 +4,10 @@
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';
@@ -30,24 +30,56 @@ function decodeHTMLEntities(str) {
30
30
  }
31
31
  /** Registered services */
32
32
  let services = new Map();
33
- const selectorCache = new WeakMap();
34
33
  /**
35
34
  * Build a CSS selector for all registered directives.
35
+ * Uses the global directive registry to support any prefix (g-, l-, v-, etc.).
36
+ * Also includes local registry entries with g- prefix for backward compatibility.
36
37
  *
37
38
  * @internal
38
39
  */
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);
40
+ function getSelector(localRegistry) {
41
+ const selectors = [];
42
+ for (const name of getDirectiveNames()) {
43
+ const registration = getDirective(name);
44
+ if (!registration)
45
+ continue;
46
+ const { options } = registration;
47
+ // Custom element directives - match by tag name
48
+ if (options.template || options.scope || options.provide || options.using) {
49
+ selectors.push(name);
50
+ }
51
+ // All directives can be used as attributes
52
+ selectors.push(`[${name}]`);
53
+ }
54
+ // Add local registry entries with g- prefix (backward compatibility)
55
+ if (localRegistry) {
56
+ for (const name of localRegistry.keys()) {
57
+ const fullName = `g-${name}`;
58
+ // Skip if already in global registry
59
+ if (!getDirective(fullName)) {
60
+ selectors.push(`[${fullName}]`);
61
+ }
62
+ }
49
63
  }
50
- return selector;
64
+ // Also match native <slot> elements
65
+ selectors.push('slot');
66
+ // Match g-scope for inline scope initialization (TODO: make prefix configurable)
67
+ selectors.push('[g-scope]');
68
+ return selectors.join(',');
69
+ }
70
+ /**
71
+ * Get template attributes from an element.
72
+ *
73
+ * @internal
74
+ */
75
+ function getTemplateAttrs(el) {
76
+ const attrs = {
77
+ children: el.innerHTML
78
+ };
79
+ for (const attr of el.attributes) {
80
+ attrs[attr.name] = attr.value;
81
+ }
82
+ return attrs;
51
83
  }
52
84
  /**
53
85
  * Check if element has any g-bind:* attributes.
@@ -65,16 +97,14 @@ function hasBindAttributes(el) {
65
97
  /**
66
98
  * Register a directive in the registry.
67
99
  *
68
- * @remarks
69
- * Invalidates the cached selector so it will be rebuilt on next render.
70
- *
71
100
  * @param registry - The directive registry
72
- * @param name - Directive name (without g- prefix)
101
+ * @param name - Directive name
73
102
  * @param fn - The directive function
103
+ *
104
+ * @deprecated Use `directive()` from 'gonia' instead to register directives globally.
74
105
  */
75
106
  export function registerDirective(registry, name, fn) {
76
107
  registry.set(name, fn);
77
- selectorCache.delete(registry);
78
108
  }
79
109
  /**
80
110
  * Register a service for dependency injection.
@@ -189,8 +219,8 @@ export async function render(html, state, registry) {
189
219
  // Add a placeholder entry so they get processed
190
220
  if (match.hasAttribute('g-scope')) {
191
221
  let hasDirective = false;
192
- for (const [name] of registry) {
193
- if (match.hasAttribute(`g-${name}`)) {
222
+ for (const name of getDirectiveNames()) {
223
+ if (match.hasAttribute(name)) {
194
224
  hasDirective = true;
195
225
  break;
196
226
  }
@@ -206,18 +236,55 @@ export async function render(html, state, registry) {
206
236
  });
207
237
  }
208
238
  }
239
+ // Check all registered directives from global registry
240
+ const tagName = match.tagName.toLowerCase();
241
+ for (const name of getDirectiveNames()) {
242
+ const registration = getDirective(name);
243
+ if (!registration)
244
+ continue;
245
+ const { fn, options } = registration;
246
+ // Check if this is a custom element directive (tag name matches)
247
+ if (tagName === name) {
248
+ if (options.template || options.scope || options.provide || options.using) {
249
+ index.push({
250
+ el: match,
251
+ name,
252
+ directive: fn,
253
+ expr: '',
254
+ priority: fn?.priority ?? DirectivePriority.TEMPLATE,
255
+ isCustomElement: true,
256
+ using: options.using
257
+ });
258
+ }
259
+ }
260
+ // Check if this is an attribute directive
261
+ const attr = match.getAttribute(name);
262
+ if (attr !== null) {
263
+ index.push({
264
+ el: match,
265
+ name,
266
+ directive: fn,
267
+ expr: decodeHTMLEntities(attr),
268
+ priority: fn?.priority ?? DirectivePriority.NORMAL,
269
+ using: options.using
270
+ });
271
+ }
272
+ }
273
+ // Also check local registry for backward compatibility
274
+ // Local registry uses short names with g- prefix
209
275
  for (const [name, directive] of registry) {
210
276
  const attr = match.getAttribute(`g-${name}`);
211
277
  if (attr !== null) {
212
- // Look up options from the global directive registry
213
- const registration = getDirective(`g-${name}`);
278
+ // Skip if already added from global registry
279
+ const fullName = `g-${name}`;
280
+ if (getDirective(fullName))
281
+ continue;
214
282
  index.push({
215
283
  el: match,
216
284
  name,
217
285
  directive,
218
286
  expr: decodeHTMLEntities(attr),
219
- priority: directive.priority ?? DirectivePriority.NORMAL,
220
- using: registration?.options.using
287
+ priority: directive.priority ?? DirectivePriority.NORMAL
221
288
  });
222
289
  }
223
290
  }
@@ -307,11 +374,61 @@ export async function render(html, state, registry) {
307
374
  if (item.isNativeSlot) {
308
375
  processNativeSlot(item.el);
309
376
  }
377
+ else if (item.isCustomElement) {
378
+ // Custom element directive - must check before directive === null
379
+ // because custom elements can have fn: null (template-only)
380
+ // Custom element directive - process template, scope, etc.
381
+ const registration = getDirective(item.name);
382
+ if (!registration)
383
+ continue;
384
+ 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
399
+ if (options.provide) {
400
+ registerDIProviders(item.el, options.provide);
401
+ }
402
+ // 3. Call directive function if present (initializes state)
403
+ if (fn) {
404
+ const config = createServerResolverConfig(item.el, scopeState, state);
405
+ const args = resolveInjectables(fn, item.expr, item.el, ctx.eval.bind(ctx), config, options.using);
406
+ await fn(...args);
407
+ // Register as context provider if directive declares $context
408
+ if (fn.$context?.length) {
409
+ registerProvider(item.el, fn, scopeState);
410
+ }
411
+ }
412
+ // 4. Render template if present
413
+ if (options.template) {
414
+ const attrs = getTemplateAttrs(item.el);
415
+ let html;
416
+ if (typeof options.template === 'string') {
417
+ html = options.template;
418
+ }
419
+ else {
420
+ const result = options.template(attrs, item.el);
421
+ html = result instanceof Promise ? await result : result;
422
+ }
423
+ item.el.innerHTML = html;
424
+ }
425
+ }
310
426
  else if (item.directive === null) {
311
427
  // Placeholder for g-scope - already processed above
312
428
  continue;
313
429
  }
314
430
  else {
431
+ // Attribute directive
315
432
  // Determine scope for this directive
316
433
  let scopeState;
317
434
  if (item.directive.$context?.length) {
@@ -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
  */
@@ -216,6 +261,35 @@ function generateImports(directives, customDirectives, currentFile, rootDir, dir
216
261
  }
217
262
  return statements.length > 0 ? statements.join('\n') + '\n' : '';
218
263
  }
264
+ /**
265
+ * Split function parameters, respecting nested angle brackets in generics.
266
+ */
267
+ function splitParams(paramsStr) {
268
+ const params = [];
269
+ let current = '';
270
+ let depth = 0;
271
+ for (const char of paramsStr) {
272
+ if (char === '<') {
273
+ depth++;
274
+ current += char;
275
+ }
276
+ else if (char === '>') {
277
+ depth--;
278
+ current += char;
279
+ }
280
+ else if (char === ',' && depth === 0) {
281
+ params.push(current.trim());
282
+ current = '';
283
+ }
284
+ else {
285
+ current += char;
286
+ }
287
+ }
288
+ if (current.trim()) {
289
+ params.push(current.trim());
290
+ }
291
+ return params;
292
+ }
219
293
  /**
220
294
  * Transform source code to add $inject arrays to directive functions.
221
295
  */
@@ -244,9 +318,7 @@ function transformInject(code) {
244
318
  const paramsStr = fnMatch[1] || fnMatch[2];
245
319
  if (!paramsStr)
246
320
  continue;
247
- const params = paramsStr
248
- .split(',')
249
- .map(p => p.trim())
321
+ const params = splitParams(paramsStr)
250
322
  .map(p => p.replace(/\s*:.*$/, ''))
251
323
  .map(p => p.replace(/\s*=.*$/, ''))
252
324
  .filter(Boolean);
@@ -307,26 +379,36 @@ export function gonia(options = {}) {
307
379
  rootDir = config.root;
308
380
  },
309
381
  async buildStart() {
310
- // 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)
311
391
  if (directiveSources.length > 0) {
312
392
  const files = await glob(directiveSources, {
313
393
  cwd: rootDir,
314
394
  absolute: true,
315
395
  });
396
+ let localCount = 0;
316
397
  for (const file of files) {
317
398
  const directives = scanFileForDirectives(file);
318
399
  for (const [name, info] of directives) {
319
400
  customDirectives.set(name, info);
401
+ localCount++;
320
402
  }
321
403
  }
322
- if (isDev && customDirectives.size > 0) {
323
- console.log(`[gonia] Discovered ${customDirectives.size} custom directive(s)`);
404
+ if (isDev && localCount > 0) {
405
+ console.log(`[gonia] Discovered ${localCount} local directive(s)`);
324
406
  }
325
407
  }
326
408
  },
327
409
  transform(code, id) {
328
410
  // Skip node_modules (except for $inject transform in gonia itself)
329
- const isGoniaInternal = id.includes('gonia') && id.includes('node_modules');
411
+ const isGoniaInternal = id.includes('/gonia/') && (id.includes('node_modules') || id.includes('/dist/'));
330
412
  if (id.includes('node_modules') && !isGoniaInternal)
331
413
  return null;
332
414
  if (!/\.(ts|js|tsx|jsx|html)$/.test(id))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gonia",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
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
  }