aero-vscode 0.0.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,328 @@
1
+ /**
2
+ * Completion provider for Aero HTML: attributes, import paths, component/layout tags, content globals.
3
+ *
4
+ * @remarks
5
+ * Triggers on `<`, `/`, `@`, `"`, `'`. Supplies Aero attributes (data-each, data-if, etc.), path/alias completions from the resolver, and content global properties. Uses classifyPosition and getResolver.
6
+ */
7
+ import * as vscode from 'vscode'
8
+ import * as path from 'node:path'
9
+ import * as fs from 'node:fs'
10
+ import { getResolver } from './pathResolver'
11
+ import { CONTENT_GLOBALS } from './constants'
12
+ import { isAeroDocument } from './scope'
13
+
14
+ const AERO_ATTRIBUTES: Array<{
15
+ label: string
16
+ detail: string
17
+ snippet?: string
18
+ kind: vscode.CompletionItemKind
19
+ }> = [
20
+ {
21
+ label: 'is:build',
22
+ detail: 'Build-time script block (Aero)',
23
+ kind: vscode.CompletionItemKind.Property,
24
+ },
25
+ {
26
+ label: 'is:inline',
27
+ detail: 'Inline client script, no bundling (Aero)',
28
+ kind: vscode.CompletionItemKind.Property,
29
+ },
30
+ {
31
+ label: 'data-each',
32
+ detail: 'Loop over items (Aero)',
33
+ snippet: 'data-each="{ ${1:item} in ${2:items} }"',
34
+ kind: vscode.CompletionItemKind.Keyword,
35
+ },
36
+ {
37
+ label: 'data-if',
38
+ detail: 'Conditional rendering (Aero)',
39
+ snippet: 'data-if="{ ${1:condition} }"',
40
+ kind: vscode.CompletionItemKind.Keyword,
41
+ },
42
+ {
43
+ label: 'data-else-if',
44
+ detail: 'Chained conditional (Aero)',
45
+ snippet: 'data-else-if="{ ${1:condition} }"',
46
+ kind: vscode.CompletionItemKind.Keyword,
47
+ },
48
+ {
49
+ label: 'data-else',
50
+ detail: 'Fallback conditional (Aero)',
51
+ kind: vscode.CompletionItemKind.Keyword,
52
+ },
53
+ {
54
+ label: 'data-props',
55
+ detail: 'Spread props to component (Aero)',
56
+ kind: vscode.CompletionItemKind.Property,
57
+ },
58
+ {
59
+ label: 'each',
60
+ detail: 'Loop over items (Aero shorthand)',
61
+ snippet: 'each="{ ${1:item} in ${2:items} }"',
62
+ kind: vscode.CompletionItemKind.Keyword,
63
+ },
64
+ {
65
+ label: 'if',
66
+ detail: 'Conditional rendering (Aero shorthand)',
67
+ snippet: 'if="{ ${1:condition} }"',
68
+ kind: vscode.CompletionItemKind.Keyword,
69
+ },
70
+ {
71
+ label: 'else-if',
72
+ detail: 'Chained conditional (Aero shorthand)',
73
+ snippet: 'else-if="{ ${1:condition} }"',
74
+ kind: vscode.CompletionItemKind.Keyword,
75
+ },
76
+ {
77
+ label: 'else',
78
+ detail: 'Fallback conditional (Aero shorthand)',
79
+ kind: vscode.CompletionItemKind.Keyword,
80
+ },
81
+ ]
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Completion Provider
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export class AeroCompletionProvider implements vscode.CompletionItemProvider {
88
+ provideCompletionItems(
89
+ document: vscode.TextDocument,
90
+ position: vscode.Position,
91
+ _token: vscode.CancellationToken,
92
+ context: vscode.CompletionContext,
93
+ ): vscode.ProviderResult<vscode.CompletionItem[]> {
94
+ if (!isAeroDocument(document)) return null
95
+
96
+ const lineText = document.lineAt(position.line).text
97
+ const textBefore = lineText.slice(0, position.character)
98
+
99
+ // 1. Component/layout tag completions after `<`
100
+ const tagMatch = textBefore.match(/<([a-z][a-z0-9-]*)$/)
101
+ if (tagMatch) {
102
+ return this.getComponentTagCompletions(document, tagMatch[1])
103
+ }
104
+
105
+ // 2. Aero attribute completions inside a tag
106
+ if (this.isInsideTag(lineText, position.character)) {
107
+ return this.getAttributeCompletions(textBefore)
108
+ }
109
+
110
+ // 3. Import path / alias completions
111
+ const importMatch = textBefore.match(/from\s+['"]([^'"]*?)$/)
112
+ if (importMatch) {
113
+ return this.getImportPathCompletions(document, importMatch[1])
114
+ }
115
+
116
+ // 4. Content global completions inside { }
117
+ if (this.isInsideExpression(textBefore)) {
118
+ return this.getExpressionCompletions()
119
+ }
120
+
121
+ return null
122
+ }
123
+
124
+ // -----------------------------------------------------------------------
125
+ // Component tag completions
126
+ // -----------------------------------------------------------------------
127
+
128
+ private getComponentTagCompletions(
129
+ document: vscode.TextDocument,
130
+ prefix: string,
131
+ ): vscode.CompletionItem[] {
132
+ const resolver = getResolver(document)
133
+ if (!resolver) return []
134
+
135
+ const items: vscode.CompletionItem[] = []
136
+
137
+ // Scan client/components/ for component files
138
+ const componentsDir = path.join(resolver.root, 'client', 'components')
139
+ items.push(...this.scanDirForTags(componentsDir, 'component', prefix))
140
+
141
+ // Scan client/layouts/ for layout files
142
+ const layoutsDir = path.join(resolver.root, 'client', 'layouts')
143
+ items.push(...this.scanDirForTags(layoutsDir, 'layout', prefix))
144
+
145
+ return items
146
+ }
147
+
148
+ private scanDirForTags(
149
+ dir: string,
150
+ suffix: 'component' | 'layout',
151
+ prefix: string,
152
+ ): vscode.CompletionItem[] {
153
+ const items: vscode.CompletionItem[] = []
154
+
155
+ if (!fs.existsSync(dir)) return items
156
+
157
+ try {
158
+ const files = fs.readdirSync(dir)
159
+ for (const file of files) {
160
+ if (!file.endsWith('.html')) continue
161
+ const baseName = file.replace(/\.html$/, '')
162
+ // Convert camelCase to kebab-case for tag name
163
+ const kebab = baseName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
164
+ const tagName = `${kebab}-${suffix}`
165
+
166
+ if (!tagName.startsWith(prefix)) continue
167
+
168
+ const item = new vscode.CompletionItem(
169
+ tagName,
170
+ suffix === 'component'
171
+ ? vscode.CompletionItemKind.Class
172
+ : vscode.CompletionItemKind.Struct,
173
+ )
174
+ item.detail = `${suffix === 'component' ? 'Component' : 'Layout'}: ${file}`
175
+ item.insertText = new vscode.SnippetString(`${tagName} $1/>\n`)
176
+ items.push(item)
177
+ }
178
+ } catch {
179
+ // Directory read error
180
+ }
181
+
182
+ return items
183
+ }
184
+
185
+ // -----------------------------------------------------------------------
186
+ // Attribute completions
187
+ // -----------------------------------------------------------------------
188
+
189
+ private getAttributeCompletions(textBefore: string): vscode.CompletionItem[] {
190
+ // Only suggest if we're at an attribute position (after whitespace following tag name or other attr)
191
+ if (!/\s$/.test(textBefore) && !/=["'][^"']*$/.test(textBefore)) {
192
+ // Check if we're mid-word at an attribute position
193
+ const attrPrefix = textBefore.match(/\s([a-z-:]*)$/)?.[1]
194
+ if (attrPrefix === undefined) return []
195
+ }
196
+
197
+ return AERO_ATTRIBUTES.map(attr => {
198
+ const item = new vscode.CompletionItem(attr.label, attr.kind)
199
+ item.detail = attr.detail
200
+ if (attr.snippet) {
201
+ item.insertText = new vscode.SnippetString(attr.snippet)
202
+ }
203
+ return item
204
+ })
205
+ }
206
+
207
+ // -----------------------------------------------------------------------
208
+ // Import path completions
209
+ // -----------------------------------------------------------------------
210
+
211
+ private getImportPathCompletions(
212
+ document: vscode.TextDocument,
213
+ partial: string,
214
+ ): vscode.CompletionItem[] {
215
+ const resolver = getResolver(document)
216
+ if (!resolver) return []
217
+
218
+ const items: vscode.CompletionItem[] = []
219
+
220
+ // Suggest alias prefixes if nothing typed yet or starts with @
221
+ if (!partial || partial === '@') {
222
+ const aliases = [
223
+ '@components/',
224
+ '@layouts/',
225
+ '@content/',
226
+ '@pages/',
227
+ '@styles/',
228
+ '@scripts/',
229
+ '@images/',
230
+ '@client/',
231
+ '@server/',
232
+ '~/',
233
+ ]
234
+ for (const alias of aliases) {
235
+ if (alias.startsWith(partial)) {
236
+ const item = new vscode.CompletionItem(alias, vscode.CompletionItemKind.Folder)
237
+ item.detail = 'Aero path alias'
238
+ items.push(item)
239
+ }
240
+ }
241
+ return items
242
+ }
243
+
244
+ // If partial starts with an alias, list files in that directory
245
+ const resolved = resolver.resolve(partial)
246
+ if (resolved) {
247
+ const dir =
248
+ fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()
249
+ ? resolved
250
+ : path.dirname(resolved)
251
+
252
+ if (fs.existsSync(dir)) {
253
+ try {
254
+ const files = fs.readdirSync(dir)
255
+ for (const file of files) {
256
+ const filePath = path.join(dir, file)
257
+ const stat = fs.statSync(filePath)
258
+ if (stat.isDirectory()) {
259
+ const item = new vscode.CompletionItem(
260
+ file + '/',
261
+ vscode.CompletionItemKind.Folder,
262
+ )
263
+ items.push(item)
264
+ } else {
265
+ const baseName = file.replace(/\.(html|ts|js|json)$/, '')
266
+ const item = new vscode.CompletionItem(baseName, vscode.CompletionItemKind.File)
267
+ item.detail = file
268
+ items.push(item)
269
+ }
270
+ }
271
+ } catch {
272
+ // Directory read error
273
+ }
274
+ }
275
+ }
276
+
277
+ return items
278
+ }
279
+
280
+ // -----------------------------------------------------------------------
281
+ // Expression completions
282
+ // -----------------------------------------------------------------------
283
+
284
+ private getExpressionCompletions(): vscode.CompletionItem[] {
285
+ const items: vscode.CompletionItem[] = []
286
+
287
+ // Content globals
288
+ for (const [name, alias] of Object.entries(CONTENT_GLOBALS)) {
289
+ const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Variable)
290
+ item.detail = `Content global (${alias})`
291
+ items.push(item)
292
+ }
293
+
294
+ // Aero.props
295
+ const propsItem = new vscode.CompletionItem('Aero', vscode.CompletionItemKind.Module)
296
+ propsItem.detail = 'Aero runtime context'
297
+ items.push(propsItem)
298
+
299
+ return items
300
+ }
301
+
302
+ // -----------------------------------------------------------------------
303
+ // Context detection helpers
304
+ // -----------------------------------------------------------------------
305
+
306
+ private isInsideTag(lineText: string, offset: number): boolean {
307
+ // Simple heuristic: find the last `<` before offset that isn't closed by `>`
308
+ let lastOpen = -1
309
+ let lastClose = -1
310
+ for (let i = 0; i < offset; i++) {
311
+ if (lineText[i] === '<' && lineText[i + 1] !== '/') lastOpen = i
312
+ if (lineText[i] === '>') lastClose = i
313
+ }
314
+ return lastOpen > lastClose
315
+ }
316
+
317
+ private isInsideExpression(textBefore: string): boolean {
318
+ let depth = 0
319
+ for (let i = textBefore.length - 1; i >= 0; i--) {
320
+ if (textBefore[i] === '}') depth++
321
+ if (textBefore[i] === '{') {
322
+ if (depth === 0) return true
323
+ depth--
324
+ }
325
+ }
326
+ return false
327
+ }
328
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Constants for the Aero VS Code extension: regexes, attribute names, selectors, path resolution.
3
+ *
4
+ * @remarks
5
+ * Regex patterns mirror `packages/core/compiler/constants.ts` for consistent parsing. Used by analyzer, positionAt, and providers.
6
+ */
7
+ import * as vscode from 'vscode'
8
+
9
+ /** Matches component/layout suffix on tag names: `-component` or `-layout`. */
10
+ export const COMPONENT_SUFFIX_REGEX = /-(component|layout)$/
11
+
12
+ /** Matches import statements: `import X from 'path'` */
13
+ export const IMPORT_REGEX =
14
+ /((?:^|[\r\n;])\s*)import\s+(?:(\w+)|\{([^}]+)\}|\*\s+as\s+(\w+))\s+from\s+(['"])(.+?)\5/g
15
+
16
+ /** Matches `{ ... }` expressions in template text. */
17
+ export const CURLY_INTERPOLATION_REGEX = /{([\s\S]+?)}/g
18
+
19
+ /** Alpine.js attributes that should not be treated as Aero expressions (`x-*`, `@*`, `:*`, `.*`). */
20
+ export const ALPINE_ATTR_REGEX = /^(x-|[@:.]).*/
21
+
22
+ export const ATTR_IS_BUILD = 'is:build'
23
+ export const ATTR_IS_INLINE = 'is:inline'
24
+
25
+ /** Content globals: identifier → alias path. Files in `client/content/` are exposed as globals in Aero templates. */
26
+ export const CONTENT_GLOBALS: Record<string, string> = {
27
+ site: '@content/site',
28
+ theme: '@content/theme',
29
+ }
30
+
31
+ /** Document selector for Aero-relevant HTML files (language: html, scheme: file). */
32
+ export const HTML_SELECTOR: vscode.DocumentSelector = { language: 'html', scheme: 'file' }
33
+
34
+ /** Extensions to try when resolving imports without an extension. */
35
+ export const RESOLVE_EXTENSIONS = ['.html', '.ts', '.js', '.json']