@zenithbuild/language-server 0.5.0-beta.2.20 → 0.6.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/CHANGELOG.md +16 -0
- package/RELEASE_NOTES_v0.6.0.md +39 -0
- package/package.json +2 -2
- package/src/code-actions.ts +159 -0
- package/src/diagnostics.ts +27 -1
- package/src/server.ts +83 -9
- package/src/settings.ts +6 -3
- package/test/diagnostics.spec.ts +21 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.6.0] - 2026-02-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `zenith.strictDomLints` setting: when `true`, ZEN-DOM-* diagnostics are errors; when `false`, hints
|
|
13
|
+
- Diagnostics from compiler JSON warnings (schemaVersion=1 contract)
|
|
14
|
+
- Debounce and cancellation for compile requests
|
|
15
|
+
- On-save validation
|
|
16
|
+
- Code actions for ZEN-DOM-* (querySelector → ref, addEventListener → zenOn)
|
|
17
|
+
- Code actions for window/document → zenWindow/zenDocument
|
|
18
|
+
- Completions for canonical primitives (ref, signal, state, zenOn, zenMount, etc.)
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Requires @zenithbuild/compiler ^0.6.0 (schemaVersion + warnings contract)
|
|
23
|
+
|
|
8
24
|
## [0.2.8] - 2026-01-26
|
|
9
25
|
|
|
10
26
|
### 📝 Other Changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @zenithbuild/language-server v0.6.0
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
- ZEN-DOM-* diagnostics from compiler JSON; severity flips with `strictDomLints`
|
|
6
|
+
- Debounced diagnostics (150ms) + on-save validation; last edit wins
|
|
7
|
+
- Code actions for ZEN-DOM-QUERY, ZEN-DOM-LISTENER, ZEN-DOM-WRAPPER + window/document convenience
|
|
8
|
+
|
|
9
|
+
## Breaking Changes
|
|
10
|
+
|
|
11
|
+
None.
|
|
12
|
+
|
|
13
|
+
## Key Changes
|
|
14
|
+
|
|
15
|
+
- **Diagnostics:** Surface compiler `warnings` as LSP diagnostics; `strictDomLints` setting (warning → error)
|
|
16
|
+
- **Debounce:** 150ms idle before validation; immediate validation on save
|
|
17
|
+
- **Cancellation:** Only latest validation sends diagnostics
|
|
18
|
+
- **Code actions:** Suppress/ref for ZEN-DOM-QUERY; zenOn template for ZEN-DOM-LISTENER; zenWindow/zenDocument for ZEN-DOM-WRAPPER
|
|
19
|
+
- **Convenience:** "Replace with zenWindow()" / "Replace with zenDocument()" on identifier selection (no diagnostic required)
|
|
20
|
+
- **Completions:** zenMount, zenWindow, zenDocument, zenOn, zenResize, collectRefs, signal, ref; soft suggestions for window/document
|
|
21
|
+
|
|
22
|
+
## Diagnostics / UX Highlights
|
|
23
|
+
|
|
24
|
+
| Code | Default | strictDomLints: true |
|
|
25
|
+
|------|---------|---------------------|
|
|
26
|
+
| ZEN-DOM-QUERY | Warning | Error |
|
|
27
|
+
| ZEN-DOM-LISTENER | Warning | Error |
|
|
28
|
+
| ZEN-DOM-WRAPPER | Warning | Error |
|
|
29
|
+
|
|
30
|
+
## Upgrade Notes
|
|
31
|
+
|
|
32
|
+
Requires `@zenithbuild/compiler` with JSON `schemaVersion` and `warnings` (v0.6.0+).
|
|
33
|
+
|
|
34
|
+
## Verification Checklist
|
|
35
|
+
|
|
36
|
+
- [ ] Type `document.querySelector(` → ZEN-DOM-QUERY warning + quick fixes
|
|
37
|
+
- [ ] Toggle `zenith.strictDomLints` → severity flips to error
|
|
38
|
+
- [ ] Select `window` → "Replace with zenWindow()" code action
|
|
39
|
+
- [ ] Rapid typing does not spawn excessive compiler processes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenithbuild/language-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Language Server for Zenith Framework",
|
|
5
5
|
"main": "./dist/server.js",
|
|
6
6
|
"types": "./dist/server.d.ts",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"release:major": "bun run scripts/release.ts --bump=major"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@zenithbuild/compiler": "^
|
|
19
|
+
"@zenithbuild/compiler": "^0.6.5",
|
|
20
20
|
"vscode-languageserver": "^9.0.1",
|
|
21
21
|
"vscode-languageserver-textdocument": "^1.0.11"
|
|
22
22
|
},
|
package/src/code-actions.ts
CHANGED
|
@@ -5,6 +5,10 @@ import {
|
|
|
5
5
|
|
|
6
6
|
export const EVENT_BINDING_DIAGNOSTIC_CODE = 'zenith.event.binding.syntax';
|
|
7
7
|
|
|
8
|
+
export const ZEN_DOM_QUERY = 'ZEN-DOM-QUERY';
|
|
9
|
+
export const ZEN_DOM_LISTENER = 'ZEN-DOM-LISTENER';
|
|
10
|
+
export const ZEN_DOM_WRAPPER = 'ZEN-DOM-WRAPPER';
|
|
11
|
+
|
|
8
12
|
export interface EventBindingCodeActionData {
|
|
9
13
|
replacement: string;
|
|
10
14
|
title: string;
|
|
@@ -22,6 +26,9 @@ export interface ZenithCodeAction {
|
|
|
22
26
|
|
|
23
27
|
interface ZenithTextDocumentLike {
|
|
24
28
|
uri: string;
|
|
29
|
+
getText(): string;
|
|
30
|
+
positionAt(offset: number): { line: number; character: number };
|
|
31
|
+
offsetAt(position: { line: number; character: number }): number;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export function buildEventBindingCodeActions(
|
|
@@ -58,3 +65,155 @@ export function buildEventBindingCodeActions(
|
|
|
58
65
|
|
|
59
66
|
return actions;
|
|
60
67
|
}
|
|
68
|
+
|
|
69
|
+
export function buildDomLintCodeActions(
|
|
70
|
+
document: ZenithTextDocumentLike,
|
|
71
|
+
diagnostics: ZenithDiagnostic[]
|
|
72
|
+
): ZenithCodeAction[] {
|
|
73
|
+
const actions: ZenithCodeAction[] = [];
|
|
74
|
+
const text = document.getText();
|
|
75
|
+
|
|
76
|
+
for (const diagnostic of diagnostics) {
|
|
77
|
+
const code = diagnostic.code;
|
|
78
|
+
if (code !== ZEN_DOM_QUERY && code !== ZEN_DOM_LISTENER && code !== ZEN_DOM_WRAPPER) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const startOffset = document.offsetAt(diagnostic.range.start);
|
|
83
|
+
const endOffset = document.offsetAt(diagnostic.range.end);
|
|
84
|
+
const lineStart = text.lastIndexOf('\n', startOffset) + 1;
|
|
85
|
+
const lineEnd = text.indexOf('\n', endOffset);
|
|
86
|
+
const lineEndOffset = lineEnd === -1 ? text.length : lineEnd;
|
|
87
|
+
const lineContent = text.substring(lineStart, lineEndOffset);
|
|
88
|
+
|
|
89
|
+
if (code === ZEN_DOM_QUERY) {
|
|
90
|
+
const insertPos = { line: diagnostic.range.start.line, character: 0 };
|
|
91
|
+
actions.push({
|
|
92
|
+
title: 'Suppress with // zen-allow:dom-query <reason>',
|
|
93
|
+
kind: 'quickfix',
|
|
94
|
+
diagnostics: [diagnostic],
|
|
95
|
+
edit: {
|
|
96
|
+
changes: {
|
|
97
|
+
[document.uri]: [{
|
|
98
|
+
range: { start: insertPos, end: insertPos },
|
|
99
|
+
newText: '// zen-allow:dom-query <reason>\n'
|
|
100
|
+
}]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
actions.push({
|
|
105
|
+
title: 'Convert to ref() (partial / TODO)',
|
|
106
|
+
kind: 'quickfix',
|
|
107
|
+
diagnostics: [diagnostic],
|
|
108
|
+
edit: {
|
|
109
|
+
changes: {
|
|
110
|
+
[document.uri]: [{
|
|
111
|
+
range: { start: insertPos, end: insertPos },
|
|
112
|
+
newText: '// TODO: use ref<T>() + zenMount instead\nconst elRef = ref<HTMLElement>();\n'
|
|
113
|
+
}]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
} else if (code === ZEN_DOM_LISTENER) {
|
|
118
|
+
const insertPos = { line: diagnostic.range.start.line, character: 0 };
|
|
119
|
+
const lineRange = {
|
|
120
|
+
start: document.positionAt(lineStart),
|
|
121
|
+
end: document.positionAt(lineEndOffset)
|
|
122
|
+
};
|
|
123
|
+
const commentedLine = lineContent.replace(/^(\s*)/, '$1// ');
|
|
124
|
+
actions.push({
|
|
125
|
+
title: 'Replace with zenOn template',
|
|
126
|
+
kind: 'quickfix',
|
|
127
|
+
diagnostics: [diagnostic],
|
|
128
|
+
edit: {
|
|
129
|
+
changes: {
|
|
130
|
+
[document.uri]: [
|
|
131
|
+
{
|
|
132
|
+
range: { start: insertPos, end: insertPos },
|
|
133
|
+
newText: '// zenOn(target, eventName, handler) - register disposer via ctx.cleanup\n// const off = zenOn(doc, \'keydown\', handler); ctx.cleanup(off);\n'
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
range: lineRange,
|
|
137
|
+
newText: commentedLine
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
} else if (code === ZEN_DOM_WRAPPER) {
|
|
144
|
+
let newText = lineContent;
|
|
145
|
+
if (lineContent.includes('window') && !lineContent.includes('zenWindow')) {
|
|
146
|
+
newText = newText.replace(/\bwindow\b/g, 'zenWindow()');
|
|
147
|
+
}
|
|
148
|
+
if (lineContent.includes('document') && !lineContent.includes('zenDocument')) {
|
|
149
|
+
newText = newText.replace(/\bdocument\b/g, 'zenDocument()');
|
|
150
|
+
}
|
|
151
|
+
if (lineContent.includes('globalThis.window')) {
|
|
152
|
+
newText = newText.replace(/globalThis\.window/g, 'zenWindow()');
|
|
153
|
+
}
|
|
154
|
+
if (lineContent.includes('globalThis.document')) {
|
|
155
|
+
newText = newText.replace(/globalThis\.document/g, 'zenDocument()');
|
|
156
|
+
}
|
|
157
|
+
if (newText !== lineContent) {
|
|
158
|
+
actions.push({
|
|
159
|
+
title: 'Replace with zenWindow() / zenDocument()',
|
|
160
|
+
kind: 'quickfix',
|
|
161
|
+
diagnostics: [diagnostic],
|
|
162
|
+
edit: {
|
|
163
|
+
changes: {
|
|
164
|
+
[document.uri]: [{
|
|
165
|
+
range: {
|
|
166
|
+
start: document.positionAt(lineStart),
|
|
167
|
+
end: document.positionAt(lineEndOffset)
|
|
168
|
+
},
|
|
169
|
+
newText
|
|
170
|
+
}]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return actions;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Convenience code actions: Replace window/document with zenWindow()/zenDocument()
|
|
183
|
+
* even when there is no ZEN-DOM-WRAPPER diagnostic.
|
|
184
|
+
*/
|
|
185
|
+
export function buildWindowDocumentCodeActions(
|
|
186
|
+
document: ZenithTextDocumentLike,
|
|
187
|
+
range: ZenithRange
|
|
188
|
+
): ZenithCodeAction[] {
|
|
189
|
+
const text = document.getText();
|
|
190
|
+
const startOffset = document.offsetAt(range.start);
|
|
191
|
+
const endOffset = document.offsetAt(range.end);
|
|
192
|
+
const selected = text.substring(startOffset, endOffset);
|
|
193
|
+
|
|
194
|
+
if (selected === 'window') {
|
|
195
|
+
return [{
|
|
196
|
+
title: 'Replace with zenWindow()',
|
|
197
|
+
kind: 'refactor',
|
|
198
|
+
diagnostics: [],
|
|
199
|
+
edit: {
|
|
200
|
+
changes: {
|
|
201
|
+
[document.uri]: [{ range, newText: 'zenWindow()' }]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}];
|
|
205
|
+
}
|
|
206
|
+
if (selected === 'document') {
|
|
207
|
+
return [{
|
|
208
|
+
title: 'Replace with zenDocument()',
|
|
209
|
+
kind: 'refactor',
|
|
210
|
+
diagnostics: [],
|
|
211
|
+
edit: {
|
|
212
|
+
changes: {
|
|
213
|
+
[document.uri]: [{ range, newText: 'zenDocument()' }]
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}];
|
|
217
|
+
}
|
|
218
|
+
return [];
|
|
219
|
+
}
|
package/src/diagnostics.ts
CHANGED
|
@@ -155,7 +155,33 @@ export async function collectDiagnostics(
|
|
|
155
155
|
try {
|
|
156
156
|
process.env.ZENITH_CACHE = '1';
|
|
157
157
|
const { compile } = await import('@zenithbuild/compiler');
|
|
158
|
-
await compile(text, filePath);
|
|
158
|
+
const result = await compile(text, filePath);
|
|
159
|
+
|
|
160
|
+
// 2) Surface ZEN-DOM-* warnings from compiler JSON as LSP diagnostics.
|
|
161
|
+
interface CompilerWarning {
|
|
162
|
+
code?: string;
|
|
163
|
+
message?: string;
|
|
164
|
+
range?: { start?: { line?: number; column?: number }; end?: { line?: number; column?: number } };
|
|
165
|
+
}
|
|
166
|
+
const warnings: CompilerWarning[] = (result as { warnings?: CompilerWarning[] }).warnings ?? [];
|
|
167
|
+
const domLintSeverity = settings.strictDomLints ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning;
|
|
168
|
+
for (const w of warnings) {
|
|
169
|
+
const range = w.range;
|
|
170
|
+
const startLine = (range?.start?.line ?? 1) - 1;
|
|
171
|
+
const startChar = (range?.start?.column ?? 1) - 1;
|
|
172
|
+
const endLine = (range?.end?.line ?? range?.start?.line ?? 1) - 1;
|
|
173
|
+
const endChar = (range?.end?.column ?? range?.start?.column ?? 1);
|
|
174
|
+
diagnostics.push({
|
|
175
|
+
severity: domLintSeverity,
|
|
176
|
+
range: {
|
|
177
|
+
start: { line: startLine, character: startChar },
|
|
178
|
+
end: { line: endLine, character: endChar }
|
|
179
|
+
},
|
|
180
|
+
message: w.message ?? 'DOM lint',
|
|
181
|
+
source: 'zenith-compiler',
|
|
182
|
+
code: w.code
|
|
183
|
+
});
|
|
184
|
+
}
|
|
159
185
|
} catch (error: any) {
|
|
160
186
|
const message = String(error?.message || 'Unknown compiler error');
|
|
161
187
|
const isContractViolation = message.includes(COMPONENT_SCRIPT_CONTRACT_MESSAGE);
|
package/src/server.ts
CHANGED
|
@@ -66,7 +66,7 @@ import {
|
|
|
66
66
|
} from './router';
|
|
67
67
|
|
|
68
68
|
import { collectDiagnostics } from './diagnostics';
|
|
69
|
-
import { buildEventBindingCodeActions } from './code-actions';
|
|
69
|
+
import { buildEventBindingCodeActions, buildDomLintCodeActions, buildWindowDocumentCodeActions } from './code-actions';
|
|
70
70
|
import { DEFAULT_SETTINGS, normalizeSettings, ZenithServerSettings } from './settings';
|
|
71
71
|
|
|
72
72
|
// Create connection and document manager
|
|
@@ -78,9 +78,10 @@ let projectGraphs: Map<string, ProjectGraph> = new Map();
|
|
|
78
78
|
let workspaceFolders: string[] = [];
|
|
79
79
|
let globalSettings: ZenithServerSettings = DEFAULT_SETTINGS;
|
|
80
80
|
|
|
81
|
-
// Lifecycle hooks with documentation
|
|
81
|
+
// Lifecycle hooks and platform primitives with documentation
|
|
82
82
|
const LIFECYCLE_HOOKS = [
|
|
83
83
|
{ name: 'state', doc: 'Declare a reactive state variable', snippet: 'state ${1:name} = ${2:value}', kind: CompletionItemKind.Keyword },
|
|
84
|
+
{ name: 'zenMount', doc: 'Mount callback with ctx.cleanup for disposers', snippet: 'zenMount((ctx) => {\n\t$0\n})', kind: CompletionItemKind.Function },
|
|
84
85
|
{ name: 'zenOnMount', doc: 'Called when component is mounted to the DOM', snippet: 'zenOnMount(() => {\n\t$0\n})', kind: CompletionItemKind.Function },
|
|
85
86
|
{ name: 'zenOnDestroy', doc: 'Called when component is removed from the DOM', snippet: 'zenOnDestroy(() => {\n\t$0\n})', kind: CompletionItemKind.Function },
|
|
86
87
|
{ name: 'zenOnUpdate', doc: 'Called after any state update causes a re-render', snippet: 'zenOnUpdate(() => {\n\t$0\n})', kind: CompletionItemKind.Function },
|
|
@@ -88,6 +89,16 @@ const LIFECYCLE_HOOKS = [
|
|
|
88
89
|
{ name: 'useFetch', doc: 'Fetch data with caching and SSG support', snippet: 'useFetch("${1:url}")', kind: CompletionItemKind.Function }
|
|
89
90
|
];
|
|
90
91
|
|
|
92
|
+
const PLATFORM_PRIMITIVES = [
|
|
93
|
+
{ name: 'zenWindow', doc: 'SSR-safe window access (returns null when not in browser)', snippet: 'zenWindow()', kind: CompletionItemKind.Function },
|
|
94
|
+
{ name: 'zenDocument', doc: 'SSR-safe document access (returns null when not in browser)', snippet: 'zenDocument()', kind: CompletionItemKind.Function },
|
|
95
|
+
{ name: 'zenOn', doc: 'Event subscription with disposer; register via ctx.cleanup', snippet: 'zenOn(${1:target}, \'${2:event}\', ${3:handler})', kind: CompletionItemKind.Function },
|
|
96
|
+
{ name: 'zenResize', doc: 'Window resize handler; returns disposer for ctx.cleanup', snippet: 'zenResize(({ w, h }) => {\n\t$0\n})', kind: CompletionItemKind.Function },
|
|
97
|
+
{ name: 'collectRefs', doc: 'Collect multiple refs into a deterministic node list', snippet: 'collectRefs(${1:refA}, ${2:refB})', kind: CompletionItemKind.Function },
|
|
98
|
+
{ name: 'signal', doc: 'Create a signal for explicit get/set', snippet: 'signal(${1:0})', kind: CompletionItemKind.Function },
|
|
99
|
+
{ name: 'ref', doc: 'Create a ref for DOM node or value', snippet: 'ref<${1:HTMLElement}>()', kind: CompletionItemKind.Function }
|
|
100
|
+
];
|
|
101
|
+
|
|
91
102
|
// Common HTML elements
|
|
92
103
|
const HTML_ELEMENTS = [
|
|
93
104
|
{ tag: 'div', doc: 'Generic container element' },
|
|
@@ -360,6 +371,44 @@ connection.onCompletion((params: TextDocumentPositionParams): CompletionItem[] =
|
|
|
360
371
|
}
|
|
361
372
|
}
|
|
362
373
|
|
|
374
|
+
// Platform primitives (zenWindow, zenDocument, zenOn, zenResize, collectRefs, signal, ref)
|
|
375
|
+
for (const prim of PLATFORM_PRIMITIVES) {
|
|
376
|
+
if (!ctx.currentWord || prim.name.toLowerCase().startsWith(ctx.currentWord.toLowerCase())) {
|
|
377
|
+
completions.push({
|
|
378
|
+
label: prim.name,
|
|
379
|
+
kind: prim.kind,
|
|
380
|
+
detail: 'Zenith Platform',
|
|
381
|
+
documentation: { kind: MarkupKind.Markdown, value: prim.doc },
|
|
382
|
+
insertText: prim.snippet,
|
|
383
|
+
insertTextFormat: InsertTextFormat.Snippet,
|
|
384
|
+
sortText: `0_${prim.name}`
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Soft suggestions: window -> zenWindow, document -> zenDocument (do not block normal JS)
|
|
390
|
+
const lc = ctx.currentWord.toLowerCase();
|
|
391
|
+
if (lc === 'window' || lc.startsWith('wind')) {
|
|
392
|
+
completions.push({
|
|
393
|
+
label: 'zenWindow',
|
|
394
|
+
kind: CompletionItemKind.Function,
|
|
395
|
+
detail: 'Zenith (SSR-safe)',
|
|
396
|
+
documentation: { kind: MarkupKind.Markdown, value: 'Use zenWindow() instead of window for SSR-safe access.' },
|
|
397
|
+
insertText: 'zenWindow()',
|
|
398
|
+
sortText: '0_zenWindow'
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (lc === 'document' || lc.startsWith('doc')) {
|
|
402
|
+
completions.push({
|
|
403
|
+
label: 'zenDocument',
|
|
404
|
+
kind: CompletionItemKind.Function,
|
|
405
|
+
detail: 'Zenith (SSR-safe)',
|
|
406
|
+
documentation: { kind: MarkupKind.Markdown, value: 'Use zenDocument() instead of document for SSR-safe access.' },
|
|
407
|
+
insertText: 'zenDocument()',
|
|
408
|
+
sortText: '0_zenDocument'
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
363
412
|
// Router hooks when router is imported
|
|
364
413
|
if (routerEnabled) {
|
|
365
414
|
for (const hook of Object.values(ROUTER_HOOKS)) {
|
|
@@ -674,8 +723,10 @@ connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
|
|
|
674
723
|
if (!document) {
|
|
675
724
|
return [];
|
|
676
725
|
}
|
|
677
|
-
|
|
678
|
-
|
|
726
|
+
const eventActions = buildEventBindingCodeActions(document, params.context.diagnostics);
|
|
727
|
+
const domLintActions = buildDomLintCodeActions(document, params.context.diagnostics);
|
|
728
|
+
const windowDocActions = buildWindowDocumentCodeActions(document, params.range);
|
|
729
|
+
return [...eventActions, ...domLintActions, ...windowDocActions];
|
|
679
730
|
});
|
|
680
731
|
|
|
681
732
|
connection.onHover((params: TextDocumentPositionParams): Hover | null => {
|
|
@@ -822,9 +873,26 @@ connection.onHover((params: TextDocumentPositionParams): Hover | null => {
|
|
|
822
873
|
return null;
|
|
823
874
|
});
|
|
824
875
|
|
|
825
|
-
//
|
|
876
|
+
// Debounce + cancellation for diagnostics (prevents editor lag from rapid typing)
|
|
877
|
+
const DEBOUNCE_MS = 150;
|
|
878
|
+
const validationTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
879
|
+
const validationIds = new Map<string, number>();
|
|
880
|
+
|
|
826
881
|
documents.onDidChangeContent(change => {
|
|
827
|
-
|
|
882
|
+
const uri = change.document.uri;
|
|
883
|
+
const existing = validationTimeouts.get(uri);
|
|
884
|
+
if (existing) clearTimeout(existing);
|
|
885
|
+
validationTimeouts.set(
|
|
886
|
+
uri,
|
|
887
|
+
setTimeout(() => {
|
|
888
|
+
validationTimeouts.delete(uri);
|
|
889
|
+
validateDocument(change.document);
|
|
890
|
+
}, DEBOUNCE_MS)
|
|
891
|
+
);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
documents.onDidSave(event => {
|
|
895
|
+
validateDocument(event.document);
|
|
828
896
|
});
|
|
829
897
|
|
|
830
898
|
documents.onDidOpen(event => {
|
|
@@ -832,11 +900,17 @@ documents.onDidOpen(event => {
|
|
|
832
900
|
});
|
|
833
901
|
|
|
834
902
|
async function validateDocument(document: TextDocument) {
|
|
835
|
-
const
|
|
836
|
-
const
|
|
903
|
+
const uri = document.uri;
|
|
904
|
+
const id = (validationIds.get(uri) ?? 0) + 1;
|
|
905
|
+
validationIds.set(uri, id);
|
|
906
|
+
|
|
907
|
+
const graph = getProjectGraph(uri);
|
|
908
|
+
const filePath = uri.replace('file://', '');
|
|
837
909
|
const projectRoot = detectProjectRoot(path.dirname(filePath), workspaceFolders);
|
|
838
910
|
const diagnostics = await collectDiagnostics(document, graph, globalSettings, projectRoot);
|
|
839
|
-
|
|
911
|
+
|
|
912
|
+
if (validationIds.get(uri) !== id) return;
|
|
913
|
+
connection.sendDiagnostics({ uri, diagnostics });
|
|
840
914
|
}
|
|
841
915
|
|
|
842
916
|
connection.onDidChangeConfiguration((change) => {
|
package/src/settings.ts
CHANGED
|
@@ -2,14 +2,17 @@ export type ComponentScriptsMode = 'forbid' | 'allow';
|
|
|
2
2
|
|
|
3
3
|
export interface ZenithServerSettings {
|
|
4
4
|
componentScripts: ComponentScriptsMode;
|
|
5
|
+
strictDomLints: boolean;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export const DEFAULT_SETTINGS: ZenithServerSettings = Object.freeze({
|
|
8
|
-
componentScripts: 'forbid'
|
|
9
|
+
componentScripts: 'forbid',
|
|
10
|
+
strictDomLints: false
|
|
9
11
|
});
|
|
10
12
|
|
|
11
13
|
export function normalizeSettings(input: unknown): ZenithServerSettings {
|
|
12
|
-
const maybe = (input || {}) as { componentScripts?: unknown };
|
|
14
|
+
const maybe = (input || {}) as { componentScripts?: unknown; strictDomLints?: unknown };
|
|
13
15
|
const mode = maybe.componentScripts === 'allow' ? 'allow' : 'forbid';
|
|
14
|
-
|
|
16
|
+
const strictDomLints = maybe.strictDomLints === true;
|
|
17
|
+
return { componentScripts: mode, strictDomLints };
|
|
15
18
|
}
|
package/test/diagnostics.spec.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
|
|
4
|
-
import { collectContractDiagnostics, CONTRACT_MESSAGES } from '../src/diagnostics';
|
|
4
|
+
import { collectDiagnostics, collectContractDiagnostics, CONTRACT_MESSAGES } from '../src/diagnostics';
|
|
5
5
|
import { buildEventBindingCodeActions } from '../src/code-actions';
|
|
6
|
-
import { DEFAULT_SETTINGS } from '../src/settings';
|
|
6
|
+
import { DEFAULT_SETTINGS, normalizeSettings } from '../src/settings';
|
|
7
7
|
|
|
8
8
|
const PROJECT_ROOT = '/tmp/zenith-site';
|
|
9
9
|
|
|
@@ -99,3 +99,22 @@ test('css import contract allows local precompiled css with suffixes', () => {
|
|
|
99
99
|
assert.ok(!messages.includes(CONTRACT_MESSAGES.cssBareImport));
|
|
100
100
|
assert.ok(!messages.includes(CONTRACT_MESSAGES.cssEscape));
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test('ZEN-DOM-QUERY diagnostic appears for querySelector and severity maps with strictDomLints', async () => {
|
|
104
|
+
const document = doc(
|
|
105
|
+
'file:///tmp/zenith-site/src/pages/index.zen',
|
|
106
|
+
'<script lang="ts">\nconst el = document.querySelector(".foo");\n</script>\n<div class="foo">hi</div>'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const settingsDefault = normalizeSettings({ strictDomLints: false });
|
|
110
|
+
const diagnosticsDefault = await collectDiagnostics(document, null, settingsDefault, PROJECT_ROOT);
|
|
111
|
+
const queryDefault = diagnosticsDefault.filter((d) => d.code === 'ZEN-DOM-QUERY');
|
|
112
|
+
assert.ok(queryDefault.length >= 1, `expected ZEN-DOM-QUERY diagnostic, got: ${JSON.stringify(diagnosticsDefault.map((d) => d.code))}`);
|
|
113
|
+
assert.equal(queryDefault[0]?.severity, 2, 'ZEN-DOM-QUERY should be Warning (2) when strictDomLints=false');
|
|
114
|
+
|
|
115
|
+
const settingsStrict = normalizeSettings({ strictDomLints: true });
|
|
116
|
+
const diagnosticsStrict = await collectDiagnostics(document, null, settingsStrict, PROJECT_ROOT);
|
|
117
|
+
const queryStrict = diagnosticsStrict.filter((d) => d.code === 'ZEN-DOM-QUERY');
|
|
118
|
+
assert.ok(queryStrict.length >= 1, `expected ZEN-DOM-QUERY diagnostic in strict mode, got: ${JSON.stringify(diagnosticsStrict.map((d) => d.code))}`);
|
|
119
|
+
assert.equal(queryStrict[0]?.severity, 1, 'ZEN-DOM-QUERY should be Error (1) when strictDomLints=true');
|
|
120
|
+
});
|