luxlabs 1.0.0
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/LICENSE +37 -0
- package/README.md +161 -0
- package/commands/ab-tests.js +437 -0
- package/commands/agents.js +226 -0
- package/commands/data.js +966 -0
- package/commands/deploy.js +166 -0
- package/commands/dev.js +569 -0
- package/commands/init.js +126 -0
- package/commands/interface/boilerplate.js +52 -0
- package/commands/interface/git-utils.js +85 -0
- package/commands/interface/index.js +7 -0
- package/commands/interface/init.js +375 -0
- package/commands/interface/path.js +74 -0
- package/commands/interface.js +125 -0
- package/commands/knowledge.js +339 -0
- package/commands/link.js +127 -0
- package/commands/list.js +97 -0
- package/commands/login.js +247 -0
- package/commands/logout.js +19 -0
- package/commands/logs.js +182 -0
- package/commands/pricing.js +328 -0
- package/commands/project.js +704 -0
- package/commands/secrets.js +129 -0
- package/commands/servers.js +411 -0
- package/commands/storage.js +177 -0
- package/commands/up.js +211 -0
- package/commands/validate-data-lux.js +502 -0
- package/commands/voice-agents.js +1055 -0
- package/commands/webview.js +393 -0
- package/commands/workflows.js +836 -0
- package/lib/config.js +403 -0
- package/lib/helpers.js +189 -0
- package/lib/node-helper.js +120 -0
- package/lux.js +268 -0
- package/package.json +56 -0
- package/templates/next-env.d.ts +6 -0
package/commands/up.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const archiver = require('archiver');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const ora = require('ora');
|
|
6
|
+
const chalk = require('chalk');
|
|
7
|
+
const ignore = require('ignore');
|
|
8
|
+
const {
|
|
9
|
+
loadConfig,
|
|
10
|
+
loadInterfaceConfig,
|
|
11
|
+
saveInterfaceConfig,
|
|
12
|
+
getApiUrl,
|
|
13
|
+
getAuthHeaders,
|
|
14
|
+
isAuthenticated,
|
|
15
|
+
} = require('../lib/config');
|
|
16
|
+
|
|
17
|
+
async function up(options) {
|
|
18
|
+
// Check authentication
|
|
19
|
+
if (!isAuthenticated()) {
|
|
20
|
+
console.log(
|
|
21
|
+
chalk.red('❌ Not authenticated. Run'),
|
|
22
|
+
chalk.white('lux login'),
|
|
23
|
+
chalk.red('first.')
|
|
24
|
+
);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check if initialized
|
|
29
|
+
const interfaceConfig = loadInterfaceConfig();
|
|
30
|
+
if (!interfaceConfig) {
|
|
31
|
+
console.log(
|
|
32
|
+
chalk.red('❌ Not initialized. Run'),
|
|
33
|
+
chalk.white('lux init'),
|
|
34
|
+
chalk.red('first.')
|
|
35
|
+
);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const config = loadConfig();
|
|
40
|
+
const apiUrl = getApiUrl();
|
|
41
|
+
const spinner = ora('Preparing upload...').start();
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
let interfaceId = interfaceConfig.id;
|
|
45
|
+
|
|
46
|
+
// Step 1: Create interface if it doesn't exist
|
|
47
|
+
if (!interfaceId) {
|
|
48
|
+
spinner.text = 'Creating interface...';
|
|
49
|
+
|
|
50
|
+
const { data } = await axios.post(
|
|
51
|
+
`${apiUrl}/api/interfaces`,
|
|
52
|
+
{
|
|
53
|
+
name: interfaceConfig.name,
|
|
54
|
+
description: interfaceConfig.description,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
headers: getAuthHeaders(),
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
interfaceId = data.interface.id;
|
|
62
|
+
interfaceConfig.id = interfaceId;
|
|
63
|
+
interfaceConfig.githubRepoUrl = data.interface.github_repo_url;
|
|
64
|
+
saveInterfaceConfig(interfaceConfig);
|
|
65
|
+
|
|
66
|
+
spinner.succeed(chalk.green(`Created interface: ${interfaceId}`));
|
|
67
|
+
spinner.start();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Step 2: Create zip file
|
|
71
|
+
spinner.text = 'Creating zip file...';
|
|
72
|
+
const zipPath = await createZip(process.cwd());
|
|
73
|
+
const stats = fs.statSync(zipPath);
|
|
74
|
+
spinner.text = `Created zip (${formatBytes(stats.size)})...`;
|
|
75
|
+
|
|
76
|
+
// Step 3: Get presigned upload URL
|
|
77
|
+
spinner.text = 'Getting upload URL...';
|
|
78
|
+
const { data: urlData } = await axios.post(
|
|
79
|
+
`${apiUrl}/api/interfaces/${interfaceId}/presigned-urls`,
|
|
80
|
+
{ zip_upload: true },
|
|
81
|
+
{ headers: getAuthHeaders() }
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Step 4: Upload zip to R2
|
|
85
|
+
spinner.text = 'Uploading files...';
|
|
86
|
+
await axios.put(urlData.upload_url, fs.readFileSync(zipPath), {
|
|
87
|
+
headers: { 'Content-Type': 'application/zip' },
|
|
88
|
+
maxBodyLength: Infinity,
|
|
89
|
+
maxContentLength: Infinity,
|
|
90
|
+
onUploadProgress: (progressEvent) => {
|
|
91
|
+
const percent = Math.round(
|
|
92
|
+
(progressEvent.loaded * 100) / progressEvent.total
|
|
93
|
+
);
|
|
94
|
+
spinner.text = `Uploading files... ${percent}%`;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Step 5: Trigger extraction and GitHub sync
|
|
99
|
+
spinner.text = 'Syncing to GitHub...';
|
|
100
|
+
const { data: uploadResult } = await axios.post(
|
|
101
|
+
`${apiUrl}/api/interfaces/${interfaceId}/upload-files`,
|
|
102
|
+
{ zip_path: urlData.storage_path },
|
|
103
|
+
{ headers: getAuthHeaders() }
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Cleanup
|
|
107
|
+
fs.unlinkSync(zipPath);
|
|
108
|
+
|
|
109
|
+
spinner.succeed(
|
|
110
|
+
chalk.green(`✓ Uploaded ${uploadResult.files_uploaded} files`)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
console.log(chalk.dim(`\n${uploadResult.message}\n`));
|
|
114
|
+
console.log(chalk.cyan('Files synced to dev branch. Ready to deploy!'));
|
|
115
|
+
console.log(
|
|
116
|
+
chalk.dim(`Run ${chalk.white('lux deploy')} to publish to production\n`)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (interfaceConfig.githubRepoUrl) {
|
|
120
|
+
console.log(chalk.dim(`GitHub: ${interfaceConfig.githubRepoUrl}`));
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
spinner.fail('Upload failed');
|
|
124
|
+
console.error(
|
|
125
|
+
chalk.red('\n❌ Error:'),
|
|
126
|
+
error.response?.data?.error || error.message
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (error.response?.status === 401) {
|
|
130
|
+
console.log(
|
|
131
|
+
chalk.yellow('\nYour session may have expired. Try running:'),
|
|
132
|
+
chalk.white('lux login')
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function createZip(sourceDir) {
|
|
141
|
+
const output = fs.createWriteStream('/tmp/lux-upload.zip');
|
|
142
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
143
|
+
|
|
144
|
+
// Load .gitignore patterns
|
|
145
|
+
const ig = ignore();
|
|
146
|
+
const gitignorePath = path.join(sourceDir, '.gitignore');
|
|
147
|
+
|
|
148
|
+
if (fs.existsSync(gitignorePath)) {
|
|
149
|
+
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
|
|
150
|
+
ig.add(gitignoreContent);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Add default ignores (same as server)
|
|
154
|
+
ig.add([
|
|
155
|
+
'node_modules/',
|
|
156
|
+
'.next/',
|
|
157
|
+
'.git/',
|
|
158
|
+
'.env.local',
|
|
159
|
+
'.DS_Store',
|
|
160
|
+
'dist/',
|
|
161
|
+
'build/',
|
|
162
|
+
'.cache/',
|
|
163
|
+
'coverage/',
|
|
164
|
+
'.lux/interface.json', // Don't upload local config
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
output.on('close', () => resolve('/tmp/lux-upload.zip'));
|
|
169
|
+
archive.on('error', reject);
|
|
170
|
+
|
|
171
|
+
archive.pipe(output);
|
|
172
|
+
|
|
173
|
+
// Recursively add files
|
|
174
|
+
function addDirectory(dirPath, baseDir = '') {
|
|
175
|
+
const files = fs.readdirSync(dirPath);
|
|
176
|
+
|
|
177
|
+
for (const file of files) {
|
|
178
|
+
const fullPath = path.join(dirPath, file);
|
|
179
|
+
const relativePath = path.join(baseDir, file);
|
|
180
|
+
|
|
181
|
+
// Check if ignored
|
|
182
|
+
if (ig.ignores(relativePath)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const stat = fs.statSync(fullPath);
|
|
187
|
+
|
|
188
|
+
if (stat.isDirectory()) {
|
|
189
|
+
addDirectory(fullPath, relativePath);
|
|
190
|
+
} else if (stat.isFile()) {
|
|
191
|
+
archive.file(fullPath, { name: relativePath });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
addDirectory(sourceDir);
|
|
197
|
+
archive.finalize();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatBytes(bytes) {
|
|
202
|
+
if (bytes === 0) return '0 Bytes';
|
|
203
|
+
const k = 1024;
|
|
204
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
205
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
206
|
+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
up,
|
|
211
|
+
};
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate data-lux Attributes CLI Command
|
|
3
|
+
*
|
|
4
|
+
* Scans interface component files and reports interactive elements
|
|
5
|
+
* that are missing data-lux attributes.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node lux-cli.js validate-data-lux [interface-id]
|
|
9
|
+
*
|
|
10
|
+
* If interface-id is not provided, validates all interfaces.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const chalk = require('chalk');
|
|
16
|
+
const {
|
|
17
|
+
getInterfacesDir,
|
|
18
|
+
getOrgId,
|
|
19
|
+
getProjectId,
|
|
20
|
+
LUX_STUDIO_DIR,
|
|
21
|
+
} = require('../lib/config');
|
|
22
|
+
const { error, success, info, warn } = require('../lib/helpers');
|
|
23
|
+
|
|
24
|
+
// Interactive elements that should have data-lux
|
|
25
|
+
const INTERACTIVE_ELEMENTS = ['button', 'input', 'form', 'select', 'textarea'];
|
|
26
|
+
|
|
27
|
+
// Event handlers that indicate an element is interactive
|
|
28
|
+
const INTERACTIVE_HANDLERS = [
|
|
29
|
+
'onClick',
|
|
30
|
+
'onSubmit',
|
|
31
|
+
'onChange',
|
|
32
|
+
'onBlur',
|
|
33
|
+
'onFocus',
|
|
34
|
+
'onKeyDown',
|
|
35
|
+
'onKeyUp',
|
|
36
|
+
'onKeyPress',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find all component files in a directory recursively
|
|
41
|
+
*/
|
|
42
|
+
function findComponentFiles(dir, files = []) {
|
|
43
|
+
if (!fs.existsSync(dir)) return files;
|
|
44
|
+
|
|
45
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
46
|
+
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
const fullPath = path.join(dir, entry.name);
|
|
49
|
+
|
|
50
|
+
// Skip node_modules and hidden directories
|
|
51
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
findComponentFiles(fullPath, files);
|
|
57
|
+
} else if (entry.isFile() && /\.(tsx|jsx)$/.test(entry.name)) {
|
|
58
|
+
files.push(fullPath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return files;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find the end of a JSX element's opening tag, properly handling nested braces.
|
|
67
|
+
* Returns { endIndex, attributes } or null if not found.
|
|
68
|
+
*
|
|
69
|
+
* This handles cases like:
|
|
70
|
+
* <input onChange={(e) => setValue(e)} data-lux={lux('name')} />
|
|
71
|
+
* where the `>` in `=>` should not end the tag.
|
|
72
|
+
*/
|
|
73
|
+
function findElementEnd(content, startIndex) {
|
|
74
|
+
let braceDepth = 0;
|
|
75
|
+
let i = startIndex;
|
|
76
|
+
|
|
77
|
+
while (i < content.length) {
|
|
78
|
+
const char = content[i];
|
|
79
|
+
|
|
80
|
+
if (char === '{') {
|
|
81
|
+
braceDepth++;
|
|
82
|
+
} else if (char === '}') {
|
|
83
|
+
braceDepth--;
|
|
84
|
+
} else if (char === '>' && braceDepth === 0) {
|
|
85
|
+
// Found the actual end of the opening tag
|
|
86
|
+
return {
|
|
87
|
+
endIndex: i,
|
|
88
|
+
attributes: content.substring(startIndex, i),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Find all matches of interactive elements with proper brace handling
|
|
100
|
+
*/
|
|
101
|
+
function findElementMatches(content, element) {
|
|
102
|
+
const matches = [];
|
|
103
|
+
const tagPattern = new RegExp(`<${element}\\b`, 'gi');
|
|
104
|
+
let tagMatch;
|
|
105
|
+
|
|
106
|
+
while ((tagMatch = tagPattern.exec(content)) !== null) {
|
|
107
|
+
const tagStart = tagMatch.index;
|
|
108
|
+
const attrStart = tagStart + tagMatch[0].length;
|
|
109
|
+
|
|
110
|
+
const result = findElementEnd(content, attrStart);
|
|
111
|
+
if (result) {
|
|
112
|
+
matches.push({
|
|
113
|
+
index: tagStart,
|
|
114
|
+
fullMatch: content.substring(tagStart, result.endIndex + 1),
|
|
115
|
+
attributes: result.attributes,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return matches;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Find all elements that have a specific handler, with proper brace handling
|
|
125
|
+
*/
|
|
126
|
+
function findElementsWithHandler(content, handler) {
|
|
127
|
+
const matches = [];
|
|
128
|
+
// Match any element opening tag
|
|
129
|
+
const tagPattern = /<(\w+)\b/g;
|
|
130
|
+
let tagMatch;
|
|
131
|
+
|
|
132
|
+
while ((tagMatch = tagPattern.exec(content)) !== null) {
|
|
133
|
+
const tagStart = tagMatch.index;
|
|
134
|
+
const elementName = tagMatch[1];
|
|
135
|
+
const attrStart = tagStart + tagMatch[0].length;
|
|
136
|
+
|
|
137
|
+
const result = findElementEnd(content, attrStart);
|
|
138
|
+
if (result) {
|
|
139
|
+
// Check if this element has the handler
|
|
140
|
+
const handlerPattern = new RegExp(`${handler}=`);
|
|
141
|
+
if (handlerPattern.test(result.attributes)) {
|
|
142
|
+
matches.push({
|
|
143
|
+
index: tagStart,
|
|
144
|
+
element: elementName.toLowerCase(),
|
|
145
|
+
fullMatch: content.substring(tagStart, result.endIndex + 1),
|
|
146
|
+
attributes: result.attributes,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return matches;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check if an element is inside a .map() callback
|
|
157
|
+
* This is a heuristic check - looks for common patterns
|
|
158
|
+
*/
|
|
159
|
+
function isInsideMapCallback(content, elementIndex) {
|
|
160
|
+
// Look backwards from the element to find .map( pattern
|
|
161
|
+
const beforeElement = content.substring(0, elementIndex);
|
|
162
|
+
const lastMapIndex = beforeElement.lastIndexOf('.map(');
|
|
163
|
+
const lastCloseParen = beforeElement.lastIndexOf(')');
|
|
164
|
+
|
|
165
|
+
// If there's a .map( and it's not closed before our element, we're likely inside it
|
|
166
|
+
if (lastMapIndex > -1 && lastMapIndex > lastCloseParen) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Extract element information from a match
|
|
175
|
+
*/
|
|
176
|
+
function extractElementInfo(content, elementStr, matchIndex) {
|
|
177
|
+
// Count line number
|
|
178
|
+
const beforeMatch = content.substring(0, matchIndex);
|
|
179
|
+
const lineNumber = (beforeMatch.match(/\n/g) || []).length + 1;
|
|
180
|
+
|
|
181
|
+
// Try to extract text content for buttons
|
|
182
|
+
let textContent = null;
|
|
183
|
+
const afterElement = content.substring(matchIndex);
|
|
184
|
+
const closingMatch = afterElement.match(/>([^<]*)</);
|
|
185
|
+
if (closingMatch && closingMatch[1].trim()) {
|
|
186
|
+
textContent = closingMatch[1].trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Try to extract placeholder for inputs
|
|
190
|
+
const placeholderMatch = elementStr.match(/placeholder=["']([^"']+)["']/);
|
|
191
|
+
const placeholder = placeholderMatch ? placeholderMatch[1] : null;
|
|
192
|
+
|
|
193
|
+
// Try to extract name attribute
|
|
194
|
+
const nameMatch = elementStr.match(/name=["']([^"']+)["']/);
|
|
195
|
+
const name = nameMatch ? nameMatch[1] : null;
|
|
196
|
+
|
|
197
|
+
// Try to extract aria-label
|
|
198
|
+
const ariaMatch = elementStr.match(/aria-label=["']([^"']+)["']/);
|
|
199
|
+
const ariaLabel = ariaMatch ? ariaMatch[1] : null;
|
|
200
|
+
|
|
201
|
+
// Try to extract onClick handler name
|
|
202
|
+
const onClickMatch = elementStr.match(/onClick=\{(\w+)\}/);
|
|
203
|
+
const handlerName = onClickMatch ? onClickMatch[1] : null;
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
lineNumber,
|
|
207
|
+
textContent,
|
|
208
|
+
placeholder,
|
|
209
|
+
name,
|
|
210
|
+
ariaLabel,
|
|
211
|
+
handlerName,
|
|
212
|
+
rawElement: elementStr.substring(0, 80) + (elementStr.length > 80 ? '...' : ''),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Generate a suggested name for the element
|
|
218
|
+
*/
|
|
219
|
+
function suggestName(element, info) {
|
|
220
|
+
// Priority: textContent > placeholder > name > ariaLabel > handlerName > generic
|
|
221
|
+
let suggestion = null;
|
|
222
|
+
|
|
223
|
+
if (info.textContent) {
|
|
224
|
+
suggestion = slugify(info.textContent);
|
|
225
|
+
} else if (info.placeholder) {
|
|
226
|
+
suggestion = slugify(info.placeholder) + '-input';
|
|
227
|
+
} else if (info.name) {
|
|
228
|
+
suggestion = slugify(info.name) + '-input';
|
|
229
|
+
} else if (info.ariaLabel) {
|
|
230
|
+
suggestion = slugify(info.ariaLabel);
|
|
231
|
+
} else if (info.handlerName) {
|
|
232
|
+
// Convert handleSubmit to submit, handleDeleteUser to delete-user
|
|
233
|
+
suggestion = slugify(info.handlerName.replace(/^handle/, ''));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Fallback to generic name
|
|
237
|
+
if (!suggestion) {
|
|
238
|
+
suggestion = element + '-element';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Append element type suffix if not present
|
|
242
|
+
if (element === 'button' && !suggestion.includes('button')) {
|
|
243
|
+
suggestion += '-button';
|
|
244
|
+
} else if (element === 'input' && !suggestion.includes('input')) {
|
|
245
|
+
suggestion += '-input';
|
|
246
|
+
} else if (element === 'form' && !suggestion.includes('form')) {
|
|
247
|
+
suggestion += '-form';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return suggestion;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Slugify a string for use as element name
|
|
255
|
+
*/
|
|
256
|
+
function slugify(str) {
|
|
257
|
+
return str
|
|
258
|
+
.toLowerCase()
|
|
259
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
260
|
+
.replace(/^-|-$/g, '')
|
|
261
|
+
.substring(0, 30); // Limit length
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Validate a single component file
|
|
266
|
+
*/
|
|
267
|
+
function validateFile(filePath, repoDir) {
|
|
268
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
269
|
+
const issues = [];
|
|
270
|
+
const relativePath = path.relative(repoDir, filePath);
|
|
271
|
+
|
|
272
|
+
// Check if file has 'use client' directive (only validate client components)
|
|
273
|
+
const isClientComponent = content.includes("'use client'") || content.includes('"use client"');
|
|
274
|
+
|
|
275
|
+
// Find interactive elements with proper brace handling
|
|
276
|
+
for (const element of INTERACTIVE_ELEMENTS) {
|
|
277
|
+
const matches = findElementMatches(content, element);
|
|
278
|
+
|
|
279
|
+
for (const match of matches) {
|
|
280
|
+
const attributes = match.attributes;
|
|
281
|
+
const matchIndex = match.index;
|
|
282
|
+
|
|
283
|
+
// Skip if already has data-lux
|
|
284
|
+
if (/data-lux/.test(attributes)) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Skip hidden inputs
|
|
289
|
+
if (element === 'input' && /type=["']hidden["']/.test(attributes)) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Skip if has spread props (can't determine)
|
|
294
|
+
if (/\{\s*\.\.\.\w+/.test(attributes)) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Skip if inside .map() callback
|
|
299
|
+
if (isInsideMapCallback(content, matchIndex)) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const info = extractElementInfo(content, match.fullMatch, matchIndex);
|
|
304
|
+
const suggestedName = suggestName(element, info);
|
|
305
|
+
|
|
306
|
+
issues.push({
|
|
307
|
+
file: relativePath,
|
|
308
|
+
line: info.lineNumber,
|
|
309
|
+
element,
|
|
310
|
+
rawElement: info.rawElement,
|
|
311
|
+
suggestedName,
|
|
312
|
+
textContent: info.textContent,
|
|
313
|
+
placeholder: info.placeholder,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Also check for any element with interactive handlers
|
|
319
|
+
for (const handler of INTERACTIVE_HANDLERS) {
|
|
320
|
+
const matches = findElementsWithHandler(content, handler);
|
|
321
|
+
|
|
322
|
+
for (const match of matches) {
|
|
323
|
+
const element = match.element;
|
|
324
|
+
const attributes = match.attributes;
|
|
325
|
+
const matchIndex = match.index;
|
|
326
|
+
|
|
327
|
+
// Skip if it's already an interactive element we checked
|
|
328
|
+
if (INTERACTIVE_ELEMENTS.includes(element)) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Skip if already has data-lux
|
|
333
|
+
if (/data-lux/.test(attributes)) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Skip if has spread props
|
|
338
|
+
if (/\{\s*\.\.\.\w+/.test(attributes)) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Skip if inside .map() callback
|
|
343
|
+
if (isInsideMapCallback(content, matchIndex)) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const info = extractElementInfo(content, match.fullMatch, matchIndex);
|
|
348
|
+
const suggestedName = suggestName(element, info);
|
|
349
|
+
|
|
350
|
+
// Avoid duplicates
|
|
351
|
+
const isDuplicate = issues.some(
|
|
352
|
+
(issue) => issue.file === relativePath && issue.line === info.lineNumber
|
|
353
|
+
);
|
|
354
|
+
if (isDuplicate) continue;
|
|
355
|
+
|
|
356
|
+
issues.push({
|
|
357
|
+
file: relativePath,
|
|
358
|
+
line: info.lineNumber,
|
|
359
|
+
element,
|
|
360
|
+
rawElement: info.rawElement,
|
|
361
|
+
suggestedName,
|
|
362
|
+
handler,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return issues;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Validate an interface
|
|
372
|
+
*/
|
|
373
|
+
function validateInterface(interfaceId, interfacesDir) {
|
|
374
|
+
const interfaceDir = path.join(interfacesDir, interfaceId);
|
|
375
|
+
const repoDir = path.join(interfaceDir, 'repo');
|
|
376
|
+
|
|
377
|
+
if (!fs.existsSync(repoDir)) {
|
|
378
|
+
return { interfaceId, error: 'Interface repo not found', issues: [] };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Find all component files
|
|
382
|
+
const componentFiles = findComponentFiles(repoDir);
|
|
383
|
+
const allIssues = [];
|
|
384
|
+
|
|
385
|
+
for (const file of componentFiles) {
|
|
386
|
+
const issues = validateFile(file, repoDir);
|
|
387
|
+
allIssues.push(...issues);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { interfaceId, issues: allIssues };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* List all interfaces in the project
|
|
395
|
+
*/
|
|
396
|
+
function listInterfaces(interfacesDir) {
|
|
397
|
+
if (!fs.existsSync(interfacesDir)) return [];
|
|
398
|
+
|
|
399
|
+
return fs.readdirSync(interfacesDir, { withFileTypes: true })
|
|
400
|
+
.filter((d) => d.isDirectory())
|
|
401
|
+
.map((d) => d.name);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Format and print results
|
|
406
|
+
*/
|
|
407
|
+
function printResults(results, outputJson = false) {
|
|
408
|
+
if (outputJson) {
|
|
409
|
+
console.log(JSON.stringify(results, null, 2));
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let totalIssues = 0;
|
|
414
|
+
|
|
415
|
+
for (const result of results) {
|
|
416
|
+
if (result.error) {
|
|
417
|
+
warn(`${result.interfaceId}: ${result.error}`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (result.issues.length === 0) continue;
|
|
422
|
+
|
|
423
|
+
totalIssues += result.issues.length;
|
|
424
|
+
|
|
425
|
+
console.log('');
|
|
426
|
+
console.log(chalk.bold(`Interface: ${result.interfaceId}`));
|
|
427
|
+
console.log(chalk.gray('─'.repeat(50)));
|
|
428
|
+
|
|
429
|
+
for (const issue of result.issues) {
|
|
430
|
+
console.log('');
|
|
431
|
+
console.log(chalk.yellow(` ${issue.file}:${issue.line}`));
|
|
432
|
+
console.log(chalk.gray(` ${issue.rawElement}`));
|
|
433
|
+
console.log(chalk.cyan(` Suggested: data-lux={lux('${issue.suggestedName}')}`));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
console.log('');
|
|
438
|
+
|
|
439
|
+
if (totalIssues === 0) {
|
|
440
|
+
success('All interactive elements have data-lux attributes!');
|
|
441
|
+
return { valid: true, totalIssues: 0 };
|
|
442
|
+
} else {
|
|
443
|
+
console.log(chalk.red(`\n❌ ${totalIssues} element(s) missing data-lux attributes`));
|
|
444
|
+
console.log('');
|
|
445
|
+
console.log(chalk.gray('To fix, add data-lux attributes using the createLux helper:'));
|
|
446
|
+
console.log(chalk.gray(" 1. import { createLux } from '@/lib/lux';"));
|
|
447
|
+
console.log(chalk.gray(" 2. const lux = createLux('ComponentName');"));
|
|
448
|
+
console.log(chalk.gray(" 3. <button data-lux={lux('button-name')}>..."));
|
|
449
|
+
console.log('');
|
|
450
|
+
return { valid: false, totalIssues };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Main handler for validate-data-lux command
|
|
456
|
+
*/
|
|
457
|
+
async function handleValidateDataLux(args) {
|
|
458
|
+
const interfaceId = args[0];
|
|
459
|
+
const outputJson = args.includes('--json');
|
|
460
|
+
|
|
461
|
+
// Get interfaces directory
|
|
462
|
+
const interfacesDir = getInterfacesDir();
|
|
463
|
+
if (!interfacesDir) {
|
|
464
|
+
error('Could not determine interfaces directory. Are you logged in?');
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!fs.existsSync(interfacesDir)) {
|
|
469
|
+
error(`Interfaces directory not found: ${interfacesDir}`);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let results = [];
|
|
474
|
+
|
|
475
|
+
if (interfaceId && interfaceId !== '--json') {
|
|
476
|
+
// Validate specific interface
|
|
477
|
+
results.push(validateInterface(interfaceId, interfacesDir));
|
|
478
|
+
} else {
|
|
479
|
+
// Validate all interfaces
|
|
480
|
+
const interfaces = listInterfaces(interfacesDir);
|
|
481
|
+
|
|
482
|
+
if (interfaces.length === 0) {
|
|
483
|
+
info('No interfaces found in this project.');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
console.log(chalk.gray(`Validating ${interfaces.length} interface(s)...`));
|
|
488
|
+
|
|
489
|
+
for (const iface of interfaces) {
|
|
490
|
+
results.push(validateInterface(iface, interfacesDir));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const summary = printResults(results, outputJson);
|
|
495
|
+
|
|
496
|
+
// Exit with error code if issues found (useful for CI)
|
|
497
|
+
if (summary && !summary.valid) {
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
module.exports = { handleValidateDataLux };
|