@zenithbuild/language-server 0.2.7 → 0.5.0-beta.2.19
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/CHANGELOG.md +6 -0
- package/README.md +14 -0
- package/RELEASE_NOTES.md +33 -0
- package/package.json +5 -2
- package/scripts/release.ts +24 -7
- package/src/code-actions.ts +60 -0
- package/src/contracts.ts +100 -0
- package/src/diagnostics.ts +391 -74
- package/src/project.ts +141 -25
- package/src/server.ts +68 -46
- package/src/settings.ts +15 -0
- package/src/types/zenith-compiler.d.ts +3 -0
- package/test/contracts.spec.ts +37 -0
- package/test/diagnostics.spec.ts +101 -0
- package/test/project-root.spec.ts +44 -0
- package/tsconfig.test.json +25 -0
- package/bun.lock +0 -83
package/src/project.ts
CHANGED
|
@@ -22,40 +22,156 @@ export interface ProjectGraph {
|
|
|
22
22
|
pages: Map<string, ComponentInfo>;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const ZENITH_CONFIG_CANDIDATES = [
|
|
26
|
+
'zenith.config.ts',
|
|
27
|
+
'zenith.config.js',
|
|
28
|
+
'zenith.config.mjs',
|
|
29
|
+
'zenith.config.cjs',
|
|
30
|
+
'zenith.config.json'
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function hasZenithConfig(dir: string): boolean {
|
|
34
|
+
return ZENITH_CONFIG_CANDIDATES.some((fileName) => fs.existsSync(path.join(dir, fileName)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasZenithCliDependency(dir: string): boolean {
|
|
38
|
+
const packageJsonPath = path.join(dir, 'package.json');
|
|
39
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const raw = fs.readFileSync(packageJsonPath, 'utf-8');
|
|
45
|
+
const pkg = JSON.parse(raw) as {
|
|
46
|
+
dependencies?: Record<string, string>;
|
|
47
|
+
devDependencies?: Record<string, string>;
|
|
48
|
+
peerDependencies?: Record<string, string>;
|
|
49
|
+
optionalDependencies?: Record<string, string>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const deps = [
|
|
53
|
+
pkg.dependencies || {},
|
|
54
|
+
pkg.devDependencies || {},
|
|
55
|
+
pkg.peerDependencies || {},
|
|
56
|
+
pkg.optionalDependencies || {}
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
return deps.some((group) => Object.prototype.hasOwnProperty.call(group, '@zenithbuild/cli'));
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasZenithStructure(dir: string): boolean {
|
|
66
|
+
const srcDir = path.join(dir, 'src');
|
|
67
|
+
if (fs.existsSync(srcDir)) {
|
|
68
|
+
const hasPages = fs.existsSync(path.join(srcDir, 'pages'));
|
|
69
|
+
const hasLayouts = fs.existsSync(path.join(srcDir, 'layouts'));
|
|
70
|
+
if (hasPages || hasLayouts) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const appDir = path.join(dir, 'app');
|
|
76
|
+
if (fs.existsSync(appDir)) {
|
|
77
|
+
const hasPages = fs.existsSync(path.join(appDir, 'pages'));
|
|
78
|
+
const hasLayouts = fs.existsSync(path.join(appDir, 'layouts'));
|
|
79
|
+
if (hasPages || hasLayouts) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findNearestByRule(startPath: string, predicate: (dir: string) => boolean): string | null {
|
|
88
|
+
let current = path.resolve(startPath);
|
|
89
|
+
if (!fs.existsSync(current)) {
|
|
90
|
+
current = path.dirname(current);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
while (!fs.existsSync(current) && current !== path.dirname(current)) {
|
|
94
|
+
current = path.dirname(current);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!fs.existsSync(current)) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!fs.statSync(current).isDirectory()) {
|
|
102
|
+
current = path.dirname(current);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
while (current !== path.dirname(current)) {
|
|
106
|
+
if (predicate(current)) {
|
|
107
|
+
return current;
|
|
108
|
+
}
|
|
109
|
+
current = path.dirname(current);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (predicate(current)) {
|
|
113
|
+
return current;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findFallbackRoot(startPath: string): string | null {
|
|
120
|
+
return findNearestByRule(startPath, (dir) => {
|
|
121
|
+
if (fs.existsSync(path.join(dir, 'package.json'))) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
if (hasZenithStructure(dir)) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
25
131
|
/**
|
|
26
132
|
* Detect Zenith project root
|
|
27
|
-
*
|
|
133
|
+
* Priority:
|
|
134
|
+
* 1) nearest zenith.config.*
|
|
135
|
+
* 2) nearest package.json with @zenithbuild/cli
|
|
136
|
+
* 3) nearest Zenith structure (src/pages|layouts or app/pages|layouts)
|
|
137
|
+
* 4) workspace folder fallbacks (if provided)
|
|
138
|
+
* 5) nearest package.json or Zenith structure
|
|
28
139
|
*/
|
|
29
|
-
export function detectProjectRoot(startPath: string): string | null {
|
|
30
|
-
|
|
140
|
+
export function detectProjectRoot(startPath: string, workspaceFolders: string[] = []): string | null {
|
|
141
|
+
const localConfigRoot = findNearestByRule(startPath, hasZenithConfig);
|
|
142
|
+
if (localConfigRoot) {
|
|
143
|
+
return localConfigRoot;
|
|
144
|
+
}
|
|
31
145
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
146
|
+
const localCliRoot = findNearestByRule(startPath, hasZenithCliDependency);
|
|
147
|
+
if (localCliRoot) {
|
|
148
|
+
return localCliRoot;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const localStructureRoot = findNearestByRule(startPath, hasZenithStructure);
|
|
152
|
+
if (localStructureRoot) {
|
|
153
|
+
return localStructureRoot;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const absoluteStart = path.resolve(startPath);
|
|
157
|
+
const matchingWorkspaceFolders = workspaceFolders
|
|
158
|
+
.map((workspacePath) => path.resolve(workspacePath))
|
|
159
|
+
.filter((workspacePath) => absoluteStart === workspacePath || absoluteStart.startsWith(`${workspacePath}${path.sep}`))
|
|
160
|
+
.sort((a, b) => b.length - a.length);
|
|
161
|
+
|
|
162
|
+
for (const workspaceRoot of matchingWorkspaceFolders) {
|
|
163
|
+
if (hasZenithConfig(workspaceRoot)) {
|
|
164
|
+
return workspaceRoot;
|
|
36
165
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (fs.existsSync(srcDir)) {
|
|
40
|
-
const hasPages = fs.existsSync(path.join(srcDir, 'pages'));
|
|
41
|
-
const hasLayouts = fs.existsSync(path.join(srcDir, 'layouts'));
|
|
42
|
-
if (hasPages || hasLayouts) {
|
|
43
|
-
return current;
|
|
44
|
-
}
|
|
166
|
+
if (hasZenithCliDependency(workspaceRoot)) {
|
|
167
|
+
return workspaceRoot;
|
|
45
168
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (fs.existsSync(appDir)) {
|
|
49
|
-
const hasPages = fs.existsSync(path.join(appDir, 'pages'));
|
|
50
|
-
const hasLayouts = fs.existsSync(path.join(appDir, 'layouts'));
|
|
51
|
-
if (hasPages || hasLayouts) {
|
|
52
|
-
return current;
|
|
53
|
-
}
|
|
169
|
+
if (hasZenithStructure(workspaceRoot)) {
|
|
170
|
+
return workspaceRoot;
|
|
54
171
|
}
|
|
55
|
-
current = path.dirname(current);
|
|
56
172
|
}
|
|
57
173
|
|
|
58
|
-
return
|
|
174
|
+
return findFallbackRoot(startPath);
|
|
59
175
|
}
|
|
60
176
|
|
|
61
177
|
/**
|
package/src/server.ts
CHANGED
|
@@ -15,9 +15,12 @@ import {
|
|
|
15
15
|
TextDocuments,
|
|
16
16
|
ProposedFeatures,
|
|
17
17
|
InitializeParams,
|
|
18
|
+
DidChangeConfigurationNotification,
|
|
18
19
|
CompletionItem,
|
|
19
20
|
CompletionItemKind,
|
|
20
21
|
TextDocumentPositionParams,
|
|
22
|
+
CodeActionParams,
|
|
23
|
+
CodeAction,
|
|
21
24
|
TextDocumentSyncKind,
|
|
22
25
|
InitializeResult,
|
|
23
26
|
Hover,
|
|
@@ -35,27 +38,27 @@ import {
|
|
|
35
38
|
ProjectGraph
|
|
36
39
|
} from './project';
|
|
37
40
|
|
|
38
|
-
import {
|
|
39
|
-
DIRECTIVES,
|
|
40
|
-
isDirective,
|
|
41
|
-
getDirective,
|
|
41
|
+
import {
|
|
42
|
+
DIRECTIVES,
|
|
43
|
+
isDirective,
|
|
44
|
+
getDirective,
|
|
42
45
|
getDirectiveNames,
|
|
43
46
|
canPlaceDirective,
|
|
44
|
-
parseForExpression
|
|
47
|
+
parseForExpression
|
|
45
48
|
} from './metadata/directive-metadata';
|
|
46
49
|
|
|
47
|
-
import {
|
|
48
|
-
parseZenithImports,
|
|
49
|
-
hasRouterImport,
|
|
50
|
-
resolveModule,
|
|
50
|
+
import {
|
|
51
|
+
parseZenithImports,
|
|
52
|
+
hasRouterImport,
|
|
53
|
+
resolveModule,
|
|
51
54
|
resolveExport,
|
|
52
55
|
getAllModules,
|
|
53
|
-
getModuleExports
|
|
56
|
+
getModuleExports
|
|
54
57
|
} from './imports';
|
|
55
58
|
|
|
56
|
-
import {
|
|
57
|
-
ROUTER_HOOKS,
|
|
58
|
-
ZENLINK_PROPS,
|
|
59
|
+
import {
|
|
60
|
+
ROUTER_HOOKS,
|
|
61
|
+
ZENLINK_PROPS,
|
|
59
62
|
ROUTE_FIELDS,
|
|
60
63
|
getRouterHook,
|
|
61
64
|
isRouterHook,
|
|
@@ -63,6 +66,8 @@ import {
|
|
|
63
66
|
} from './router';
|
|
64
67
|
|
|
65
68
|
import { collectDiagnostics } from './diagnostics';
|
|
69
|
+
import { buildEventBindingCodeActions } from './code-actions';
|
|
70
|
+
import { DEFAULT_SETTINGS, normalizeSettings, ZenithServerSettings } from './settings';
|
|
66
71
|
|
|
67
72
|
// Create connection and document manager
|
|
68
73
|
const connection = createConnection(ProposedFeatures.all);
|
|
@@ -70,6 +75,8 @@ const documents = new TextDocuments(TextDocument);
|
|
|
70
75
|
|
|
71
76
|
// Project graph cache
|
|
72
77
|
let projectGraphs: Map<string, ProjectGraph> = new Map();
|
|
78
|
+
let workspaceFolders: string[] = [];
|
|
79
|
+
let globalSettings: ZenithServerSettings = DEFAULT_SETTINGS;
|
|
73
80
|
|
|
74
81
|
// Lifecycle hooks with documentation
|
|
75
82
|
const LIFECYCLE_HOOKS = [
|
|
@@ -87,7 +94,7 @@ const HTML_ELEMENTS = [
|
|
|
87
94
|
{ tag: 'span', doc: 'Inline container element' },
|
|
88
95
|
{ tag: 'p', doc: 'Paragraph element' },
|
|
89
96
|
{ tag: 'a', doc: 'Anchor/link element', attrs: 'href="$1"' },
|
|
90
|
-
{ tag: 'button', doc: 'Button element', attrs: '
|
|
97
|
+
{ tag: 'button', doc: 'Button element', attrs: 'on:click={$1}' },
|
|
91
98
|
{ tag: 'input', doc: 'Input element', attrs: 'type="$1"', selfClosing: true },
|
|
92
99
|
{ tag: 'img', doc: 'Image element', attrs: 'src="$1" alt="$2"', selfClosing: true },
|
|
93
100
|
{ tag: 'h1', doc: 'Heading level 1' },
|
|
@@ -133,7 +140,7 @@ const HTML_ATTRIBUTES = [
|
|
|
133
140
|
'placeholder', 'disabled', 'checked', 'readonly', 'required', 'hidden'
|
|
134
141
|
];
|
|
135
142
|
|
|
136
|
-
// DOM events for
|
|
143
|
+
// DOM events for on:event handlers
|
|
137
144
|
const DOM_EVENTS = [
|
|
138
145
|
'click', 'change', 'input', 'submit', 'keydown', 'keyup', 'keypress',
|
|
139
146
|
'focus', 'blur', 'mouseover', 'mouseout', 'mouseenter', 'mouseleave'
|
|
@@ -190,7 +197,7 @@ function extractLoopVariables(text: string): string[] {
|
|
|
190
197
|
const vars: string[] = [];
|
|
191
198
|
const loopPattern = /zen:for\s*=\s*["']([^"']+)["']/g;
|
|
192
199
|
let match;
|
|
193
|
-
|
|
200
|
+
|
|
194
201
|
while ((match = loopPattern.exec(text)) !== null) {
|
|
195
202
|
const parsed = parseForExpression(match[1]);
|
|
196
203
|
if (parsed) {
|
|
@@ -198,7 +205,7 @@ function extractLoopVariables(text: string): string[] {
|
|
|
198
205
|
if (parsed.indexVar) vars.push(parsed.indexVar);
|
|
199
206
|
}
|
|
200
207
|
}
|
|
201
|
-
|
|
208
|
+
|
|
202
209
|
return vars;
|
|
203
210
|
}
|
|
204
211
|
|
|
@@ -257,7 +264,7 @@ function getPositionContext(text: string, offset: number): {
|
|
|
257
264
|
// Get current word being typed
|
|
258
265
|
const wordMatch = before.match(/[a-zA-Z_$:@][a-zA-Z0-9_$:-]*$/);
|
|
259
266
|
const currentWord = wordMatch ? wordMatch[0] : '';
|
|
260
|
-
|
|
267
|
+
|
|
261
268
|
// Check for @ or : prefix for event/binding completion
|
|
262
269
|
const afterAt = before.endsWith('@') || currentWord.startsWith('@');
|
|
263
270
|
const afterColon = before.endsWith(':') || (currentWord.startsWith(':') && !currentWord.startsWith(':'));
|
|
@@ -268,7 +275,7 @@ function getPositionContext(text: string, offset: number): {
|
|
|
268
275
|
// Get project graph for a document
|
|
269
276
|
function getProjectGraph(docUri: string): ProjectGraph | null {
|
|
270
277
|
const filePath = docUri.replace('file://', '');
|
|
271
|
-
const projectRoot = detectProjectRoot(path.dirname(filePath));
|
|
278
|
+
const projectRoot = detectProjectRoot(path.dirname(filePath), workspaceFolders);
|
|
272
279
|
|
|
273
280
|
if (!projectRoot) {
|
|
274
281
|
return null;
|
|
@@ -284,13 +291,19 @@ function getProjectGraph(docUri: string): ProjectGraph | null {
|
|
|
284
291
|
// Invalidate project graph on file changes
|
|
285
292
|
function invalidateProjectGraph(uri: string) {
|
|
286
293
|
const filePath = uri.replace('file://', '');
|
|
287
|
-
const projectRoot = detectProjectRoot(path.dirname(filePath));
|
|
294
|
+
const projectRoot = detectProjectRoot(path.dirname(filePath), workspaceFolders);
|
|
288
295
|
if (projectRoot) {
|
|
289
296
|
projectGraphs.delete(projectRoot);
|
|
290
297
|
}
|
|
291
298
|
}
|
|
292
299
|
|
|
293
300
|
connection.onInitialize((params: InitializeParams): InitializeResult => {
|
|
301
|
+
workspaceFolders = (params.workspaceFolders || [])
|
|
302
|
+
.map((folder) => folder.uri.replace('file://', ''));
|
|
303
|
+
if (workspaceFolders.length === 0 && params.rootUri) {
|
|
304
|
+
workspaceFolders = [params.rootUri.replace('file://', '')];
|
|
305
|
+
}
|
|
306
|
+
|
|
294
307
|
return {
|
|
295
308
|
capabilities: {
|
|
296
309
|
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
@@ -298,11 +311,16 @@ connection.onInitialize((params: InitializeParams): InitializeResult => {
|
|
|
298
311
|
resolveProvider: true,
|
|
299
312
|
triggerCharacters: ['{', '<', '"', "'", '=', '.', ' ', ':', '(', '@']
|
|
300
313
|
},
|
|
301
|
-
hoverProvider: true
|
|
314
|
+
hoverProvider: true,
|
|
315
|
+
codeActionProvider: true
|
|
302
316
|
}
|
|
303
317
|
};
|
|
304
318
|
});
|
|
305
319
|
|
|
320
|
+
connection.onInitialized(() => {
|
|
321
|
+
connection.client.register(DidChangeConfigurationNotification.type);
|
|
322
|
+
});
|
|
323
|
+
|
|
306
324
|
connection.onCompletion((params: TextDocumentPositionParams): CompletionItem[] => {
|
|
307
325
|
const document = documents.get(params.textDocument.uri);
|
|
308
326
|
if (!document) return [];
|
|
@@ -529,7 +547,7 @@ connection.onCompletion((params: TextDocumentPositionParams): CompletionItem[] =
|
|
|
529
547
|
if (ctx.inTag && ctx.tagName && !ctx.inAttributeValue) {
|
|
530
548
|
// Directives (zen:if, zen:for, etc.)
|
|
531
549
|
const elementType = ctx.tagName === 'slot' ? 'slot' : (/^[A-Z]/.test(ctx.tagName) ? 'component' : 'element');
|
|
532
|
-
|
|
550
|
+
|
|
533
551
|
for (const directiveName of getDirectiveNames()) {
|
|
534
552
|
if (canPlaceDirective(directiveName, elementType as 'element' | 'component' | 'slot')) {
|
|
535
553
|
if (!ctx.currentWord || directiveName.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
|
|
@@ -549,17 +567,17 @@ connection.onCompletion((params: TextDocumentPositionParams): CompletionItem[] =
|
|
|
549
567
|
}
|
|
550
568
|
}
|
|
551
569
|
|
|
552
|
-
//
|
|
553
|
-
if (ctx.
|
|
570
|
+
// on:event completions
|
|
571
|
+
if (!ctx.currentWord || ctx.currentWord.startsWith('on:') || ctx.currentWord === 'on') {
|
|
554
572
|
for (const event of DOM_EVENTS) {
|
|
555
573
|
completions.push({
|
|
556
|
-
label:
|
|
574
|
+
label: `on:${event}`,
|
|
557
575
|
kind: CompletionItemKind.Event,
|
|
558
576
|
detail: 'event binding',
|
|
559
577
|
documentation: `Bind to ${event} event`,
|
|
560
|
-
insertText:
|
|
578
|
+
insertText: `on:${event}={$1}`,
|
|
561
579
|
insertTextFormat: InsertTextFormat.Snippet,
|
|
562
|
-
sortText: `
|
|
580
|
+
sortText: `1_on:${event}`
|
|
563
581
|
});
|
|
564
582
|
}
|
|
565
583
|
}
|
|
@@ -613,22 +631,6 @@ connection.onCompletion((params: TextDocumentPositionParams): CompletionItem[] =
|
|
|
613
631
|
}
|
|
614
632
|
}
|
|
615
633
|
|
|
616
|
-
// Standard event handlers (onclick, onchange, etc.)
|
|
617
|
-
for (const event of DOM_EVENTS) {
|
|
618
|
-
const onEvent = `on${event}`;
|
|
619
|
-
if (!ctx.currentWord || onEvent.startsWith(ctx.currentWord.toLowerCase())) {
|
|
620
|
-
completions.push({
|
|
621
|
-
label: onEvent,
|
|
622
|
-
kind: CompletionItemKind.Event,
|
|
623
|
-
detail: 'event handler',
|
|
624
|
-
documentation: `Bind to ${event} event`,
|
|
625
|
-
insertText: `${onEvent}={$1}`,
|
|
626
|
-
insertTextFormat: InsertTextFormat.Snippet,
|
|
627
|
-
sortText: `2_${onEvent}`
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
634
|
// HTML attributes
|
|
633
635
|
for (const attr of HTML_ATTRIBUTES) {
|
|
634
636
|
if (!ctx.currentWord || attr.startsWith(ctx.currentWord.toLowerCase())) {
|
|
@@ -647,7 +649,7 @@ connection.onCompletion((params: TextDocumentPositionParams): CompletionItem[] =
|
|
|
647
649
|
// === INSIDE ATTRIBUTE VALUE ===
|
|
648
650
|
if (ctx.inAttributeValue) {
|
|
649
651
|
// Event handler: offer functions
|
|
650
|
-
const eventMatch = lineBefore.match(/
|
|
652
|
+
const eventMatch = lineBefore.match(/on:[a-zA-Z][a-zA-Z0-9_-]*=["'{][^"'{}]*$/);
|
|
651
653
|
if (eventMatch) {
|
|
652
654
|
for (const func of functions) {
|
|
653
655
|
completions.push({
|
|
@@ -667,6 +669,15 @@ connection.onCompletionResolve((item: CompletionItem): CompletionItem => {
|
|
|
667
669
|
return item;
|
|
668
670
|
});
|
|
669
671
|
|
|
672
|
+
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
|
|
673
|
+
const document = documents.get(params.textDocument.uri);
|
|
674
|
+
if (!document) {
|
|
675
|
+
return [];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return buildEventBindingCodeActions(document, params.context.diagnostics);
|
|
679
|
+
});
|
|
680
|
+
|
|
670
681
|
connection.onHover((params: TextDocumentPositionParams): Hover | null => {
|
|
671
682
|
const document = documents.get(params.textDocument.uri);
|
|
672
683
|
if (!document) return null;
|
|
@@ -693,7 +704,7 @@ connection.onHover((params: TextDocumentPositionParams): Hover | null => {
|
|
|
693
704
|
} else {
|
|
694
705
|
notes = '- Compile-time directive\n- No runtime assumptions\n- Processed at build time';
|
|
695
706
|
}
|
|
696
|
-
|
|
707
|
+
|
|
697
708
|
return {
|
|
698
709
|
contents: {
|
|
699
710
|
kind: MarkupKind.Markdown,
|
|
@@ -822,10 +833,21 @@ documents.onDidOpen(event => {
|
|
|
822
833
|
|
|
823
834
|
async function validateDocument(document: TextDocument) {
|
|
824
835
|
const graph = getProjectGraph(document.uri);
|
|
825
|
-
const
|
|
836
|
+
const filePath = document.uri.replace('file://', '');
|
|
837
|
+
const projectRoot = detectProjectRoot(path.dirname(filePath), workspaceFolders);
|
|
838
|
+
const diagnostics = await collectDiagnostics(document, graph, globalSettings, projectRoot);
|
|
826
839
|
connection.sendDiagnostics({ uri: document.uri, diagnostics });
|
|
827
840
|
}
|
|
828
841
|
|
|
842
|
+
connection.onDidChangeConfiguration((change) => {
|
|
843
|
+
const config = (change.settings?.zenith ?? change.settings) as unknown;
|
|
844
|
+
globalSettings = normalizeSettings(config);
|
|
845
|
+
|
|
846
|
+
for (const doc of documents.all()) {
|
|
847
|
+
validateDocument(doc);
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
|
|
829
851
|
// Watch for file changes
|
|
830
852
|
connection.onDidChangeWatchedFiles(params => {
|
|
831
853
|
for (const change of params.changes) {
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ComponentScriptsMode = 'forbid' | 'allow';
|
|
2
|
+
|
|
3
|
+
export interface ZenithServerSettings {
|
|
4
|
+
componentScripts: ComponentScriptsMode;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_SETTINGS: ZenithServerSettings = Object.freeze({
|
|
8
|
+
componentScripts: 'forbid'
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function normalizeSettings(input: unknown): ZenithServerSettings {
|
|
12
|
+
const maybe = (input || {}) as { componentScripts?: unknown };
|
|
13
|
+
const mode = maybe.componentScripts === 'allow' ? 'allow' : 'forbid';
|
|
14
|
+
return { componentScripts: mode };
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
stripImportSuffix,
|
|
7
|
+
isCssContractImportSpecifier,
|
|
8
|
+
isLocalCssSpecifier,
|
|
9
|
+
resolveCssImportPath
|
|
10
|
+
} from '../src/contracts';
|
|
11
|
+
|
|
12
|
+
test('stripImportSuffix removes query/hash suffixes deterministically', () => {
|
|
13
|
+
assert.equal(stripImportSuffix('./styles/output.css?v=1#hash'), './styles/output.css');
|
|
14
|
+
assert.equal(stripImportSuffix('./styles/output.css#hash?v=1'), './styles/output.css');
|
|
15
|
+
assert.equal(stripImportSuffix('./styles/output.css'), './styles/output.css');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('css contract identifies local and bare css import shapes', () => {
|
|
19
|
+
assert.equal(isCssContractImportSpecifier('./styles/output.css?v=1'), true);
|
|
20
|
+
assert.equal(isCssContractImportSpecifier('tailwindcss'), true);
|
|
21
|
+
assert.equal(isCssContractImportSpecifier('@scope/css'), true);
|
|
22
|
+
assert.equal(isLocalCssSpecifier('./styles/output.css'), true);
|
|
23
|
+
assert.equal(isLocalCssSpecifier('../styles/output.css#hash'), true);
|
|
24
|
+
assert.equal(isLocalCssSpecifier('/src/styles/output.css'), true);
|
|
25
|
+
assert.equal(isLocalCssSpecifier('tailwindcss'), false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('resolveCssImportPath flags project-root traversal escape', () => {
|
|
29
|
+
const projectRoot = path.join('/tmp', 'zenith-site');
|
|
30
|
+
const importer = path.join(projectRoot, 'src', 'pages', 'index.zen');
|
|
31
|
+
|
|
32
|
+
const ok = resolveCssImportPath(importer, '../styles/output.css?v=1#hash', projectRoot);
|
|
33
|
+
assert.equal(ok.escapesProjectRoot, false);
|
|
34
|
+
|
|
35
|
+
const escaped = resolveCssImportPath(importer, '../../../../outside.css', projectRoot);
|
|
36
|
+
assert.equal(escaped.escapesProjectRoot, true);
|
|
37
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { collectContractDiagnostics, CONTRACT_MESSAGES } from '../src/diagnostics';
|
|
5
|
+
import { buildEventBindingCodeActions } from '../src/code-actions';
|
|
6
|
+
import { DEFAULT_SETTINGS } from '../src/settings';
|
|
7
|
+
|
|
8
|
+
const PROJECT_ROOT = '/tmp/zenith-site';
|
|
9
|
+
|
|
10
|
+
function doc(uri: string, content: string) {
|
|
11
|
+
return {
|
|
12
|
+
uri,
|
|
13
|
+
getText() {
|
|
14
|
+
return content;
|
|
15
|
+
},
|
|
16
|
+
positionAt(offset: number) {
|
|
17
|
+
const bounded = Math.max(0, Math.min(offset, content.length));
|
|
18
|
+
const before = content.slice(0, bounded);
|
|
19
|
+
const lines = before.split('\n');
|
|
20
|
+
return {
|
|
21
|
+
line: lines.length - 1,
|
|
22
|
+
character: lines[lines.length - 1]?.length || 0
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('component script contract is enforced for components when mode=forbid', () => {
|
|
29
|
+
const document = doc(
|
|
30
|
+
'file:///tmp/zenith-site/src/components/Hero.zen',
|
|
31
|
+
'<section><script>const x = 1;</script><h1>Hero</h1></section>'
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const diagnostics = collectContractDiagnostics(document, null, DEFAULT_SETTINGS, PROJECT_ROOT);
|
|
35
|
+
const messageSet = diagnostics.map((item) => item.message);
|
|
36
|
+
assert.ok(messageSet.includes(CONTRACT_MESSAGES.componentScript));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('component script contract allows scripts when mode=allow', () => {
|
|
40
|
+
const document = doc(
|
|
41
|
+
'file:///tmp/zenith-site/src/components/Hero.zen',
|
|
42
|
+
'<section><script>const x = 1;</script><h1>Hero</h1></section>'
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const diagnostics = collectContractDiagnostics(document, null, { componentScripts: 'allow' }, PROJECT_ROOT);
|
|
46
|
+
const messageSet = diagnostics.map((item) => item.message);
|
|
47
|
+
assert.ok(!messageSet.includes(CONTRACT_MESSAGES.componentScript));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('route scripts are allowed by component script contract', () => {
|
|
51
|
+
const document = doc(
|
|
52
|
+
'file:///tmp/zenith-site/src/pages/index.zen',
|
|
53
|
+
'<RootLayout><script>const x = 1;</script><h1>Home</h1></RootLayout>'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const diagnostics = collectContractDiagnostics(document, null, DEFAULT_SETTINGS, PROJECT_ROOT);
|
|
57
|
+
const messageSet = diagnostics.map((item) => item.message);
|
|
58
|
+
assert.ok(!messageSet.includes(CONTRACT_MESSAGES.componentScript));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('event binding diagnostics flag onclick and @click and provide quick fixes', () => {
|
|
62
|
+
const document = doc(
|
|
63
|
+
'file:///tmp/zenith-site/src/pages/index.zen',
|
|
64
|
+
'<button onclick="submitForm">Save</button><button @click={submitForm}>Save</button>'
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const diagnostics = collectContractDiagnostics(document, null, DEFAULT_SETTINGS, PROJECT_ROOT)
|
|
68
|
+
.filter((item) => String(item.code || '') === 'zenith.event.binding.syntax');
|
|
69
|
+
|
|
70
|
+
assert.equal(diagnostics.length, 2);
|
|
71
|
+
assert.equal(diagnostics[0]?.data?.replacement, 'on:click={submitForm}');
|
|
72
|
+
assert.equal(diagnostics[1]?.data?.replacement, 'on:click={submitForm}');
|
|
73
|
+
|
|
74
|
+
const actions = buildEventBindingCodeActions(document, diagnostics);
|
|
75
|
+
assert.equal(actions.length, 2);
|
|
76
|
+
assert.equal(actions[0]?.title, 'Convert to on:click={submitForm}');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('css import contract flags bare imports and path escapes', () => {
|
|
80
|
+
const document = doc(
|
|
81
|
+
'file:///tmp/zenith-site/src/pages/index.zen',
|
|
82
|
+
'<RootLayout><script>import \"tailwindcss\"; import \"../../../../outside.css\";</script></RootLayout>'
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const diagnostics = collectContractDiagnostics(document, null, DEFAULT_SETTINGS, PROJECT_ROOT);
|
|
86
|
+
const messages = diagnostics.map((item) => item.message);
|
|
87
|
+
assert.ok(messages.includes(CONTRACT_MESSAGES.cssBareImport));
|
|
88
|
+
assert.ok(messages.includes(CONTRACT_MESSAGES.cssEscape));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('css import contract allows local precompiled css with suffixes', () => {
|
|
92
|
+
const document = doc(
|
|
93
|
+
'file:///tmp/zenith-site/src/pages/index.zen',
|
|
94
|
+
'<RootLayout><script>import \"../styles/output.css?v=1#hash\";</script></RootLayout>'
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const diagnostics = collectContractDiagnostics(document, null, DEFAULT_SETTINGS, PROJECT_ROOT);
|
|
98
|
+
const messages = diagnostics.map((item) => item.message);
|
|
99
|
+
assert.ok(!messages.includes(CONTRACT_MESSAGES.cssBareImport));
|
|
100
|
+
assert.ok(!messages.includes(CONTRACT_MESSAGES.cssEscape));
|
|
101
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { detectProjectRoot } from '../src/project';
|
|
8
|
+
|
|
9
|
+
function createTempDir(prefix: string): string {
|
|
10
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('detectProjectRoot prefers nearest zenith.config.*', () => {
|
|
14
|
+
const root = createTempDir('zenith-lsp-root-');
|
|
15
|
+
const nested = path.join(root, 'apps', 'site', 'src', 'pages');
|
|
16
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(root, 'zenith.config.ts'), 'export default {}\n');
|
|
18
|
+
|
|
19
|
+
const detected = detectProjectRoot(path.join(nested, 'index.zen'));
|
|
20
|
+
assert.equal(detected, root);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('detectProjectRoot prefers nearest package.json with @zenithbuild/cli', () => {
|
|
24
|
+
const root = createTempDir('zenith-lsp-pkg-');
|
|
25
|
+
const nested = path.join(root, 'src', 'components');
|
|
26
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
path.join(root, 'package.json'),
|
|
29
|
+
JSON.stringify({ dependencies: { '@zenithbuild/cli': '^1.0.0' } }, null, 2)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const detected = detectProjectRoot(path.join(nested, 'Hero.zen'));
|
|
33
|
+
assert.equal(detected, root);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('detectProjectRoot falls back to matching workspace folder structure', () => {
|
|
37
|
+
const workspace = createTempDir('zenith-lsp-workspace-');
|
|
38
|
+
const siteRoot = path.join(workspace, 'site-a');
|
|
39
|
+
const nested = path.join(siteRoot, 'src', 'pages', 'blog');
|
|
40
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
41
|
+
|
|
42
|
+
const detected = detectProjectRoot(path.join(nested, 'first-post.zen'), [workspace, siteRoot]);
|
|
43
|
+
assert.equal(detected, siteRoot);
|
|
44
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./.test-dist",
|
|
5
|
+
"rootDir": ".",
|
|
6
|
+
"declaration": false,
|
|
7
|
+
"sourceMap": false
|
|
8
|
+
},
|
|
9
|
+
"include": [
|
|
10
|
+
"src/contracts.ts",
|
|
11
|
+
"src/project.ts",
|
|
12
|
+
"src/settings.ts",
|
|
13
|
+
"src/diagnostics.ts",
|
|
14
|
+
"src/code-actions.ts",
|
|
15
|
+
"src/imports.ts",
|
|
16
|
+
"src/metadata/**/*.ts",
|
|
17
|
+
"src/**/*.d.ts",
|
|
18
|
+
"test/**/*.ts"
|
|
19
|
+
],
|
|
20
|
+
"exclude": [
|
|
21
|
+
"node_modules",
|
|
22
|
+
"dist",
|
|
23
|
+
".test-dist"
|
|
24
|
+
]
|
|
25
|
+
}
|