gonia 0.2.6 → 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.
- package/dist/server/render.d.ts +3 -4
- package/dist/server/render.js +142 -25
- package/dist/ts-plugin/index.d.ts +15 -0
- package/dist/ts-plugin/index.js +518 -0
- package/dist/vite/plugin.js +59 -4
- package/package.json +8 -7
package/dist/server/render.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/server/render.js
CHANGED
|
@@ -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(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
193
|
-
if (match.hasAttribute(
|
|
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
|
-
//
|
|
213
|
-
const
|
|
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;
|
package/dist/vite/plugin.js
CHANGED
|
@@ -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
|
-
//
|
|
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 &&
|
|
350
|
-
console.log(`[gonia] Discovered ${
|
|
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.
|
|
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": "^
|
|
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
|
}
|