ezx 0.0.13 → 0.0.14
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/package.json +2 -1
- package/src/cli.ts +32 -0
- package/src/index.ts +217 -0
- package/src/preprocessor.ts +546 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ezx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "Enhanced zx - Write better shell scripts with markdown preprocessing features including directory context persistence, file extraction, and fixes for zx's indentation parsing issues",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"zx",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"files": [
|
|
49
49
|
"dist",
|
|
50
|
+
"src",
|
|
50
51
|
"README.md",
|
|
51
52
|
"LICENSE"
|
|
52
53
|
],
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {Command} from 'commander';
|
|
4
|
+
import {runMarkdownFile, type RunMarkdownOptions} from './index.js';
|
|
5
|
+
import pkg from '../package.json' with {type: 'json'};
|
|
6
|
+
|
|
7
|
+
const program = new Command();
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('ezx')
|
|
11
|
+
.description('Enhanced zx - A tool for writing better scripts with markdown support')
|
|
12
|
+
.version(pkg.version)
|
|
13
|
+
.argument('<script>', 'Path to markdown script')
|
|
14
|
+
.option('-v, --verbose', 'enable verbose mode')
|
|
15
|
+
.option('-q, --quiet', 'suppress any outputs')
|
|
16
|
+
.option('--shell <path>', 'custom shell binary')
|
|
17
|
+
.option('--prefix <command>', 'prefix all commands')
|
|
18
|
+
.option('--postfix <command>', 'postfix all commands')
|
|
19
|
+
.option('--cwd <path>', 'set current directory')
|
|
20
|
+
.option('-s, --sections <headings...>', 'execute only sections under these headings (e.g., "Create a new project")')
|
|
21
|
+
.option('-p, --preprocess-only [file]', 'output preprocessed markdown without executing (optionally to a file)')
|
|
22
|
+
.option('--standalone', 'create .npmrc file after setting directory for standalone npm mode in monorepos')
|
|
23
|
+
.action(async (script: string, options: RunMarkdownOptions) => {
|
|
24
|
+
try {
|
|
25
|
+
await runMarkdownFile(script, options);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('Error:', error instanceof Error ? error.message : error);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
program.parse();
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {transformMarkdown} from 'zx/cli';
|
|
2
|
+
import {$, fs, path} from 'zx';
|
|
3
|
+
import {randomBytes} from 'node:crypto';
|
|
4
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
5
|
+
import {preprocessMarkdown, sanitizeMarkdownForZx} from './preprocessor.js';
|
|
6
|
+
|
|
7
|
+
// Import zx globals to make $, path, fs, etc. available globally
|
|
8
|
+
await import('zx/globals');
|
|
9
|
+
|
|
10
|
+
export interface RunMarkdownOptions {
|
|
11
|
+
verbose?: boolean;
|
|
12
|
+
quiet?: boolean;
|
|
13
|
+
shell?: string;
|
|
14
|
+
prefix?: string;
|
|
15
|
+
postfix?: string;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
preferLocal?: boolean;
|
|
18
|
+
sections?: string[]; // Execute only sections under these headings
|
|
19
|
+
preprocessOnly?: boolean | string; // Output preprocessed markdown without executing (optionally to file path)
|
|
20
|
+
standalone?: boolean; // Create .npmrc file after setting directory for standalone npm mode in monorepos
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract sections from markdown based on heading matches
|
|
25
|
+
* Returns only the content under the specified headings
|
|
26
|
+
*/
|
|
27
|
+
function extractSectionsByHeading(markdown: string, sectionHeadings: string[]): string {
|
|
28
|
+
if (!sectionHeadings || sectionHeadings.length === 0) {
|
|
29
|
+
return markdown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const lines = markdown.split('\n');
|
|
33
|
+
const result: string[] = [];
|
|
34
|
+
let inMatchingSection = false;
|
|
35
|
+
let currentSectionLevel = 0;
|
|
36
|
+
let inCodeBlock = false;
|
|
37
|
+
|
|
38
|
+
// Normalize section headings (case-insensitive, trim whitespace)
|
|
39
|
+
const normalizedSectionHeadings = sectionHeadings.map(h => h.trim().toLowerCase());
|
|
40
|
+
const headingRegex = /^(#{1,6})\s+(.+)$/;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
const line = lines[i];
|
|
44
|
+
|
|
45
|
+
// Track code blocks
|
|
46
|
+
if (line.startsWith('```')) {
|
|
47
|
+
inCodeBlock = !inCodeBlock;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Only check for headings when not in a code block
|
|
51
|
+
const match = !inCodeBlock ? line.match(headingRegex) : null;
|
|
52
|
+
|
|
53
|
+
if (match) {
|
|
54
|
+
const level = match[1].length;
|
|
55
|
+
const headingText = match[2].trim().toLowerCase();
|
|
56
|
+
|
|
57
|
+
// Check if this heading matches one of our target headings
|
|
58
|
+
if (normalizedSectionHeadings.includes(headingText)) {
|
|
59
|
+
inMatchingSection = true;
|
|
60
|
+
currentSectionLevel = level;
|
|
61
|
+
result.push(line); // Include the heading itself
|
|
62
|
+
} else if (inMatchingSection) {
|
|
63
|
+
// If we're in a matching section and hit another heading of same or higher level
|
|
64
|
+
// (less # means higher in the hierarchy), exit the section
|
|
65
|
+
if (level <= currentSectionLevel) {
|
|
66
|
+
inMatchingSection = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} else if (inMatchingSection) {
|
|
70
|
+
result.push(line);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run markdown content or file with CLI-like options
|
|
79
|
+
*
|
|
80
|
+
* @param markdown - Markdown content or file path
|
|
81
|
+
* @param options - CLI-like options
|
|
82
|
+
*/
|
|
83
|
+
export async function runMarkdown(markdown: string | Buffer, options: RunMarkdownOptions = {}): Promise<void> {
|
|
84
|
+
// Save current state
|
|
85
|
+
const savedState = {
|
|
86
|
+
verbose: $.verbose,
|
|
87
|
+
quiet: $.quiet,
|
|
88
|
+
shell: $.shell,
|
|
89
|
+
prefix: $.prefix,
|
|
90
|
+
postfix: $.postfix,
|
|
91
|
+
cwd: $.cwd,
|
|
92
|
+
preferLocal: $.preferLocal,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// Apply options
|
|
97
|
+
if (options.verbose !== undefined) $.verbose = options.verbose;
|
|
98
|
+
if (options.quiet !== undefined) $.quiet = options.quiet;
|
|
99
|
+
if (options.shell) $.shell = options.shell;
|
|
100
|
+
if (options.prefix) $.prefix = options.prefix;
|
|
101
|
+
if (options.postfix) $.postfix = options.postfix;
|
|
102
|
+
if (options.cwd) $.cwd = options.cwd;
|
|
103
|
+
if (options.preferLocal !== undefined) $.preferLocal = options.preferLocal;
|
|
104
|
+
|
|
105
|
+
// Read markdown content if it's a file path
|
|
106
|
+
let markdownContent: string;
|
|
107
|
+
if (typeof markdown === 'string' && (markdown.endsWith('.md') || markdown.includes('\n') === false)) {
|
|
108
|
+
markdownContent = await fs.readFile(markdown, 'utf8');
|
|
109
|
+
} else {
|
|
110
|
+
markdownContent = markdown.toString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Extract sections if specified
|
|
114
|
+
if (options.sections && options.sections.length > 0) {
|
|
115
|
+
markdownContent = extractSectionsByHeading(markdownContent, options.sections);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Preprocess markdown with ezx enhancements
|
|
119
|
+
const {preprocessedMarkdown} = preprocessMarkdown(markdownContent, {
|
|
120
|
+
cwd: options.cwd || process.cwd(),
|
|
121
|
+
standalone: options.standalone,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Sanitize markdown to prevent zx from misinterpreting indented prose as code
|
|
125
|
+
// This handles edge cases like indented code blocks within list items
|
|
126
|
+
const sanitizedMarkdown = sanitizeMarkdownForZx(preprocessedMarkdown);
|
|
127
|
+
|
|
128
|
+
// If preprocessOnly is set, output the preprocessed markdown and return
|
|
129
|
+
if (options.preprocessOnly) {
|
|
130
|
+
if (typeof options.preprocessOnly === 'string') {
|
|
131
|
+
// Write to file
|
|
132
|
+
await fs.writeFile(options.preprocessOnly, sanitizedMarkdown);
|
|
133
|
+
} else {
|
|
134
|
+
// Output to stdout
|
|
135
|
+
console.log(sanitizedMarkdown);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Transform markdown to JavaScript
|
|
141
|
+
let jsCode = transformMarkdown(sanitizedMarkdown);
|
|
142
|
+
|
|
143
|
+
// Resolve the absolute path to zx/globals using Node's module resolution
|
|
144
|
+
// This will work regardless of where ezx is installed (local, global, or as a dependency)
|
|
145
|
+
const zxGlobalsPath = await import.meta.resolve('zx/globals');
|
|
146
|
+
|
|
147
|
+
// Prepend globals import to make $, fs, path, etc. available in the script context
|
|
148
|
+
// Use the resolved absolute path to ensure zx is found
|
|
149
|
+
jsCode = `await import('${zxGlobalsPath}')\n\n${jsCode}`;
|
|
150
|
+
|
|
151
|
+
// Write to temp file in current directory (not tmpdir) so zx can be resolved
|
|
152
|
+
// Node's module resolution looks for package.json starting from the file's directory,
|
|
153
|
+
// so the temp file needs to be in a location that has access to node_modules
|
|
154
|
+
const cwd = options.cwd || process.cwd();
|
|
155
|
+
const tempFile = path.join(cwd, `.ezx-${randomBytes(8).toString('hex')}.mjs`);
|
|
156
|
+
await fs.writeFile(tempFile, jsCode);
|
|
157
|
+
|
|
158
|
+
// Register cleanup handlers for various exit scenarios
|
|
159
|
+
let tempFileCleaned = false;
|
|
160
|
+
const cleanup = async () => {
|
|
161
|
+
if (tempFileCleaned) return;
|
|
162
|
+
tempFileCleaned = true;
|
|
163
|
+
try {
|
|
164
|
+
await fs.rm(tempFile, {force: true});
|
|
165
|
+
} catch {
|
|
166
|
+
// Ignore cleanup errors
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Ensure cleanup on normal exit, uncaught errors, and signals
|
|
171
|
+
process.on('exit', cleanup);
|
|
172
|
+
process.on('uncaughtException', cleanup);
|
|
173
|
+
process.on('SIGINT', cleanup);
|
|
174
|
+
process.on('SIGTERM', cleanup);
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
// Import and execute the transformed code (same as CLI)
|
|
178
|
+
await import(pathToFileURL(tempFile).toString());
|
|
179
|
+
} finally {
|
|
180
|
+
// Clean up temp file and remove event listeners
|
|
181
|
+
await cleanup();
|
|
182
|
+
process.off('exit', cleanup);
|
|
183
|
+
process.off('uncaughtException', cleanup);
|
|
184
|
+
process.off('SIGINT', cleanup);
|
|
185
|
+
process.off('SIGTERM', cleanup);
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
// Restore state
|
|
189
|
+
Object.assign($, savedState);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run a markdown file from disk
|
|
195
|
+
*
|
|
196
|
+
* @param filePath - Path to markdown file
|
|
197
|
+
* @param options - CLI-like options
|
|
198
|
+
*/
|
|
199
|
+
export async function runMarkdownFile(filePath: string, options: RunMarkdownOptions = {}): Promise<void> {
|
|
200
|
+
return runMarkdown(filePath, options);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Run markdown content directly
|
|
205
|
+
*
|
|
206
|
+
* @param markdownContent - Markdown content as string
|
|
207
|
+
* @param options - CLI-like options
|
|
208
|
+
*/
|
|
209
|
+
export async function runMarkdownContent(markdownContent: string, options: RunMarkdownOptions = {}): Promise<void> {
|
|
210
|
+
return runMarkdown(markdownContent, options);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Re-export everything from zx for convenience
|
|
214
|
+
export * from 'zx';
|
|
215
|
+
|
|
216
|
+
// Export preprocessor functions
|
|
217
|
+
export * from './preprocessor.js';
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {marked, Lexer} from 'marked';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Directive types supported in blockquotes
|
|
6
|
+
*/
|
|
7
|
+
export type DirectiveType = 'file' | 'async';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parsed directive from a blockquote line
|
|
11
|
+
*/
|
|
12
|
+
export interface Directive {
|
|
13
|
+
type: DirectiveType;
|
|
14
|
+
value: string; // file path for 'file', description for 'async'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CodeBlock {
|
|
18
|
+
language: string;
|
|
19
|
+
content: string;
|
|
20
|
+
startLine: number;
|
|
21
|
+
endLine: number;
|
|
22
|
+
filePath?: string; // File path from file: directive
|
|
23
|
+
isAsync?: boolean; // Whether this is an async block from async: directive
|
|
24
|
+
asyncDescription?: string; // Optional description from async: directive
|
|
25
|
+
precedingBlockquoteLine?: number; // Line number of the blockquote with directive
|
|
26
|
+
sleepSeconds?: number; // Sleep duration from sleep:N modifier
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FileExtraction {
|
|
30
|
+
filePath: string;
|
|
31
|
+
content: string;
|
|
32
|
+
language: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PreprocessResult {
|
|
36
|
+
preprocessedMarkdown: string;
|
|
37
|
+
extractedFiles: FileExtraction[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Information about a block that will be transformed to a heredoc
|
|
42
|
+
*/
|
|
43
|
+
interface HeredocTransform {
|
|
44
|
+
filePath: string;
|
|
45
|
+
content: string;
|
|
46
|
+
directoryPrefix?: string; // Optional cd prefix for unified directory context
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Information about a block that will be transformed to async JS
|
|
51
|
+
*/
|
|
52
|
+
interface AsyncTransform {
|
|
53
|
+
content: string;
|
|
54
|
+
directoryPrefix?: string; // Optional cd prefix for unified directory context
|
|
55
|
+
description?: string; // Optional description for documentation
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sanitize markdown to prevent zx's parser from misinterpreting
|
|
61
|
+
* indented prose as code blocks.
|
|
62
|
+
*
|
|
63
|
+
* zx treats lines starting with 2+ spaces after an empty line as code.
|
|
64
|
+
* We use marked to identify actual code blocks and rebuild the markdown
|
|
65
|
+
* with only headings and code blocks (stripping all prose).
|
|
66
|
+
*
|
|
67
|
+
* This also handles the edge case of indented code blocks within list items
|
|
68
|
+
* (e.g., " ```bash" within a list), which zx's regex parser cannot handle.
|
|
69
|
+
*/
|
|
70
|
+
export function sanitizeMarkdownForZx(markdown: string): string {
|
|
71
|
+
const tokens = Lexer.lex(markdown);
|
|
72
|
+
const output: string[] = [];
|
|
73
|
+
|
|
74
|
+
// Recursively process tokens to extract code blocks and headings
|
|
75
|
+
function processTokens(tokenArray: any[], depth: number = 0) {
|
|
76
|
+
for (const token of tokenArray) {
|
|
77
|
+
if (token.type === 'code') {
|
|
78
|
+
// Keep fenced code blocks - marked handles indentation correctly
|
|
79
|
+
// Remove bracketed modifiers like [npm], [pnpm], etc. but preserve zx's modifiers like skip
|
|
80
|
+
// zx doesn't understand bracketed modifiers and will skip code blocks with them
|
|
81
|
+
const langTag = token.lang || '';
|
|
82
|
+
// Remove bracketed modifiers using regex to match [anything]
|
|
83
|
+
const cleanLangTag = langTag.replace(/\s*\[.*?\]\s*/g, ' ').trim();
|
|
84
|
+
const fence = '```';
|
|
85
|
+
output.push(`${fence}${cleanLangTag}`);
|
|
86
|
+
output.push(token.text);
|
|
87
|
+
output.push(fence);
|
|
88
|
+
output.push(''); // Empty line after for readability
|
|
89
|
+
} else if (token.type === 'heading') {
|
|
90
|
+
// Keep headings (useful for section extraction in ezx)
|
|
91
|
+
output.push(`${'#'.repeat(token.depth)} ${token.text}`);
|
|
92
|
+
output.push('');
|
|
93
|
+
} else if (token.type === 'list' && token.items) {
|
|
94
|
+
// Process list items to find nested code blocks
|
|
95
|
+
for (const item of token.items) {
|
|
96
|
+
if (item.tokens) {
|
|
97
|
+
processTokens(item.tokens, depth + 1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Other content (paragraphs, lists, blockquotes, etc.) is dropped
|
|
102
|
+
// since it's not executable and could confuse zx's parser
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
processTokens(tokens);
|
|
107
|
+
|
|
108
|
+
return output.join('\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if a language is a bash/shell language
|
|
113
|
+
*/
|
|
114
|
+
function isBashLanguage(language: string): boolean {
|
|
115
|
+
const lang = language.toLowerCase();
|
|
116
|
+
return lang === 'bash' || lang === 'sh' || lang === 'shell';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse language tag and extract modifiers
|
|
121
|
+
* e.g., "bash sleep:3 skip" -> { language: "bash", languageTag: "bash skip", sleepSeconds: 3 }
|
|
122
|
+
* e.g., "bash [npm] skip" -> { language: "bash", languageTag: "bash skip", sleepSeconds: undefined }
|
|
123
|
+
* The languageTag has sleep:N and bracketed modifiers like [npm] removed, but preserves zx's modifiers like skip
|
|
124
|
+
*/
|
|
125
|
+
function parseLanguageTag(langTag: string): { language: string; languageTag: string; sleepSeconds?: number } {
|
|
126
|
+
const parts = langTag.trim().split(/\s+/);
|
|
127
|
+
const language = parts[0] || '';
|
|
128
|
+
let sleepSeconds: number | undefined;
|
|
129
|
+
const otherModifiers: string[] = [];
|
|
130
|
+
|
|
131
|
+
for (let i = 1; i < parts.length; i++) {
|
|
132
|
+
const part = parts[i];
|
|
133
|
+
const sleepMatch = part.match(/^sleep:(\d+)$/i);
|
|
134
|
+
if (sleepMatch) {
|
|
135
|
+
sleepSeconds = parseInt(sleepMatch[1], 10);
|
|
136
|
+
} else if (part.match(/^\[.*?\]$/)) {
|
|
137
|
+
// Skip bracketed modifiers like [npm], [pnpm], etc.
|
|
138
|
+
// These are not understood by zx and cause code blocks to be skipped
|
|
139
|
+
continue;
|
|
140
|
+
} else {
|
|
141
|
+
// Preserve other modifiers like 'skip'
|
|
142
|
+
otherModifiers.push(part);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Build language tag without sleep:N and bracketed modifiers
|
|
147
|
+
const languageTag = [language, ...otherModifiers].join(' ');
|
|
148
|
+
|
|
149
|
+
return { language, languageTag, sleepSeconds };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Extract directive from a blockquote line
|
|
154
|
+
*
|
|
155
|
+
* Supports:
|
|
156
|
+
* - file: directive: > file: `path/to/file.json` or > file: path/to/file.json
|
|
157
|
+
* - async: directive: > async: `description` or > async: description or > async:
|
|
158
|
+
*
|
|
159
|
+
* Note: The old syntax `> `filename`` without a directive prefix is NO LONGER supported.
|
|
160
|
+
* This is a breaking change - use `> file: `filename`` instead.
|
|
161
|
+
*/
|
|
162
|
+
function extractDirectiveFromBlockquote(line: string): Directive | null {
|
|
163
|
+
const trimmed = line.trim();
|
|
164
|
+
|
|
165
|
+
// Match file: directive with optional backticks
|
|
166
|
+
// e.g., > file: `package.json` or > file: package.json
|
|
167
|
+
const fileMatch = trimmed.match(/^>\s*file:\s*(?:`([^`]+)`|(\S.*?))\s*$/i);
|
|
168
|
+
if (fileMatch) {
|
|
169
|
+
const value = (fileMatch[1] || fileMatch[2] || '').trim();
|
|
170
|
+
if (!value) {
|
|
171
|
+
return null; // file: requires a path
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
type: 'file',
|
|
175
|
+
value,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Match async: directive with optional backticks
|
|
180
|
+
// e.g., > async: `start server` or > async: start server or > async:
|
|
181
|
+
const asyncMatch = trimmed.match(/^>\s*async:\s*(?:`([^`]*)`|(.*))?\s*$/i);
|
|
182
|
+
if (asyncMatch) {
|
|
183
|
+
return {
|
|
184
|
+
type: 'async',
|
|
185
|
+
value: (asyncMatch[1] || asyncMatch[2] || '').trim(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Parse markdown and extract all code blocks with their line numbers
|
|
194
|
+
* Also extracts directives (file: or async:) from preceding blockquote if present
|
|
195
|
+
*/
|
|
196
|
+
function parseCodeBlocks(markdown: string): CodeBlock[] {
|
|
197
|
+
const codeBlocks: CodeBlock[] = [];
|
|
198
|
+
const lines = markdown.split('\n');
|
|
199
|
+
let inCodeBlock = false;
|
|
200
|
+
let currentBlock: Partial<CodeBlock> = {};
|
|
201
|
+
let blockStartLine = 0;
|
|
202
|
+
let precedingBlockquoteLine: number | undefined;
|
|
203
|
+
let pendingDirective: Directive | undefined;
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < lines.length; i++) {
|
|
206
|
+
const line = lines[i];
|
|
207
|
+
|
|
208
|
+
// Check for code block start (including indented code blocks in list items)
|
|
209
|
+
if (line.match(/^\s*```/)) {
|
|
210
|
+
if (!inCodeBlock) {
|
|
211
|
+
// Starting a new code block - extract full language tag including modifiers
|
|
212
|
+
const languageMatch = line.match(/^\s*```(.*)$/);
|
|
213
|
+
const fullLangTag = languageMatch?.[1] || '';
|
|
214
|
+
const { language, languageTag, sleepSeconds } = parseLanguageTag(fullLangTag);
|
|
215
|
+
|
|
216
|
+
// Validate async directive is only used with bash blocks
|
|
217
|
+
if (pendingDirective?.type === 'async' && !isBashLanguage(language)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`[ezx] async: directive error at line ${(precedingBlockquoteLine ?? 0) + 1}\n` +
|
|
220
|
+
`The async: directive can only be used with bash, sh, or shell code blocks.\n` +
|
|
221
|
+
`Found: \`\`\`${language || '(no language specified)'}\n` +
|
|
222
|
+
`\n` +
|
|
223
|
+
`To fix this, either:\n` +
|
|
224
|
+
`1. Change the code block language to bash, sh, or shell\n` +
|
|
225
|
+
`2. Remove the async: directive from the preceding blockquote`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
currentBlock = {
|
|
230
|
+
language,
|
|
231
|
+
filePath: pendingDirective?.type === 'file' ? pendingDirective.value : undefined,
|
|
232
|
+
isAsync: pendingDirective?.type === 'async',
|
|
233
|
+
asyncDescription: pendingDirective?.type === 'async' ? pendingDirective.value : undefined,
|
|
234
|
+
precedingBlockquoteLine: precedingBlockquoteLine,
|
|
235
|
+
sleepSeconds,
|
|
236
|
+
content: '',
|
|
237
|
+
startLine: i,
|
|
238
|
+
};
|
|
239
|
+
inCodeBlock = true;
|
|
240
|
+
blockStartLine = i;
|
|
241
|
+
precedingBlockquoteLine = undefined;
|
|
242
|
+
pendingDirective = undefined; // Reset for next block
|
|
243
|
+
} else {
|
|
244
|
+
// Ending the current code block
|
|
245
|
+
if (
|
|
246
|
+
currentBlock.language !== undefined &&
|
|
247
|
+
currentBlock.content !== undefined &&
|
|
248
|
+
currentBlock.startLine !== undefined
|
|
249
|
+
) {
|
|
250
|
+
codeBlocks.push({
|
|
251
|
+
language: currentBlock.language,
|
|
252
|
+
content: currentBlock.content,
|
|
253
|
+
startLine: currentBlock.startLine,
|
|
254
|
+
endLine: i,
|
|
255
|
+
filePath: currentBlock.filePath,
|
|
256
|
+
isAsync: currentBlock.isAsync,
|
|
257
|
+
asyncDescription: currentBlock.asyncDescription,
|
|
258
|
+
precedingBlockquoteLine: currentBlock.precedingBlockquoteLine,
|
|
259
|
+
sleepSeconds: currentBlock.sleepSeconds,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
inCodeBlock = false;
|
|
263
|
+
currentBlock = {};
|
|
264
|
+
}
|
|
265
|
+
} else if (!inCodeBlock) {
|
|
266
|
+
// Look for blockquote with directive (only outside code blocks)
|
|
267
|
+
const directive = extractDirectiveFromBlockquote(line);
|
|
268
|
+
if (directive) {
|
|
269
|
+
precedingBlockquoteLine = i;
|
|
270
|
+
pendingDirective = directive;
|
|
271
|
+
}
|
|
272
|
+
} else if (inCodeBlock && currentBlock.content !== undefined) {
|
|
273
|
+
// Add line to current block content
|
|
274
|
+
currentBlock.content += (currentBlock.content ? '\n' : '') + line;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return codeBlocks;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Check if a line contains a directory set command
|
|
283
|
+
*/
|
|
284
|
+
function isDirectorySetCommand(line: string): boolean {
|
|
285
|
+
const trimmed = line.trim().toLowerCase();
|
|
286
|
+
return trimmed === '# set the current directory';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Preprocess markdown file
|
|
292
|
+
*/
|
|
293
|
+
export function preprocessMarkdown(
|
|
294
|
+
input: string,
|
|
295
|
+
options: {cwd?: string; standalone?: boolean} = {},
|
|
296
|
+
): PreprocessResult {
|
|
297
|
+
const {cwd = process.cwd(), standalone = false} = options;
|
|
298
|
+
const lines = input.split('\n');
|
|
299
|
+
const codeBlocks = parseCodeBlocks(input);
|
|
300
|
+
const extractedFiles: FileExtraction[] = [];
|
|
301
|
+
|
|
302
|
+
// Track the current directory context
|
|
303
|
+
let currentDirectory: string | null = null;
|
|
304
|
+
|
|
305
|
+
// Map of block indices to transformations (prepend cd)
|
|
306
|
+
const blocksToTransform: Map<number, string> = new Map();
|
|
307
|
+
// Map of block indices to transform to heredoc
|
|
308
|
+
const blocksToHeredoc: Map<number, HeredocTransform> = new Map();
|
|
309
|
+
// Map of block indices to transform to async JS
|
|
310
|
+
const blocksToAsync: Map<number, AsyncTransform> = new Map();
|
|
311
|
+
// Track where we need to insert .npmrc after directory set
|
|
312
|
+
const npmrcInsertBlocks: Set<number> = new Set();
|
|
313
|
+
|
|
314
|
+
// Process each code block
|
|
315
|
+
for (let i = 0; i < codeBlocks.length; i++) {
|
|
316
|
+
const block = codeBlocks[i];
|
|
317
|
+
const isBash = isBashLanguage(block.language);
|
|
318
|
+
|
|
319
|
+
// Check if this is an async block
|
|
320
|
+
if (block.isAsync) {
|
|
321
|
+
// Transform to async JS block
|
|
322
|
+
blocksToAsync.set(i, {
|
|
323
|
+
content: block.content.trim(),
|
|
324
|
+
directoryPrefix: currentDirectory || undefined,
|
|
325
|
+
description: block.asyncDescription,
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check if this is a file extraction block (file path from file: directive)
|
|
331
|
+
if (block.filePath) {
|
|
332
|
+
// This is a file extraction block - transform to heredoc
|
|
333
|
+
const fileContent = block.content.trim();
|
|
334
|
+
extractedFiles.push({
|
|
335
|
+
filePath: block.filePath,
|
|
336
|
+
content: fileContent,
|
|
337
|
+
language: block.language,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Mark this block for transformation to heredoc
|
|
341
|
+
// If we have a current directory, prepend it to the heredoc command
|
|
342
|
+
blocksToHeredoc.set(i, {
|
|
343
|
+
filePath: block.filePath,
|
|
344
|
+
content: fileContent,
|
|
345
|
+
directoryPrefix: currentDirectory || undefined,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Check for directory set command in bash blocks
|
|
352
|
+
if (isBash) {
|
|
353
|
+
const blockLines = block.content.split('\n');
|
|
354
|
+
let justSetDirectory = false;
|
|
355
|
+
|
|
356
|
+
// First, check if this block has a directory set command
|
|
357
|
+
const hasDirectorySet = blockLines.some((line) => isDirectorySetCommand(line));
|
|
358
|
+
|
|
359
|
+
if (hasDirectorySet) {
|
|
360
|
+
// If we have a current directory from a PREVIOUS block, prepend cd to this block
|
|
361
|
+
// This ensures the cd command in this block is relative to the current virtual directory
|
|
362
|
+
if (currentDirectory) {
|
|
363
|
+
blocksToTransform.set(i, currentDirectory);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (let j = 0; j < blockLines.length; j++) {
|
|
367
|
+
const line = blockLines[j];
|
|
368
|
+
if (isDirectorySetCommand(line)) {
|
|
369
|
+
// Found directory set command, now look for the next cd command
|
|
370
|
+
for (let k = j + 1; k < blockLines.length; k++) {
|
|
371
|
+
const lineAfter = blockLines[k];
|
|
372
|
+
const cdMatch = lineAfter.match(/^\s*cd\s+(.+)$/);
|
|
373
|
+
if (cdMatch) {
|
|
374
|
+
// The cd command will execute relative to the prepended virtual directory
|
|
375
|
+
const newPath = cdMatch[1].trim();
|
|
376
|
+
// Update the virtual directory context
|
|
377
|
+
if (currentDirectory) {
|
|
378
|
+
currentDirectory = `${currentDirectory}/${newPath}`;
|
|
379
|
+
} else {
|
|
380
|
+
currentDirectory = newPath;
|
|
381
|
+
}
|
|
382
|
+
justSetDirectory = true;
|
|
383
|
+
// If standalone mode is enabled, mark this block for .npmrc insertion
|
|
384
|
+
if (standalone) {
|
|
385
|
+
npmrcInsertBlocks.add(i);
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// If we have a current directory from a PREVIOUS block, prepend cd to this bash block
|
|
396
|
+
// Only prepend if this is NOT the block where we just set the directory (we already did above)
|
|
397
|
+
if (currentDirectory && !justSetDirectory) {
|
|
398
|
+
blocksToTransform.set(i, currentDirectory);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Build preprocessed markdown
|
|
404
|
+
const preprocessedLines: string[] = [];
|
|
405
|
+
// Track lines to skip (blockquote lines that contained directives)
|
|
406
|
+
const linesToSkip = new Set<number>();
|
|
407
|
+
for (const block of codeBlocks) {
|
|
408
|
+
if (block.precedingBlockquoteLine !== undefined && (block.filePath || block.isAsync)) {
|
|
409
|
+
linesToSkip.add(block.precedingBlockquoteLine);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (let i = 0; i < lines.length; i++) {
|
|
414
|
+
const line = lines[i];
|
|
415
|
+
|
|
416
|
+
// Skip blockquote lines that contained directives (they were consumed)
|
|
417
|
+
if (linesToSkip.has(i)) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Check if this is the start of a code block (including indented code blocks in list items)
|
|
422
|
+
if (line.match(/^\s*```/)) {
|
|
423
|
+
const blockIndex = codeBlocks.findIndex((b) => b.startLine === i);
|
|
424
|
+
|
|
425
|
+
if (blockIndex !== -1) {
|
|
426
|
+
const block = codeBlocks[blockIndex];
|
|
427
|
+
|
|
428
|
+
// Check if this block should be transformed to async JS
|
|
429
|
+
if (blocksToAsync.has(blockIndex)) {
|
|
430
|
+
const asyncBlock = blocksToAsync.get(blockIndex)!;
|
|
431
|
+
// Transform to JS block with $ template literal
|
|
432
|
+
preprocessedLines.push('```js');
|
|
433
|
+
|
|
434
|
+
// Join multiple lines with ; to execute as a single command chain
|
|
435
|
+
// Filter out empty lines and comments that start with #
|
|
436
|
+
const commandLines = asyncBlock.content
|
|
437
|
+
.split('\n')
|
|
438
|
+
.map(line => line.trim())
|
|
439
|
+
.filter(line => line && !line.startsWith('#'));
|
|
440
|
+
let command = commandLines.join('; ');
|
|
441
|
+
|
|
442
|
+
// Add cd prefix if we have a directory context
|
|
443
|
+
if (asyncBlock.directoryPrefix) {
|
|
444
|
+
command = `cd ${asyncBlock.directoryPrefix} && ${command}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Escape backticks in the command for the template literal
|
|
448
|
+
const escapedCommand = command.replace(/`/g, '\\`');
|
|
449
|
+
|
|
450
|
+
// Output the $ template literal (fire-and-forget, no await)
|
|
451
|
+
// zx will await all pending promises at the end
|
|
452
|
+
preprocessedLines.push(`$\`${escapedCommand}\``);
|
|
453
|
+
preprocessedLines.push('```');
|
|
454
|
+
// Skip to the closing ```
|
|
455
|
+
i = block.endLine;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Check if this block should be transformed to heredoc
|
|
460
|
+
if (blocksToHeredoc.has(blockIndex)) {
|
|
461
|
+
const heredoc = blocksToHeredoc.get(blockIndex)!;
|
|
462
|
+
// Transform to bash heredoc block
|
|
463
|
+
preprocessedLines.push('```bash');
|
|
464
|
+
// If we have a directory prefix, prepend cd && to the cat command
|
|
465
|
+
// Also strip the directory prefix from the file path if it matches
|
|
466
|
+
let catPath = heredoc.filePath;
|
|
467
|
+
if (heredoc.directoryPrefix) {
|
|
468
|
+
// Remove the directory prefix from the file path if it starts with it
|
|
469
|
+
if (catPath.startsWith(heredoc.directoryPrefix + '/')) {
|
|
470
|
+
catPath = catPath.slice(heredoc.directoryPrefix.length + 1);
|
|
471
|
+
}
|
|
472
|
+
preprocessedLines.push(`cd ${heredoc.directoryPrefix} && cat > '${catPath}' << 'EZXEOF'`);
|
|
473
|
+
} else {
|
|
474
|
+
preprocessedLines.push(`cat > '${catPath}' << 'EZXEOF'`);
|
|
475
|
+
}
|
|
476
|
+
preprocessedLines.push(heredoc.content);
|
|
477
|
+
preprocessedLines.push('EZXEOF');
|
|
478
|
+
preprocessedLines.push('```');
|
|
479
|
+
// Skip to the closing ```
|
|
480
|
+
i = block.endLine;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Output the opening ``` line (strip only sleep:N, preserve other modifiers like skip)
|
|
485
|
+
// We need to get the languageTag from the original line since block.language is just the base language
|
|
486
|
+
const origLangMatch = line.match(/^\s*```(.*)$/);
|
|
487
|
+
const origFullTag = origLangMatch?.[1] || '';
|
|
488
|
+
const { languageTag } = parseLanguageTag(origFullTag);
|
|
489
|
+
preprocessedLines.push(`\`\`\`${languageTag}`);
|
|
490
|
+
|
|
491
|
+
// Check if we need to prepend sleep command (only for bash blocks)
|
|
492
|
+
if (block.sleepSeconds && isBashLanguage(block.language)) {
|
|
493
|
+
preprocessedLines.push(`sleep ${block.sleepSeconds}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Check if we need to prepend cd command
|
|
497
|
+
if (blocksToTransform.has(blockIndex)) {
|
|
498
|
+
const dir = blocksToTransform.get(blockIndex)!;
|
|
499
|
+
preprocessedLines.push(`cd ${dir}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Output the block content
|
|
503
|
+
if (block.content) {
|
|
504
|
+
preprocessedLines.push(block.content);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// If standalone mode is enabled and this block set the directory, append .npmrc creation
|
|
508
|
+
if (npmrcInsertBlocks.has(blockIndex)) {
|
|
509
|
+
preprocessedLines.push('');
|
|
510
|
+
preprocessedLines.push('cat > .npmrc << \'EZXEOF\'');
|
|
511
|
+
preprocessedLines.push('# Disable workspace awareness for test project');
|
|
512
|
+
preprocessedLines.push('# This ensures that pnpm install and other commands work in standalone mode');
|
|
513
|
+
preprocessedLines.push('enable-pre-post-scripts=true');
|
|
514
|
+
preprocessedLines.push('shamefully-hoist=false');
|
|
515
|
+
preprocessedLines.push('strict-peer-dependencies=false');
|
|
516
|
+
preprocessedLines.push('EZXEOF');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Skip to the closing ```
|
|
520
|
+
i = block.endLine - 1;
|
|
521
|
+
} else {
|
|
522
|
+
// This is a closing ``` we've already processed
|
|
523
|
+
preprocessedLines.push(line);
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
// Regular line
|
|
527
|
+
preprocessedLines.push(line);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
preprocessedMarkdown: preprocessedLines.join('\n'),
|
|
533
|
+
extractedFiles,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Read and preprocess a markdown file
|
|
539
|
+
*/
|
|
540
|
+
export function preprocessFile(
|
|
541
|
+
filePath: string,
|
|
542
|
+
options: {cwd?: string} = {},
|
|
543
|
+
): PreprocessResult {
|
|
544
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
545
|
+
return preprocessMarkdown(content, options);
|
|
546
|
+
}
|