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.
- package/dist/client/hydrate.js +61 -6
- package/dist/directive-utils.d.ts +50 -0
- package/dist/directive-utils.js +101 -0
- package/dist/server/render.d.ts +3 -4
- package/dist/server/render.js +161 -36
- 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/client/hydrate.js
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
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
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
}
|
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,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(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
193
|
-
if (match.hasAttribute(
|
|
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
|
-
//
|
|
213
|
-
const
|
|
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 =
|
|
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
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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;
|
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.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": "^
|
|
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
|
}
|