zedx 0.3.1 → 0.4.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/README.md +7 -1
- package/dist/add.d.ts +2 -0
- package/dist/add.js +137 -0
- package/dist/check.d.ts +1 -0
- package/dist/check.js +243 -0
- package/dist/dev.d.ts +1 -0
- package/dist/dev.js +243 -0
- package/dist/index.js +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,8 +19,14 @@ brew install tahayvr/tap/zedx
|
|
|
19
19
|
# Create a new extension
|
|
20
20
|
zedx
|
|
21
21
|
|
|
22
|
+
# Add a theme or language to an existing extension
|
|
23
|
+
zedx add theme "Midnight Blue"
|
|
24
|
+
zedx add language rust
|
|
25
|
+
|
|
26
|
+
# Validate extension config and show what is missing or incomplete
|
|
27
|
+
zedx check
|
|
28
|
+
|
|
22
29
|
# Bump extension version
|
|
23
|
-
# Run inside extension dir
|
|
24
30
|
zedx version patch # 1.2.3 → 1.2.4
|
|
25
31
|
zedx version minor # 1.2.3 → 1.3.0
|
|
26
32
|
zedx version major # 1.2.3 → 2.0.0
|
package/dist/add.d.ts
ADDED
package/dist/add.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import ejs from 'ejs';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import * as p from '@clack/prompts';
|
|
6
|
+
import color from 'picocolors';
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const isDev = __dirname.includes('/src/');
|
|
10
|
+
const PROJECT_ROOT = isDev ? path.join(__dirname, '..') : __dirname;
|
|
11
|
+
const TEMPLATE_DIR = path.join(PROJECT_ROOT, 'templates');
|
|
12
|
+
async function renderTemplate(templatePath, data) {
|
|
13
|
+
const template = await fs.readFile(templatePath, 'utf-8');
|
|
14
|
+
return ejs.render(template, data);
|
|
15
|
+
}
|
|
16
|
+
function tomlGet(content, key) {
|
|
17
|
+
const match = content.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'));
|
|
18
|
+
return match?.[1];
|
|
19
|
+
}
|
|
20
|
+
function slugify(name) {
|
|
21
|
+
return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
22
|
+
}
|
|
23
|
+
export async function addTheme(callerDir, themeName) {
|
|
24
|
+
p.intro(`${color.bgBlue(color.bold(' zedx add theme '))} ${color.blue('Adding a theme to your extension…')}`);
|
|
25
|
+
const tomlPath = path.join(callerDir, 'extension.toml');
|
|
26
|
+
if (!(await fs.pathExists(tomlPath))) {
|
|
27
|
+
p.log.error(color.red('No extension.toml found. Run zedx from an extension directory.'));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const tomlContent = await fs.readFile(tomlPath, 'utf-8');
|
|
31
|
+
const extensionId = tomlGet(tomlContent, 'id') ?? 'extension';
|
|
32
|
+
const author = tomlGet(tomlContent, 'authors')
|
|
33
|
+
?? tomlContent.match(/^authors\s*=\s*\["([^"]+)"\]/m)?.[1]
|
|
34
|
+
?? '';
|
|
35
|
+
const appearance = await p.select({
|
|
36
|
+
message: 'Appearance:',
|
|
37
|
+
options: [
|
|
38
|
+
{ value: 'dark', label: 'Dark' },
|
|
39
|
+
{ value: 'light', label: 'Light' },
|
|
40
|
+
{ value: 'both', label: 'Both (Dark & Light)' },
|
|
41
|
+
],
|
|
42
|
+
initialValue: 'dark',
|
|
43
|
+
});
|
|
44
|
+
if (p.isCancel(appearance)) {
|
|
45
|
+
p.cancel('Cancelled.');
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
const appearances = appearance === 'both' ? ['dark', 'light'] : [appearance];
|
|
49
|
+
const themeSlug = slugify(themeName);
|
|
50
|
+
const themeFile = `${themeSlug}.json`;
|
|
51
|
+
const themesDir = path.join(callerDir, 'themes');
|
|
52
|
+
const themePath = path.join(themesDir, themeFile);
|
|
53
|
+
if (await fs.pathExists(themePath)) {
|
|
54
|
+
p.log.error(color.red(`themes/${themeFile} already exists.`));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
await fs.ensureDir(themesDir);
|
|
58
|
+
const themeJson = await renderTemplate(path.join(TEMPLATE_DIR, 'theme/theme.json.ejs'), { id: extensionId, author, themeName, appearances });
|
|
59
|
+
await fs.writeFile(themePath, themeJson);
|
|
60
|
+
p.log.success(`Created ${color.cyan(`themes/${themeFile}`)}`);
|
|
61
|
+
p.outro(`${color.green('✓')} Theme added.\n` +
|
|
62
|
+
`${color.dim('Run')} ${color.cyan('zedx check')} ${color.dim('to validate your extension.')}`);
|
|
63
|
+
}
|
|
64
|
+
export async function addLanguage(callerDir, languageId) {
|
|
65
|
+
p.intro(`${color.bgBlue(color.bold(' zedx add language '))} ${color.blue('Adding a language to your extension…')}`);
|
|
66
|
+
const tomlPath = path.join(callerDir, 'extension.toml');
|
|
67
|
+
if (!(await fs.pathExists(tomlPath))) {
|
|
68
|
+
p.log.error(color.red('No extension.toml found. Run zedx from an extension directory.'));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
const tomlContent = await fs.readFile(tomlPath, 'utf-8');
|
|
72
|
+
// Check for duplicate
|
|
73
|
+
const alreadyExists = new RegExp(`^\\[grammars\\.${languageId}\\]`, 'm').test(tomlContent)
|
|
74
|
+
|| new RegExp(`^#\\s*\\[grammars\\.${languageId}\\]`, 'm').test(tomlContent);
|
|
75
|
+
if (alreadyExists) {
|
|
76
|
+
p.log.error(color.red(`Language "${languageId}" already exists in extension.toml.`));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const languageName = await p.text({
|
|
80
|
+
message: 'Language display name:',
|
|
81
|
+
placeholder: languageId,
|
|
82
|
+
});
|
|
83
|
+
if (p.isCancel(languageName)) {
|
|
84
|
+
p.cancel('Cancelled.');
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
const languageNameValue = String(languageName || languageId);
|
|
88
|
+
const langDir = path.join(callerDir, 'languages', languageId);
|
|
89
|
+
if (await fs.pathExists(langDir)) {
|
|
90
|
+
p.log.error(color.red(`languages/${languageId}/ already exists.`));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
await fs.ensureDir(langDir);
|
|
94
|
+
// Write config.toml
|
|
95
|
+
const data = {
|
|
96
|
+
languageName: languageNameValue,
|
|
97
|
+
languageId,
|
|
98
|
+
pathSuffixes: [],
|
|
99
|
+
lineComments: ['//', '#'],
|
|
100
|
+
grammarRepo: '',
|
|
101
|
+
grammarRev: '',
|
|
102
|
+
};
|
|
103
|
+
const configToml = await renderTemplate(path.join(TEMPLATE_DIR, 'language/config.toml.ejs'), data);
|
|
104
|
+
await fs.writeFile(path.join(langDir, 'config.toml'), configToml);
|
|
105
|
+
p.log.success(`Created ${color.cyan(`languages/${languageId}/config.toml`)}`);
|
|
106
|
+
// Write .scm query files
|
|
107
|
+
const queryFiles = [
|
|
108
|
+
'highlights.scm',
|
|
109
|
+
'brackets.scm',
|
|
110
|
+
'outline.scm',
|
|
111
|
+
'indents.scm',
|
|
112
|
+
'injections.scm',
|
|
113
|
+
'overrides.scm',
|
|
114
|
+
'textobjects.scm',
|
|
115
|
+
'redactions.scm',
|
|
116
|
+
'runnables.scm',
|
|
117
|
+
];
|
|
118
|
+
for (const file of queryFiles) {
|
|
119
|
+
const templatePath = path.join(TEMPLATE_DIR, 'language', file);
|
|
120
|
+
if (await fs.pathExists(templatePath)) {
|
|
121
|
+
const content = ejs.render(await fs.readFile(templatePath, 'utf-8'), data);
|
|
122
|
+
await fs.writeFile(path.join(langDir, file), content);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
p.log.success(`Created ${color.cyan(`languages/${languageId}/`)} query files`);
|
|
126
|
+
// Append grammar block to extension.toml
|
|
127
|
+
const grammarBlock = `\n# [grammars.${languageId}]\n` +
|
|
128
|
+
`# repository = "https://github.com/user/tree-sitter-${languageId}"\n` +
|
|
129
|
+
`# rev = "main"\n` +
|
|
130
|
+
`\n# [language_servers.${languageId}-lsp]\n` +
|
|
131
|
+
`# name = "${languageNameValue} LSP"\n` +
|
|
132
|
+
`# languages = ["${languageNameValue}"]\n`;
|
|
133
|
+
await fs.appendFile(tomlPath, grammarBlock);
|
|
134
|
+
p.log.success(`Updated ${color.cyan('extension.toml')} with grammar block`);
|
|
135
|
+
p.outro(`${color.green('✓')} Language added.\n` +
|
|
136
|
+
`${color.dim('Run')} ${color.cyan('zedx check')} ${color.dim('to validate your extension.')}`);
|
|
137
|
+
}
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runCheck(callerDir: string): Promise<void>;
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import color from 'picocolors';
|
|
5
|
+
// Minimal TOML key extraction — handles `key = "value"` and `key = ["a", "b"]`
|
|
6
|
+
function tomlGet(content, key) {
|
|
7
|
+
const match = content.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'));
|
|
8
|
+
return match?.[1];
|
|
9
|
+
}
|
|
10
|
+
function tomlHasUncommentedKey(content, key) {
|
|
11
|
+
return new RegExp(`^${key}\\s*=`, 'm').test(content);
|
|
12
|
+
}
|
|
13
|
+
function tomlHasSection(content, section) {
|
|
14
|
+
// Looks for an uncommented [section] or [section.something] header
|
|
15
|
+
return new RegExp(`^\\[${section.replace('.', '\\.')}`, 'm').test(content);
|
|
16
|
+
}
|
|
17
|
+
export async function runCheck(callerDir) {
|
|
18
|
+
p.intro(`${color.bgBlue(color.bold(' zedx check '))} ${color.blue('Validating extension config…')}`);
|
|
19
|
+
const tomlPath = path.join(callerDir, 'extension.toml');
|
|
20
|
+
if (!(await fs.pathExists(tomlPath))) {
|
|
21
|
+
p.log.error(color.red('No extension.toml found in current directory.'));
|
|
22
|
+
p.log.info(`Run ${color.cyan('zedx')} to scaffold a new extension first.`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const tomlContent = await fs.readFile(tomlPath, 'utf-8');
|
|
26
|
+
const extensionId = tomlGet(tomlContent, 'id');
|
|
27
|
+
const extensionName = tomlGet(tomlContent, 'name');
|
|
28
|
+
const repository = tomlGet(tomlContent, 'repository');
|
|
29
|
+
const results = [];
|
|
30
|
+
// ── extension.toml ────────────────────────────────────────────────────────
|
|
31
|
+
const extIssues = [];
|
|
32
|
+
if (!extensionId) {
|
|
33
|
+
extIssues.push({
|
|
34
|
+
file: 'extension.toml',
|
|
35
|
+
message: 'Missing required field: id',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (!extensionName) {
|
|
39
|
+
extIssues.push({
|
|
40
|
+
file: 'extension.toml',
|
|
41
|
+
message: 'Missing required field: name',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (!repository || repository.includes('username')) {
|
|
45
|
+
extIssues.push({
|
|
46
|
+
file: 'extension.toml',
|
|
47
|
+
message: 'repository still uses the default placeholder URL',
|
|
48
|
+
hint: 'Set it to your actual GitHub repository URL',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// Detect language entries by looking for uncommented [grammars.*] sections
|
|
52
|
+
const grammarMatches = [...tomlContent.matchAll(/^\[grammars\.(\S+)\]/gm)];
|
|
53
|
+
const commentedGrammarMatches = [...tomlContent.matchAll(/^#\s*\[grammars\.(\S+)\]/gm)];
|
|
54
|
+
const languageIds = grammarMatches.map(m => m[1]);
|
|
55
|
+
const hasLanguage = languageIds.length > 0 || commentedGrammarMatches.length > 0;
|
|
56
|
+
if (commentedGrammarMatches.length > 0 && grammarMatches.length === 0) {
|
|
57
|
+
const ids = commentedGrammarMatches.map(m => m[1]);
|
|
58
|
+
extIssues.push({
|
|
59
|
+
file: 'extension.toml',
|
|
60
|
+
message: `Grammar section is commented out for: ${ids.join(', ')}`,
|
|
61
|
+
hint: 'Uncomment [grammars.<id>] and set a real tree-sitter repository URL and rev',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// Detect theme entries by looking for themes/ directory
|
|
65
|
+
const themesDir = path.join(callerDir, 'themes');
|
|
66
|
+
const hasTheme = await fs.pathExists(themesDir);
|
|
67
|
+
results.push({ file: 'extension.toml', issues: extIssues });
|
|
68
|
+
// ── theme validation ──────────────────────────────────────────────────────
|
|
69
|
+
if (hasTheme) {
|
|
70
|
+
const themeIssues = [];
|
|
71
|
+
const themeFiles = (await fs.readdir(themesDir)).filter(f => f.endsWith('.json'));
|
|
72
|
+
if (themeFiles.length === 0) {
|
|
73
|
+
themeIssues.push({
|
|
74
|
+
file: 'themes/',
|
|
75
|
+
message: 'No .json theme files found in themes/ directory',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
for (const themeFile of themeFiles) {
|
|
79
|
+
const themePath = path.join(themesDir, themeFile);
|
|
80
|
+
const themeIssuesForFile = [];
|
|
81
|
+
let themeJson;
|
|
82
|
+
try {
|
|
83
|
+
themeJson = await fs.readJson(themePath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
themeIssuesForFile.push({
|
|
87
|
+
file: `themes/${themeFile}`,
|
|
88
|
+
message: 'Invalid JSON — file could not be parsed',
|
|
89
|
+
});
|
|
90
|
+
results.push({ file: `themes/${themeFile}`, issues: themeIssuesForFile });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const themes = themeJson['themes'];
|
|
94
|
+
if (!themes || themes.length === 0) {
|
|
95
|
+
themeIssuesForFile.push({
|
|
96
|
+
file: `themes/${themeFile}`,
|
|
97
|
+
message: 'No theme variants found under the "themes" key',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (const variant of themes) {
|
|
102
|
+
const variantName = String(variant['name'] ?? 'unknown');
|
|
103
|
+
const style = variant['style'];
|
|
104
|
+
if (!style) {
|
|
105
|
+
themeIssuesForFile.push({
|
|
106
|
+
file: `themes/${themeFile}`,
|
|
107
|
+
message: `Variant "${variantName}": missing "style" block`,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// Check for placeholder-like neutral grays that indicate untouched scaffold
|
|
112
|
+
const background = style['background'];
|
|
113
|
+
const placeholderBgs = ['#1e1e1e', '#f5f5f5', '#ffffff', '#000000'];
|
|
114
|
+
if (background && placeholderBgs.includes(background.toLowerCase())) {
|
|
115
|
+
themeIssuesForFile.push({
|
|
116
|
+
file: `themes/${themeFile}`,
|
|
117
|
+
message: `Variant "${variantName}": background color is still the scaffold placeholder (${background})`,
|
|
118
|
+
hint: 'Replace with your actual theme colors',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// Check that syntax block is populated
|
|
122
|
+
const syntax = style['syntax'];
|
|
123
|
+
if (!syntax || Object.keys(syntax).length === 0) {
|
|
124
|
+
themeIssuesForFile.push({
|
|
125
|
+
file: `themes/${themeFile}`,
|
|
126
|
+
message: `Variant "${variantName}": "syntax" block is empty or missing`,
|
|
127
|
+
hint: 'Add syntax token color definitions',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
themeIssues.push(...themeIssuesForFile);
|
|
133
|
+
}
|
|
134
|
+
results.push({ file: 'themes/', issues: themeIssues });
|
|
135
|
+
}
|
|
136
|
+
// ── language validation ───────────────────────────────────────────────────
|
|
137
|
+
if (hasLanguage) {
|
|
138
|
+
// Collect all language IDs from both uncommented and commented grammar sections
|
|
139
|
+
const allLanguageIds = [
|
|
140
|
+
...grammarMatches.map(m => m[1]),
|
|
141
|
+
...commentedGrammarMatches.map(m => m[1]),
|
|
142
|
+
];
|
|
143
|
+
for (const langId of allLanguageIds) {
|
|
144
|
+
const langDir = path.join(callerDir, 'languages', langId);
|
|
145
|
+
const langIssues = [];
|
|
146
|
+
if (!(await fs.pathExists(langDir))) {
|
|
147
|
+
langIssues.push({
|
|
148
|
+
file: `languages/${langId}/`,
|
|
149
|
+
message: `Language directory does not exist`,
|
|
150
|
+
hint: `Expected at ${path.join('languages', langId)}`,
|
|
151
|
+
});
|
|
152
|
+
results.push({ file: `languages/${langId}/`, issues: langIssues });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// config.toml checks
|
|
156
|
+
const configPath = path.join(langDir, 'config.toml');
|
|
157
|
+
const configIssues = [];
|
|
158
|
+
if (!(await fs.pathExists(configPath))) {
|
|
159
|
+
configIssues.push({
|
|
160
|
+
file: `languages/${langId}/config.toml`,
|
|
161
|
+
message: 'config.toml is missing',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const configContent = await fs.readFile(configPath, 'utf-8');
|
|
166
|
+
if (!tomlHasUncommentedKey(configContent, 'name')) {
|
|
167
|
+
configIssues.push({
|
|
168
|
+
file: `languages/${langId}/config.toml`,
|
|
169
|
+
message: 'Missing required field: name',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (!tomlHasUncommentedKey(configContent, 'grammar')) {
|
|
173
|
+
configIssues.push({
|
|
174
|
+
file: `languages/${langId}/config.toml`,
|
|
175
|
+
message: 'Missing required field: grammar',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// path_suffixes is commented out in scaffold — flag it
|
|
179
|
+
if (!tomlHasUncommentedKey(configContent, 'path_suffixes')) {
|
|
180
|
+
configIssues.push({
|
|
181
|
+
file: `languages/${langId}/config.toml`,
|
|
182
|
+
message: 'path_suffixes is not set — files won\'t be associated with this language',
|
|
183
|
+
hint: 'Uncomment and fill in path_suffixes (e.g., ["myl"])',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// line_comments is commented out in scaffold — flag it
|
|
187
|
+
if (!tomlHasUncommentedKey(configContent, 'line_comments')) {
|
|
188
|
+
configIssues.push({
|
|
189
|
+
file: `languages/${langId}/config.toml`,
|
|
190
|
+
message: 'line_comments is not set — toggle-comment keybind won\'t work',
|
|
191
|
+
hint: 'Uncomment and set line_comments (e.g., ["// "])',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
results.push({ file: `languages/${langId}/config.toml`, issues: configIssues });
|
|
196
|
+
// highlights.scm checks
|
|
197
|
+
const highlightsPath = path.join(langDir, 'highlights.scm');
|
|
198
|
+
const highlightIssues = [];
|
|
199
|
+
if (!(await fs.pathExists(highlightsPath))) {
|
|
200
|
+
highlightIssues.push({
|
|
201
|
+
file: `languages/${langId}/highlights.scm`,
|
|
202
|
+
message: 'highlights.scm is missing',
|
|
203
|
+
hint: 'Without it, no syntax highlighting will appear',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const highlightsContent = await fs.readFile(highlightsPath, 'utf-8');
|
|
208
|
+
// Count non-comment, non-empty lines with actual query patterns
|
|
209
|
+
const activeLines = highlightsContent
|
|
210
|
+
.split('\n')
|
|
211
|
+
.filter(l => l.trim() && !l.trim().startsWith(';'));
|
|
212
|
+
if (activeLines.length <= 3) {
|
|
213
|
+
highlightIssues.push({
|
|
214
|
+
file: `languages/${langId}/highlights.scm`,
|
|
215
|
+
message: 'Only scaffold starter patterns present — no real grammar queries added yet',
|
|
216
|
+
hint: 'Add tree-sitter queries matching your language\'s grammar node types',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
results.push({ file: `languages/${langId}/highlights.scm`, issues: highlightIssues });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ── render results ────────────────────────────────────────────────────────
|
|
224
|
+
const allIssues = results.flatMap(r => r.issues);
|
|
225
|
+
const fileGroups = results.filter(r => r.issues.length > 0);
|
|
226
|
+
if (fileGroups.length === 0) {
|
|
227
|
+
p.log.success(color.green('No issues found. Your extension config looks good!'));
|
|
228
|
+
p.outro(`${color.dim('Load it in Zed:')} Extensions ${color.dim('>')} Install Dev Extension`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const group of fileGroups) {
|
|
232
|
+
p.log.warn(`${color.yellow(color.bold(group.issues[0].file))}`);
|
|
233
|
+
for (const issue of group.issues) {
|
|
234
|
+
process.stdout.write(` ${color.red('✗')} ${issue.message}\n`);
|
|
235
|
+
if (issue.hint) {
|
|
236
|
+
process.stdout.write(` ${color.dim('→')} ${color.dim(issue.hint)}\n`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
process.stdout.write('\n');
|
|
240
|
+
}
|
|
241
|
+
const issueCount = allIssues.length;
|
|
242
|
+
p.outro(`${color.red(`${issueCount} issue${issueCount === 1 ? '' : 's'} found`)} — fix the above before publishing`);
|
|
243
|
+
}
|
package/dist/dev.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runCheck(callerDir: string): Promise<void>;
|
package/dist/dev.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import color from 'picocolors';
|
|
5
|
+
// Minimal TOML key extraction — handles `key = "value"` and `key = ["a", "b"]`
|
|
6
|
+
function tomlGet(content, key) {
|
|
7
|
+
const match = content.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'));
|
|
8
|
+
return match?.[1];
|
|
9
|
+
}
|
|
10
|
+
function tomlHasUncommentedKey(content, key) {
|
|
11
|
+
return new RegExp(`^${key}\\s*=`, 'm').test(content);
|
|
12
|
+
}
|
|
13
|
+
function tomlHasSection(content, section) {
|
|
14
|
+
// Looks for an uncommented [section] or [section.something] header
|
|
15
|
+
return new RegExp(`^\\[${section.replace('.', '\\.')}`, 'm').test(content);
|
|
16
|
+
}
|
|
17
|
+
export async function runCheck(callerDir) {
|
|
18
|
+
p.intro(`${color.bgBlue(color.bold(' zedx check '))} ${color.blue('Validating extension config…')}`);
|
|
19
|
+
const tomlPath = path.join(callerDir, 'extension.toml');
|
|
20
|
+
if (!(await fs.pathExists(tomlPath))) {
|
|
21
|
+
p.log.error(color.red('No extension.toml found in current directory.'));
|
|
22
|
+
p.log.info(`Run ${color.cyan('zedx')} to scaffold a new extension first.`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const tomlContent = await fs.readFile(tomlPath, 'utf-8');
|
|
26
|
+
const extensionId = tomlGet(tomlContent, 'id');
|
|
27
|
+
const extensionName = tomlGet(tomlContent, 'name');
|
|
28
|
+
const repository = tomlGet(tomlContent, 'repository');
|
|
29
|
+
const results = [];
|
|
30
|
+
// ── extension.toml ────────────────────────────────────────────────────────
|
|
31
|
+
const extIssues = [];
|
|
32
|
+
if (!extensionId) {
|
|
33
|
+
extIssues.push({
|
|
34
|
+
file: 'extension.toml',
|
|
35
|
+
message: 'Missing required field: id',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (!extensionName) {
|
|
39
|
+
extIssues.push({
|
|
40
|
+
file: 'extension.toml',
|
|
41
|
+
message: 'Missing required field: name',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (!repository || repository.includes('username')) {
|
|
45
|
+
extIssues.push({
|
|
46
|
+
file: 'extension.toml',
|
|
47
|
+
message: 'repository still uses the default placeholder URL',
|
|
48
|
+
hint: 'Set it to your actual GitHub repository URL',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// Detect language entries by looking for uncommented [grammars.*] sections
|
|
52
|
+
const grammarMatches = [...tomlContent.matchAll(/^\[grammars\.(\S+)\]/gm)];
|
|
53
|
+
const commentedGrammarMatches = [...tomlContent.matchAll(/^#\s*\[grammars\.(\S+)\]/gm)];
|
|
54
|
+
const languageIds = grammarMatches.map(m => m[1]);
|
|
55
|
+
const hasLanguage = languageIds.length > 0 || commentedGrammarMatches.length > 0;
|
|
56
|
+
if (commentedGrammarMatches.length > 0 && grammarMatches.length === 0) {
|
|
57
|
+
const ids = commentedGrammarMatches.map(m => m[1]);
|
|
58
|
+
extIssues.push({
|
|
59
|
+
file: 'extension.toml',
|
|
60
|
+
message: `Grammar section is commented out for: ${ids.join(', ')}`,
|
|
61
|
+
hint: 'Uncomment [grammars.<id>] and set a real tree-sitter repository URL and rev',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// Detect theme entries by looking for themes/ directory
|
|
65
|
+
const themesDir = path.join(callerDir, 'themes');
|
|
66
|
+
const hasTheme = await fs.pathExists(themesDir);
|
|
67
|
+
results.push({ file: 'extension.toml', issues: extIssues });
|
|
68
|
+
// ── theme validation ──────────────────────────────────────────────────────
|
|
69
|
+
if (hasTheme) {
|
|
70
|
+
const themeIssues = [];
|
|
71
|
+
const themeFiles = (await fs.readdir(themesDir)).filter(f => f.endsWith('.json'));
|
|
72
|
+
if (themeFiles.length === 0) {
|
|
73
|
+
themeIssues.push({
|
|
74
|
+
file: 'themes/',
|
|
75
|
+
message: 'No .json theme files found in themes/ directory',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
for (const themeFile of themeFiles) {
|
|
79
|
+
const themePath = path.join(themesDir, themeFile);
|
|
80
|
+
const themeIssuesForFile = [];
|
|
81
|
+
let themeJson;
|
|
82
|
+
try {
|
|
83
|
+
themeJson = await fs.readJson(themePath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
themeIssuesForFile.push({
|
|
87
|
+
file: `themes/${themeFile}`,
|
|
88
|
+
message: 'Invalid JSON — file could not be parsed',
|
|
89
|
+
});
|
|
90
|
+
results.push({ file: `themes/${themeFile}`, issues: themeIssuesForFile });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const themes = themeJson['themes'];
|
|
94
|
+
if (!themes || themes.length === 0) {
|
|
95
|
+
themeIssuesForFile.push({
|
|
96
|
+
file: `themes/${themeFile}`,
|
|
97
|
+
message: 'No theme variants found under the "themes" key',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (const variant of themes) {
|
|
102
|
+
const variantName = String(variant['name'] ?? 'unknown');
|
|
103
|
+
const style = variant['style'];
|
|
104
|
+
if (!style) {
|
|
105
|
+
themeIssuesForFile.push({
|
|
106
|
+
file: `themes/${themeFile}`,
|
|
107
|
+
message: `Variant "${variantName}": missing "style" block`,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// Check for placeholder-like neutral grays that indicate untouched scaffold
|
|
112
|
+
const background = style['background'];
|
|
113
|
+
const placeholderBgs = ['#1e1e1e', '#f5f5f5', '#ffffff', '#000000'];
|
|
114
|
+
if (background && placeholderBgs.includes(background.toLowerCase())) {
|
|
115
|
+
themeIssuesForFile.push({
|
|
116
|
+
file: `themes/${themeFile}`,
|
|
117
|
+
message: `Variant "${variantName}": background color is still the scaffold placeholder (${background})`,
|
|
118
|
+
hint: 'Replace with your actual theme colors',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// Check that syntax block is populated
|
|
122
|
+
const syntax = style['syntax'];
|
|
123
|
+
if (!syntax || Object.keys(syntax).length === 0) {
|
|
124
|
+
themeIssuesForFile.push({
|
|
125
|
+
file: `themes/${themeFile}`,
|
|
126
|
+
message: `Variant "${variantName}": "syntax" block is empty or missing`,
|
|
127
|
+
hint: 'Add syntax token color definitions',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
themeIssues.push(...themeIssuesForFile);
|
|
133
|
+
}
|
|
134
|
+
results.push({ file: 'themes/', issues: themeIssues });
|
|
135
|
+
}
|
|
136
|
+
// ── language validation ───────────────────────────────────────────────────
|
|
137
|
+
if (hasLanguage) {
|
|
138
|
+
// Collect all language IDs from both uncommented and commented grammar sections
|
|
139
|
+
const allLanguageIds = [
|
|
140
|
+
...grammarMatches.map(m => m[1]),
|
|
141
|
+
...commentedGrammarMatches.map(m => m[1]),
|
|
142
|
+
];
|
|
143
|
+
for (const langId of allLanguageIds) {
|
|
144
|
+
const langDir = path.join(callerDir, 'languages', langId);
|
|
145
|
+
const langIssues = [];
|
|
146
|
+
if (!(await fs.pathExists(langDir))) {
|
|
147
|
+
langIssues.push({
|
|
148
|
+
file: `languages/${langId}/`,
|
|
149
|
+
message: `Language directory does not exist`,
|
|
150
|
+
hint: `Expected at ${path.join('languages', langId)}`,
|
|
151
|
+
});
|
|
152
|
+
results.push({ file: `languages/${langId}/`, issues: langIssues });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// config.toml checks
|
|
156
|
+
const configPath = path.join(langDir, 'config.toml');
|
|
157
|
+
const configIssues = [];
|
|
158
|
+
if (!(await fs.pathExists(configPath))) {
|
|
159
|
+
configIssues.push({
|
|
160
|
+
file: `languages/${langId}/config.toml`,
|
|
161
|
+
message: 'config.toml is missing',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
const configContent = await fs.readFile(configPath, 'utf-8');
|
|
166
|
+
if (!tomlHasUncommentedKey(configContent, 'name')) {
|
|
167
|
+
configIssues.push({
|
|
168
|
+
file: `languages/${langId}/config.toml`,
|
|
169
|
+
message: 'Missing required field: name',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (!tomlHasUncommentedKey(configContent, 'grammar')) {
|
|
173
|
+
configIssues.push({
|
|
174
|
+
file: `languages/${langId}/config.toml`,
|
|
175
|
+
message: 'Missing required field: grammar',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
// path_suffixes is commented out in scaffold — flag it
|
|
179
|
+
if (!tomlHasUncommentedKey(configContent, 'path_suffixes')) {
|
|
180
|
+
configIssues.push({
|
|
181
|
+
file: `languages/${langId}/config.toml`,
|
|
182
|
+
message: 'path_suffixes is not set — files won\'t be associated with this language',
|
|
183
|
+
hint: 'Uncomment and fill in path_suffixes (e.g., ["myl"])',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// line_comments is commented out in scaffold — flag it
|
|
187
|
+
if (!tomlHasUncommentedKey(configContent, 'line_comments')) {
|
|
188
|
+
configIssues.push({
|
|
189
|
+
file: `languages/${langId}/config.toml`,
|
|
190
|
+
message: 'line_comments is not set — toggle-comment keybind won\'t work',
|
|
191
|
+
hint: 'Uncomment and set line_comments (e.g., ["// "])',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
results.push({ file: `languages/${langId}/config.toml`, issues: configIssues });
|
|
196
|
+
// highlights.scm checks
|
|
197
|
+
const highlightsPath = path.join(langDir, 'highlights.scm');
|
|
198
|
+
const highlightIssues = [];
|
|
199
|
+
if (!(await fs.pathExists(highlightsPath))) {
|
|
200
|
+
highlightIssues.push({
|
|
201
|
+
file: `languages/${langId}/highlights.scm`,
|
|
202
|
+
message: 'highlights.scm is missing',
|
|
203
|
+
hint: 'Without it, no syntax highlighting will appear',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const highlightsContent = await fs.readFile(highlightsPath, 'utf-8');
|
|
208
|
+
// Count non-comment, non-empty lines with actual query patterns
|
|
209
|
+
const activeLines = highlightsContent
|
|
210
|
+
.split('\n')
|
|
211
|
+
.filter(l => l.trim() && !l.trim().startsWith(';'));
|
|
212
|
+
if (activeLines.length <= 3) {
|
|
213
|
+
highlightIssues.push({
|
|
214
|
+
file: `languages/${langId}/highlights.scm`,
|
|
215
|
+
message: 'Only scaffold starter patterns present — no real grammar queries added yet',
|
|
216
|
+
hint: 'Add tree-sitter queries matching your language\'s grammar node types',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
results.push({ file: `languages/${langId}/highlights.scm`, issues: highlightIssues });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ── render results ────────────────────────────────────────────────────────
|
|
224
|
+
const allIssues = results.flatMap(r => r.issues);
|
|
225
|
+
const fileGroups = results.filter(r => r.issues.length > 0);
|
|
226
|
+
if (fileGroups.length === 0) {
|
|
227
|
+
p.log.success(color.green('No issues found. Your extension config looks good!'));
|
|
228
|
+
p.outro(`${color.dim('Load it in Zed:')} Extensions ${color.dim('>')} Install Dev Extension`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const group of fileGroups) {
|
|
232
|
+
p.log.warn(`${color.yellow(color.bold(group.issues[0].file))}`);
|
|
233
|
+
for (const issue of group.issues) {
|
|
234
|
+
process.stdout.write(` ${color.red('✗')} ${issue.message}\n`);
|
|
235
|
+
if (issue.hint) {
|
|
236
|
+
process.stdout.write(` ${color.dim('→')} ${color.dim(issue.hint)}\n`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
process.stdout.write('\n');
|
|
240
|
+
}
|
|
241
|
+
const issueCount = allIssues.length;
|
|
242
|
+
p.outro(`${color.red(`${issueCount} issue${issueCount === 1 ? '' : 's'} found`)} — fix the above before publishing`);
|
|
243
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { Command } from 'commander';
|
|
|
6
6
|
import fs from 'fs-extra';
|
|
7
7
|
import { promptUser, promptThemeDetails, promptLanguageDetails } from './prompts.js';
|
|
8
8
|
import { generateExtension } from './generator.js';
|
|
9
|
+
import { runCheck } from './check.js';
|
|
10
|
+
import { addTheme, addLanguage } from './add.js';
|
|
9
11
|
function bumpVersion(version, type) {
|
|
10
12
|
const [major, minor, patch] = version.split('.').map(Number);
|
|
11
13
|
switch (type) {
|
|
@@ -53,6 +55,27 @@ async function main() {
|
|
|
53
55
|
}
|
|
54
56
|
await bumpExtensionVersion(type);
|
|
55
57
|
});
|
|
58
|
+
program
|
|
59
|
+
.command('check')
|
|
60
|
+
.description('Validate extension config and show what is missing or incomplete')
|
|
61
|
+
.action(async () => {
|
|
62
|
+
await runCheck(getCallerDir());
|
|
63
|
+
});
|
|
64
|
+
const addCmd = program
|
|
65
|
+
.command('add')
|
|
66
|
+
.description('Add a theme or language to an existing extension');
|
|
67
|
+
addCmd
|
|
68
|
+
.command('theme <name>')
|
|
69
|
+
.description('Add a new theme to the extension')
|
|
70
|
+
.action(async (name) => {
|
|
71
|
+
await addTheme(getCallerDir(), name);
|
|
72
|
+
});
|
|
73
|
+
addCmd
|
|
74
|
+
.command('language <id>')
|
|
75
|
+
.description('Add a new language to the extension')
|
|
76
|
+
.action(async (id) => {
|
|
77
|
+
await addLanguage(getCallerDir(), id);
|
|
78
|
+
});
|
|
56
79
|
if (process.argv.length <= 2) {
|
|
57
80
|
const options = await promptUser();
|
|
58
81
|
if (options.types.includes('theme')) {
|