@zenithbuild/language-server 0.2.2

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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Directive Metadata
3
+ *
4
+ * Compile-time directive definitions for Zenith.
5
+ * These are compiler directives, not runtime attributes.
6
+ */
7
+
8
+ export interface DirectiveMetadata {
9
+ name: string;
10
+ category: 'control-flow' | 'iteration' | 'reactive-effect' | 'conditional-visibility';
11
+ description: string;
12
+ syntax: string;
13
+ placement: ('element' | 'component')[];
14
+ example: string;
15
+ createsScope?: boolean;
16
+ scopeVariables?: string[];
17
+ }
18
+
19
+ /**
20
+ * All Zenith compiler directives
21
+ *
22
+ * These are processed at compile-time and transformed into static DOM instructions.
23
+ * The LSP must describe these directives without assuming runtime behavior.
24
+ */
25
+ export const DIRECTIVES: Record<string, DirectiveMetadata> = {
26
+ 'zen:if': {
27
+ name: 'zen:if',
28
+ category: 'control-flow',
29
+ description: 'Compile-time conditional directive. Conditionally renders the element based on a boolean expression.',
30
+ syntax: 'zen:if="condition"',
31
+ placement: ['element', 'component'],
32
+ example: '<div zen:if="isVisible">Conditionally rendered</div>'
33
+ },
34
+ 'zen:for': {
35
+ name: 'zen:for',
36
+ category: 'iteration',
37
+ description: 'Compile-time iteration directive. Repeats the element for each item in a collection.',
38
+ syntax: 'zen:for="item in items" or zen:for="item, index in items"',
39
+ placement: ['element', 'component'],
40
+ example: '<li zen:for="item in items">{item.name}</li>',
41
+ createsScope: true,
42
+ scopeVariables: ['item', 'index']
43
+ },
44
+ 'zen:effect': {
45
+ name: 'zen:effect',
46
+ category: 'reactive-effect',
47
+ description: 'Compile-time reactive effect directive. Attaches a side effect to the element lifecycle.',
48
+ syntax: 'zen:effect="expression"',
49
+ placement: ['element', 'component'],
50
+ example: '<div zen:effect="console.log(\'rendered\')">Content</div>'
51
+ },
52
+ 'zen:show': {
53
+ name: 'zen:show',
54
+ category: 'conditional-visibility',
55
+ description: 'Compile-time visibility directive. Toggles element visibility without removing from DOM.',
56
+ syntax: 'zen:show="condition"',
57
+ placement: ['element', 'component'],
58
+ example: '<div zen:show="isOpen">Toggle visibility</div>'
59
+ }
60
+ };
61
+
62
+ /**
63
+ * Check if a string is a valid directive name
64
+ */
65
+ export function isDirective(name: string): name is keyof typeof DIRECTIVES {
66
+ return name in DIRECTIVES;
67
+ }
68
+
69
+ /**
70
+ * Get directive metadata by name
71
+ */
72
+ export function getDirective(name: string): DirectiveMetadata | undefined {
73
+ return DIRECTIVES[name];
74
+ }
75
+
76
+ /**
77
+ * Get all directive names
78
+ */
79
+ export function getDirectiveNames(): string[] {
80
+ return Object.keys(DIRECTIVES);
81
+ }
82
+
83
+ /**
84
+ * Check if a directive can be placed on a specific element type
85
+ */
86
+ export function canPlaceDirective(directiveName: string, elementType: 'element' | 'component' | 'slot'): boolean {
87
+ const directive = DIRECTIVES[directiveName];
88
+ if (!directive) return false;
89
+
90
+ // Directives cannot be placed on <slot>
91
+ if (elementType === 'slot') return false;
92
+
93
+ return directive.placement.includes(elementType as 'element' | 'component');
94
+ }
95
+
96
+ /**
97
+ * Parse a zen:for expression to extract variables
98
+ */
99
+ export function parseForExpression(expression: string): { itemVar: string; indexVar?: string; source: string } | null {
100
+ // Match: "item in items" or "item, index in items"
101
+ const match = expression.match(/^\s*([a-zA-Z_$][\w$]*)(?:\s*,\s*([a-zA-Z_$][\w$]*))?\s+in\s+(.+)\s*$/);
102
+ if (!match) return null;
103
+
104
+ return {
105
+ itemVar: match[1],
106
+ indexVar: match[2],
107
+ source: match[3].trim()
108
+ };
109
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Plugin Import Metadata
3
+ *
4
+ * Static metadata for Zenith plugin modules.
5
+ * Plugin modules use the zenith:* namespace.
6
+ *
7
+ * Important: The LSP must NOT assume plugin presence.
8
+ * If a plugin is not installed, diagnostics should be soft warnings, not errors.
9
+ */
10
+
11
+ export interface PluginExport {
12
+ name: string;
13
+ kind: 'function' | 'type' | 'variable';
14
+ description: string;
15
+ signature?: string;
16
+ }
17
+
18
+ export interface PluginModuleMetadata {
19
+ module: string;
20
+ description: string;
21
+ exports: PluginExport[];
22
+ required: boolean;
23
+ }
24
+
25
+ /**
26
+ * Known Zenith plugin modules
27
+ */
28
+ export const PLUGIN_MODULES: Record<string, PluginModuleMetadata> = {
29
+ 'zenith:content': {
30
+ module: 'zenith:content',
31
+ description: 'Content collections plugin for Zenith. Provides type-safe content management for Markdown, MDX, and JSON files.',
32
+ exports: [
33
+ {
34
+ name: 'zenCollection',
35
+ kind: 'function',
36
+ description: 'Define a content collection with schema validation.',
37
+ signature: 'zenCollection<T>(options: { name: string; schema: T }): Collection<T>'
38
+ },
39
+ {
40
+ name: 'getCollection',
41
+ kind: 'function',
42
+ description: 'Get all entries from a content collection.',
43
+ signature: 'getCollection(name: string): Promise<CollectionEntry[]>'
44
+ },
45
+ {
46
+ name: 'getEntry',
47
+ kind: 'function',
48
+ description: 'Get a single entry from a content collection.',
49
+ signature: 'getEntry(collection: string, slug: string): Promise<CollectionEntry | undefined>'
50
+ },
51
+ {
52
+ name: 'useZenOrder',
53
+ kind: 'function',
54
+ description: 'Hook to sort collection entries by frontmatter order field.',
55
+ signature: 'useZenOrder(entries: CollectionEntry[]): CollectionEntry[]'
56
+ }
57
+ ],
58
+ required: false
59
+ },
60
+ 'zenith:image': {
61
+ module: 'zenith:image',
62
+ description: 'Image optimization plugin for Zenith.',
63
+ exports: [
64
+ {
65
+ name: 'Image',
66
+ kind: 'function',
67
+ description: 'Optimized image component with automatic format conversion and lazy loading.',
68
+ signature: 'Image({ src: string; alt: string; width?: number; height?: number })'
69
+ },
70
+ {
71
+ name: 'getImage',
72
+ kind: 'function',
73
+ description: 'Get optimized image metadata.',
74
+ signature: 'getImage(src: string, options?: ImageOptions): Promise<ImageMetadata>'
75
+ }
76
+ ],
77
+ required: false
78
+ }
79
+ };
80
+
81
+ /**
82
+ * Get a plugin module by name
83
+ */
84
+ export function getPluginModule(moduleName: string): PluginModuleMetadata | undefined {
85
+ return PLUGIN_MODULES[moduleName];
86
+ }
87
+
88
+ /**
89
+ * Get all plugin module names
90
+ */
91
+ export function getPluginModuleNames(): string[] {
92
+ return Object.keys(PLUGIN_MODULES);
93
+ }
94
+
95
+ /**
96
+ * Get an export from a plugin module
97
+ */
98
+ export function getPluginExport(moduleName: string, exportName: string): PluginExport | undefined {
99
+ const module = PLUGIN_MODULES[moduleName];
100
+ if (!module) return undefined;
101
+ return module.exports.find(e => e.name === exportName);
102
+ }
103
+
104
+ /**
105
+ * Check if a module is a plugin module (zenith:* namespace)
106
+ */
107
+ export function isPluginModule(moduleName: string): boolean {
108
+ return moduleName.startsWith('zenith:');
109
+ }
110
+
111
+ /**
112
+ * Check if a plugin module is known
113
+ */
114
+ export function isKnownPluginModule(moduleName: string): boolean {
115
+ return moduleName in PLUGIN_MODULES;
116
+ }
package/src/project.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Zenith Project Graph
3
+ *
4
+ * Uses the compiler's discovery logic to build a project graph
5
+ * Ensures LSP understands the same structure as the compiler
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+
11
+ export interface ComponentInfo {
12
+ name: string;
13
+ filePath: string;
14
+ type: 'layout' | 'component' | 'page';
15
+ props: string[];
16
+ }
17
+
18
+ export interface ProjectGraph {
19
+ root: string;
20
+ layouts: Map<string, ComponentInfo>;
21
+ components: Map<string, ComponentInfo>;
22
+ pages: Map<string, ComponentInfo>;
23
+ }
24
+
25
+ /**
26
+ * Detect Zenith project root
27
+ * Looks for zenith.config.ts, src/, or app/
28
+ */
29
+ export function detectProjectRoot(startPath: string): string | null {
30
+ let current = startPath;
31
+
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;
36
+ }
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
+ }
45
+ }
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
+ }
54
+ }
55
+ current = path.dirname(current);
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Extract props from a .zen file
63
+ * Infers props from usage patterns (Astro/Vue style)
64
+ */
65
+ function extractPropsFromFile(filePath: string): string[] {
66
+ try {
67
+ const content = fs.readFileSync(filePath, 'utf-8');
68
+ const props: string[] = [];
69
+
70
+ // Look for Props interface/type
71
+ const propsMatch = content.match(/(?:interface|type)\s+Props\s*[={]\s*\{([^}]+)\}/);
72
+ if (propsMatch && propsMatch[1]) {
73
+ const propNames = propsMatch[1].match(/([a-zA-Z_$][a-zA-Z0-9_$?]*)\s*[?:]?\s*:/g);
74
+ if (propNames) {
75
+ for (const p of propNames) {
76
+ const name = p.replace(/[?:\s]/g, '');
77
+ if (name && !props.includes(name)) {
78
+ props.push(name);
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ // Look for common prop patterns in expressions
85
+ const usagePatterns = content.matchAll(/\{(title|lang|className|children|href|src|alt|id|name)\}/g);
86
+ for (const match of usagePatterns) {
87
+ if (match[1] && !props.includes(match[1])) {
88
+ props.push(match[1]);
89
+ }
90
+ }
91
+
92
+ return props;
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Discover all .zen files in a directory
100
+ */
101
+ function discoverZenFiles(dir: string, type: 'layout' | 'component' | 'page'): Map<string, ComponentInfo> {
102
+ const result = new Map<string, ComponentInfo>();
103
+
104
+ if (!fs.existsSync(dir)) {
105
+ return result;
106
+ }
107
+
108
+ function scanDir(currentDir: string) {
109
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
110
+
111
+ for (const entry of entries) {
112
+ const fullPath = path.join(currentDir, entry.name);
113
+
114
+ if (entry.isDirectory()) {
115
+ scanDir(fullPath);
116
+ } else if (entry.name.endsWith('.zen')) {
117
+ const name = path.basename(entry.name, '.zen');
118
+ const props = extractPropsFromFile(fullPath);
119
+
120
+ result.set(name, {
121
+ name,
122
+ filePath: fullPath,
123
+ type,
124
+ props
125
+ });
126
+ }
127
+ }
128
+ }
129
+
130
+ scanDir(dir);
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * Build project graph from root directory
136
+ */
137
+ export function buildProjectGraph(root: string): ProjectGraph {
138
+ const srcDir = fs.existsSync(path.join(root, 'src')) ? path.join(root, 'src') : path.join(root, 'app');
139
+
140
+ const layouts = discoverZenFiles(path.join(srcDir, 'layouts'), 'layout');
141
+ const components = discoverZenFiles(path.join(srcDir, 'components'), 'component');
142
+ const pages = discoverZenFiles(path.join(srcDir, 'pages'), 'page');
143
+
144
+ return {
145
+ root,
146
+ layouts,
147
+ components,
148
+ pages
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Resolve a component/layout by name
154
+ */
155
+ export function resolveComponent(graph: ProjectGraph, name: string): ComponentInfo | undefined {
156
+ // Check layouts first (common pattern for <DefaultLayout>)
157
+ if (graph.layouts.has(name)) {
158
+ return graph.layouts.get(name);
159
+ }
160
+
161
+ // Then components
162
+ if (graph.components.has(name)) {
163
+ return graph.components.get(name);
164
+ }
165
+
166
+ return undefined;
167
+ }
package/src/router.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Router Awareness
3
+ *
4
+ * Special support for Zenith Router features.
5
+ * The LSP provides router-aware completions and hovers when zenith/router is imported.
6
+ *
7
+ * Important: No route file system assumptions or runtime navigation simulation.
8
+ */
9
+
10
+ export interface RouterHookMetadata {
11
+ name: string;
12
+ owner: string;
13
+ description: string;
14
+ restrictions: string;
15
+ returns: string;
16
+ signature: string;
17
+ }
18
+
19
+ export interface ZenLinkPropMetadata {
20
+ name: string;
21
+ type: string;
22
+ required: boolean;
23
+ description: string;
24
+ }
25
+
26
+ export interface RouteFieldMetadata {
27
+ name: string;
28
+ type: string;
29
+ description: string;
30
+ }
31
+
32
+ /**
33
+ * Router hook definitions
34
+ */
35
+ export const ROUTER_HOOKS: Record<string, RouterHookMetadata> = {
36
+ useRoute: {
37
+ name: 'useRoute',
38
+ owner: 'Router Hook (zenith/router)',
39
+ description: 'Provides reactive access to the current route state.',
40
+ restrictions: 'Must be called at top-level script scope.',
41
+ returns: '{ path: string; params: Record<string, string>; query: Record<string, string> }',
42
+ signature: 'useRoute(): RouteState'
43
+ },
44
+ useRouter: {
45
+ name: 'useRouter',
46
+ owner: 'Router Hook (zenith/router)',
47
+ description: 'Provides programmatic navigation methods.',
48
+ restrictions: 'Must be called at top-level script scope.',
49
+ returns: '{ navigate, back, forward, go }',
50
+ signature: 'useRouter(): Router'
51
+ }
52
+ };
53
+
54
+ /**
55
+ * ZenLink component props
56
+ */
57
+ export const ZENLINK_PROPS: ZenLinkPropMetadata[] = [
58
+ {
59
+ name: 'to',
60
+ type: 'string',
61
+ required: true,
62
+ description: 'The route path to navigate to.'
63
+ },
64
+ {
65
+ name: 'preload',
66
+ type: 'boolean',
67
+ required: false,
68
+ description: 'Whether to prefetch the route on hover.'
69
+ },
70
+ {
71
+ name: 'replace',
72
+ type: 'boolean',
73
+ required: false,
74
+ description: 'Whether to replace the current history entry instead of pushing a new one.'
75
+ },
76
+ {
77
+ name: 'class',
78
+ type: 'string',
79
+ required: false,
80
+ description: 'CSS class to apply to the link.'
81
+ },
82
+ {
83
+ name: 'activeClass',
84
+ type: 'string',
85
+ required: false,
86
+ description: 'CSS class to apply when the link is active.'
87
+ }
88
+ ];
89
+
90
+ /**
91
+ * Route state fields available from useRoute()
92
+ */
93
+ export const ROUTE_FIELDS: RouteFieldMetadata[] = [
94
+ {
95
+ name: 'path',
96
+ type: 'string',
97
+ description: 'The current route path (e.g., "/blog/my-post").'
98
+ },
99
+ {
100
+ name: 'params',
101
+ type: 'Record<string, string>',
102
+ description: 'Dynamic route parameters (e.g., { slug: "my-post" }).'
103
+ },
104
+ {
105
+ name: 'query',
106
+ type: 'Record<string, string>',
107
+ description: 'Query string parameters (e.g., { page: "1" }).'
108
+ }
109
+ ];
110
+
111
+ /**
112
+ * Router navigation functions
113
+ */
114
+ export const ROUTER_FUNCTIONS = [
115
+ {
116
+ name: 'navigate',
117
+ description: 'Navigate to a route programmatically.',
118
+ signature: 'navigate(to: string, options?: { replace?: boolean }): void'
119
+ },
120
+ {
121
+ name: 'prefetch',
122
+ description: 'Prefetch a route for faster navigation.',
123
+ signature: 'prefetch(path: string): Promise<void>'
124
+ },
125
+ {
126
+ name: 'isActive',
127
+ description: 'Check if a route is currently active.',
128
+ signature: 'isActive(path: string, exact?: boolean): boolean'
129
+ },
130
+ {
131
+ name: 'back',
132
+ description: 'Navigate back in history.',
133
+ signature: 'back(): void'
134
+ },
135
+ {
136
+ name: 'forward',
137
+ description: 'Navigate forward in history.',
138
+ signature: 'forward(): void'
139
+ },
140
+ {
141
+ name: 'go',
142
+ description: 'Navigate to a specific history entry.',
143
+ signature: 'go(delta: number): void'
144
+ }
145
+ ];
146
+
147
+ /**
148
+ * Get router hook metadata
149
+ */
150
+ export function getRouterHook(name: string): RouterHookMetadata | undefined {
151
+ return ROUTER_HOOKS[name];
152
+ }
153
+
154
+ /**
155
+ * Check if a name is a router hook
156
+ */
157
+ export function isRouterHook(name: string): boolean {
158
+ return name in ROUTER_HOOKS;
159
+ }
160
+
161
+ /**
162
+ * Get ZenLink prop metadata
163
+ */
164
+ export function getZenLinkProp(name: string): ZenLinkPropMetadata | undefined {
165
+ return ZENLINK_PROPS.find(p => p.name === name);
166
+ }
167
+
168
+ /**
169
+ * Get all ZenLink prop names
170
+ */
171
+ export function getZenLinkPropNames(): string[] {
172
+ return ZENLINK_PROPS.map(p => p.name);
173
+ }
174
+
175
+ /**
176
+ * Get route field metadata
177
+ */
178
+ export function getRouteField(name: string): RouteFieldMetadata | undefined {
179
+ return ROUTE_FIELDS.find(f => f.name === name);
180
+ }