@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/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
- * Looks for zenith.config.ts, src/, or app/
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
- let current = startPath;
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
- while (current !== path.dirname(current)) {
33
- // Check for zenith.config.ts
34
- if (fs.existsSync(path.join(current, 'zenith.config.ts'))) {
35
- return current;
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
- // Check for src/ directory with Zenith files
38
- const srcDir = path.join(current, 'src');
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
- // Check for app/ directory
47
- const appDir = path.join(current, 'app');
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 null;
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: 'onclick={$1}' },
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 @event and onclick handlers
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
- // @event completions
553
- if (ctx.afterAt || ctx.currentWord.startsWith('@')) {
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: `@${event}`,
574
+ label: `on:${event}`,
557
575
  kind: CompletionItemKind.Event,
558
576
  detail: 'event binding',
559
577
  documentation: `Bind to ${event} event`,
560
- insertText: `@${event}={$1}`,
578
+ insertText: `on:${event}={$1}`,
561
579
  insertTextFormat: InsertTextFormat.Snippet,
562
- sortText: `1_@${event}`
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(/(?:on\w+|@\w+)=["'{][^"'{}]*$/);
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 diagnostics = collectDiagnostics(document, graph);
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) {
@@ -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,3 @@
1
+ declare module '@zenithbuild/compiler' {
2
+ export function compile(source: string, filePath: string): Promise<unknown>;
3
+ }
@@ -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
+ }