markdown-run 0.1.4 → 0.1.6
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +12 -0
- data/exe/markdown-run +284 -327
- data/lib/language_configs.rb +80 -0
- data/lib/markdown/run/version.rb +1 -1
- data/test_markdown_exec.rb +134 -0
- metadata +4 -3
- data/docs/markdown-run-vscode.gif +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ac1a715b89896b392f75d2222bda1a4f5899dda9da7a12871548faee8d3c86bb
|
4
|
+
data.tar.gz: 5fd7954991346dd940594b264d5b162a91451ed7c89e7510eb6b0315d2c39195
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1ee917248f47a0ccc7770fdd419fe3d65dfe2ca99c79d44efe0b46a9fd599761e0c218e622c39989c806de07f92480c0de1e4c7c869d7d9f7db2439a9fb70fd0
|
7
|
+
data.tar.gz: 2c3eeb35fee738e405c30bdbb5fee6e8cad6bb0acc599c1115ad77315179e28dd37c6aace1b125c5306d9702b2cbc4c2dec1394695c9908a73853917f73a1fa3
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,14 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.1.6] - 2025-06-01
|
4
|
+
|
5
|
+
- Refactor code to state pattern
|
6
|
+
- Add yaml frontmatter to support aliases for code blocks
|
7
|
+
|
8
|
+
## [0.1.5] - 2025-05-19
|
9
|
+
|
10
|
+
- Remove gif files from release
|
11
|
+
|
3
12
|
## [0.1.4] - 2025-05-18
|
4
13
|
|
5
14
|
- Add support for zsh, bash, sh
|
data/README.md
CHANGED
@@ -43,6 +43,18 @@ example vscode keybinding
|
|
43
43
|
},
|
44
44
|
```
|
45
45
|
|
46
|
+
## Frontmatter
|
47
|
+
|
48
|
+
You can add a yaml frontmatter to redefine code block behavior.
|
49
|
+
For example sql blocks run by default against sqlite
|
50
|
+
To have them run with postgres you can add at the top of your markdown file:
|
51
|
+
|
52
|
+
```yaml
|
53
|
+
markdown-run:
|
54
|
+
alias:
|
55
|
+
- sql: psql
|
56
|
+
```
|
57
|
+
|
46
58
|
## Demo
|
47
59
|
|
48
60
|

|
data/exe/markdown-run
CHANGED
@@ -3,119 +3,61 @@
|
|
3
3
|
require "tempfile"
|
4
4
|
require "open3"
|
5
5
|
require "fileutils"
|
6
|
-
|
7
|
-
|
8
|
-
JS_CONFIG = {
|
9
|
-
command: ->(_code_content, temp_file_path) {
|
10
|
-
# Check if bun is available
|
11
|
-
bun_exists = system("command -v bun > /dev/null 2>&1")
|
12
|
-
if bun_exists
|
13
|
-
[ "bun #{temp_file_path}", {} ]
|
14
|
-
else
|
15
|
-
# Fallback to node if bun is not available
|
16
|
-
[ "node #{temp_file_path}", {} ]
|
17
|
-
end
|
18
|
-
},
|
19
|
-
temp_file_suffix: ".js",
|
20
|
-
error_handling: :js_specific # For specific stderr appending on error
|
21
|
-
}.freeze
|
22
|
-
|
23
|
-
SQLITE_CONFIG = {
|
24
|
-
command: ->(code_content, temp_file_path) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
|
25
|
-
temp_file_suffix: ".db" # Temp file is the database
|
26
|
-
}.freeze
|
27
|
-
|
28
|
-
SUPPORTED_LANGUAGES = {
|
29
|
-
"psql" => {
|
30
|
-
command: ->(code_content, _temp_file_path) {
|
31
|
-
psql_exists = system("command -v psql > /dev/null 2>&1")
|
32
|
-
unless psql_exists
|
33
|
-
abort "Error: psql command not found. Please install PostgreSQL or ensure psql is in your PATH."
|
34
|
-
end
|
35
|
-
[ "psql -A -t -X", { stdin_data: code_content } ]
|
36
|
-
}
|
37
|
-
},
|
38
|
-
"ruby" => {
|
39
|
-
command: ->(_code_content, temp_file_path) {
|
40
|
-
xmpfilter_exists = system("command -v xmpfilter > /dev/null 2>&1")
|
41
|
-
unless xmpfilter_exists
|
42
|
-
abort "Error: xmpfilter command not found. Please install xmpfilter or ensure it is in your PATH."
|
43
|
-
end
|
44
|
-
[ "xmpfilter #{temp_file_path}", {} ]
|
45
|
-
},
|
46
|
-
temp_file_suffix: ".rb",
|
47
|
-
result_block_type: "ruby" # For special '```ruby RESULT' blocks
|
48
|
-
},
|
49
|
-
"js" => JS_CONFIG,
|
50
|
-
"javascript" => JS_CONFIG, # Alias for js
|
51
|
-
"sqlite" => SQLITE_CONFIG,
|
52
|
-
"sqlite3" => SQLITE_CONFIG, # Alias for sqlite
|
53
|
-
"bash" => {
|
54
|
-
command: ->(_code_content, temp_file_path) {
|
55
|
-
bash_exists = system("command -v bash > /dev/null 2>&1")
|
56
|
-
unless bash_exists
|
57
|
-
abort "Error: bash command not found. Please ensure bash is in your PATH."
|
58
|
-
end
|
59
|
-
[ "bash #{temp_file_path}", {} ]
|
60
|
-
},
|
61
|
-
temp_file_suffix: ".sh"
|
62
|
-
},
|
63
|
-
"zsh" => {
|
64
|
-
command: ->(_code_content, temp_file_path) {
|
65
|
-
zsh_exists = system("command -v zsh > /dev/null 2>&1")
|
66
|
-
unless zsh_exists
|
67
|
-
abort "Error: zsh command not found. Please ensure zsh is in your PATH."
|
68
|
-
end
|
69
|
-
[ "zsh #{temp_file_path}", {} ]
|
70
|
-
},
|
71
|
-
temp_file_suffix: ".zsh"
|
72
|
-
},
|
73
|
-
"sh" => {
|
74
|
-
command: ->(_code_content, temp_file_path) {
|
75
|
-
sh_exists = system("command -v sh > /dev/null 2>&1")
|
76
|
-
unless sh_exists
|
77
|
-
abort "Error: sh command not found. Please ensure sh is in your PATH."
|
78
|
-
end
|
79
|
-
[ "sh #{temp_file_path}", {} ]
|
80
|
-
},
|
81
|
-
temp_file_suffix: ".sh"
|
82
|
-
}
|
83
|
-
}.freeze
|
84
|
-
|
85
|
-
LANGUAGE_REGEX_PART = SUPPORTED_LANGUAGES.keys.map { |lang| Regexp.escape(lang) }.join("|").freeze
|
86
|
-
CODE_BLOCK_START_REGEX = /^```(#{LANGUAGE_REGEX_PART})$/i
|
87
|
-
# --- End Language Execution Configuration ---
|
6
|
+
require "yaml"
|
7
|
+
require_relative "../lib/language_configs"
|
88
8
|
|
89
9
|
# Script to process markdown files, execute code blocks based on language,
|
90
10
|
# and insert their results back into the markdown.
|
91
11
|
|
12
|
+
def stderr_has_content?(stderr_output)
|
13
|
+
stderr_output && !stderr_output.strip.empty?
|
14
|
+
end
|
15
|
+
|
16
|
+
def format_captured_output(captured_status_obj, captured_stderr, captured_stdout, lang_config)
|
17
|
+
result_output = captured_stdout
|
18
|
+
stderr_output = captured_stderr
|
19
|
+
exit_status = captured_status_obj.exitstatus
|
20
|
+
|
21
|
+
# JS-specific: Append stderr to result if execution failed and stderr has content
|
22
|
+
if lang_config && lang_config[:error_handling] == :js_specific && exit_status != 0 && stderr_has_content?(stderr_output)
|
23
|
+
result_output += "\nStderr:\n#{stderr_output.strip}"
|
24
|
+
end
|
25
|
+
return exit_status, result_output, stderr_output
|
26
|
+
end
|
27
|
+
|
28
|
+
def add_error_to_output(exit_status, lang_config, lang_key, result_output, stderr_output)
|
29
|
+
warn "Code execution failed for language '#{lang_key}' with status #{exit_status}."
|
30
|
+
warn "Stderr:\n#{stderr_output}" if stderr_has_content?(stderr_output)
|
31
|
+
|
32
|
+
is_js_error_already_formatted = lang_config && lang_config[:error_handling] == :js_specific && result_output.include?("Stderr:")
|
33
|
+
unless result_output.downcase.include?("error:") || is_js_error_already_formatted
|
34
|
+
error_prefix = "Execution failed (status: #{exit_status})."
|
35
|
+
error_prefix += " Stderr: #{stderr_output.strip}" if stderr_has_content?(stderr_output)
|
36
|
+
result_output = "#{error_prefix}\n#{result_output}"
|
37
|
+
end
|
38
|
+
result_output
|
39
|
+
end
|
40
|
+
|
92
41
|
def execute_code_block(code_content, lang, temp_dir)
|
93
|
-
result_output = ""
|
94
|
-
stderr_output = ""
|
95
|
-
exit_status = 0
|
96
|
-
captured_stdout = nil
|
97
|
-
captured_stderr = nil
|
98
42
|
captured_status_obj = nil
|
99
43
|
|
100
44
|
lang_key = lang.downcase # Normalize lang input for lookup
|
101
45
|
lang_config = SUPPORTED_LANGUAGES[lang_key]
|
102
46
|
|
103
47
|
if lang_config
|
48
|
+
exit_status = 0
|
104
49
|
warn "Executing #{lang_key} code block..." # Generic description
|
105
50
|
cmd_lambda = lang_config[:command]
|
106
51
|
temp_file_suffix = lang_config[:temp_file_suffix]
|
107
52
|
|
108
|
-
|
109
|
-
# The lambda receives code_content and a potential temp_file_path
|
110
|
-
# It returns [command_string, options_hash_for_open3]
|
111
|
-
|
53
|
+
captured_stdout = nil
|
112
54
|
if temp_file_suffix # Needs a temporary file. Use lang_key as prefix.
|
113
55
|
Tempfile.create([ lang_key, temp_file_suffix ], temp_dir) do |temp_file|
|
114
56
|
temp_file.write(code_content)
|
115
57
|
temp_file.close
|
116
58
|
# Pass temp_file.path. Lambda decides if it needs code_content directly.
|
117
59
|
command_to_run, exec_options = cmd_lambda.call(code_content, temp_file.path)
|
118
|
-
captured_stdout,
|
60
|
+
captured_stdout, _, captured_status_obj = Open3.capture3(command_to_run, **exec_options)
|
119
61
|
end
|
120
62
|
else # Direct command execution (e.g., psql that takes stdin)
|
121
63
|
# Pass nil for temp_file_path. Lambda decides if it needs code_content.
|
@@ -129,292 +71,307 @@ def execute_code_block(code_content, lang, temp_dir)
|
|
129
71
|
# captured_status_obj remains nil, so common assignments below won't run
|
130
72
|
end
|
131
73
|
|
132
|
-
# Common assignment logic for cases that used Open3.capture3
|
133
74
|
if captured_status_obj
|
134
|
-
result_output = captured_stdout
|
135
|
-
stderr_output = captured_stderr
|
136
|
-
exit_status = captured_status_obj.exitstatus
|
137
|
-
|
138
|
-
# JS-specific: Append stderr to result if execution failed and stderr has content
|
139
|
-
if lang_config && lang_config[:error_handling] == :js_specific && exit_status != 0 && stderr_output && !stderr_output.strip.empty?
|
140
|
-
result_output += "\nStderr:\n#{stderr_output.strip}" # Ensure stripping
|
141
|
-
end
|
75
|
+
exit_status, result_output, stderr_output = format_captured_output(captured_status_obj, captured_stderr, captured_stdout, lang_config)
|
142
76
|
end
|
143
77
|
|
144
|
-
# Common error message enhancement
|
145
78
|
if exit_status != 0
|
146
|
-
|
147
|
-
warn "Stderr:\n#{stderr_output}" if stderr_output && !stderr_output.strip.empty?
|
148
|
-
|
149
|
-
is_js_error_already_formatted = lang_config && lang_config[:error_handling] == :js_specific && result_output.include?("Stderr:")
|
150
|
-
unless result_output.downcase.include?("error:") || is_js_error_already_formatted
|
151
|
-
error_prefix = "Execution failed (status: #{exit_status})."
|
152
|
-
error_prefix += " Stderr: #{stderr_output.strip}" if stderr_output && !stderr_output.strip.empty?
|
153
|
-
result_output = "#{error_prefix}\n#{result_output}"
|
154
|
-
end
|
79
|
+
result_output = add_error_to_output(exit_status, lang_config, lang_key, result_output, stderr_output)
|
155
80
|
end
|
156
81
|
result_output
|
157
82
|
end
|
158
83
|
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
84
|
+
class MarkdownProcessor
|
85
|
+
def initialize(temp_dir)
|
86
|
+
@temp_dir = temp_dir
|
87
|
+
@output_lines = []
|
88
|
+
@state = :outside_code_block
|
89
|
+
@current_block_lang = ""
|
90
|
+
@current_code_content = ""
|
91
|
+
@aliases = {}
|
163
92
|
end
|
164
93
|
|
165
|
-
|
166
|
-
|
167
|
-
file_enum = File.foreach(input_file_path, chomp: false).to_enum
|
94
|
+
def process_file(file_enum)
|
95
|
+
parse_frontmatter(file_enum)
|
168
96
|
|
169
|
-
|
170
|
-
|
171
|
-
|
97
|
+
loop do
|
98
|
+
current_line = get_next_line(file_enum)
|
99
|
+
break unless current_line
|
172
100
|
|
173
|
-
|
174
|
-
current_line = nil
|
175
|
-
begin
|
176
|
-
current_line = file_enum.next
|
177
|
-
rescue StopIteration
|
178
|
-
break # End of file
|
101
|
+
handle_line(current_line, file_enum)
|
179
102
|
end
|
103
|
+
@output_lines
|
104
|
+
end
|
180
105
|
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
elsif (match_data = current_line.match(CODE_BLOCK_START_REGEX))
|
199
|
-
output_lines << current_line # The opening ```lang line
|
200
|
-
current_block_lang = match_data[1].downcase
|
201
|
-
in_code_block = true
|
202
|
-
current_code_content = "" # Reset for the new block
|
203
|
-
next # Continue to the next line from the file
|
204
|
-
# Case 3: Any other line (normal text, non-executable ```foo, or generic ```RESULT not after an exec block)
|
205
|
-
else
|
206
|
-
output_lines << current_line
|
207
|
-
next # Continue to the next line from the file
|
208
|
-
end
|
209
|
-
elsif current_line.strip == "```" # We are in_code_block (current_block_lang is set, current_code_content is accumulating)
|
210
|
-
# Case 4: End of the current code block (```)
|
211
|
-
output_lines << current_line # The closing ``` of the code block
|
212
|
-
|
213
|
-
execute_this_block = true # Assume execution by default
|
214
|
-
lines_to_pass_through_if_skipped = [] # For blank line + RESULT header if skipping
|
215
|
-
blank_line_before_new_result = nil # If a blank line is consumed but block is still executed
|
216
|
-
|
217
|
-
# Peek ahead logic
|
218
|
-
peek1 = nil
|
219
|
-
begin; peek1 = file_enum.peek; rescue StopIteration; end # If EOF, execute_this_block remains true
|
220
|
-
|
221
|
-
current_lang_config = SUPPORTED_LANGUAGES[current_block_lang] # Get config for the current block
|
222
|
-
is_ruby_style_result = current_lang_config && current_lang_config[:result_block_type] == "ruby"
|
223
|
-
|
224
|
-
expected_header_regex = is_ruby_style_result ? /^```ruby\s+RESULT$/i : /^```RESULT$/i
|
225
|
-
|
226
|
-
if peek1 && peek1.match?(expected_header_regex)
|
227
|
-
execute_this_block = false
|
228
|
-
lines_to_pass_through_if_skipped << file_enum.next # Consume RESULT header
|
229
|
-
elsif peek1 && peek1.strip == "" # Blank line detected
|
230
|
-
consumed_blank_line = file_enum.next # Consume the blank line from enum
|
231
|
-
|
232
|
-
peek2 = nil
|
233
|
-
begin; peek2 = file_enum.peek; rescue StopIteration; end # EOF after blank line
|
234
|
-
|
235
|
-
if peek2 && peek2.match?(expected_header_regex)
|
236
|
-
execute_this_block = false
|
237
|
-
lines_to_pass_through_if_skipped << consumed_blank_line # Add consumed blank line
|
238
|
-
lines_to_pass_through_if_skipped << file_enum.next # Consume RESULT header
|
239
|
-
else
|
240
|
-
# Blank line, but not followed by a RESULT header (or EOF after blank).
|
241
|
-
# We will execute the block. The consumed_blank_line should be outputted.
|
242
|
-
blank_line_before_new_result = consumed_blank_line
|
243
|
-
# execute_this_block remains true
|
244
|
-
end
|
245
|
-
end
|
246
|
-
# If peek1 was nil (EOF) or something else not matching, execute_this_block remains true.
|
247
|
-
|
248
|
-
if execute_this_block
|
249
|
-
output_lines << blank_line_before_new_result if blank_line_before_new_result
|
250
|
-
if !current_code_content.strip.empty?
|
251
|
-
warn "Executing #{current_block_lang} code block..."
|
252
|
-
result_output = execute_code_block(current_code_content, current_block_lang, temp_dir)
|
253
|
-
|
254
|
-
output_lines << "\n" if blank_line_before_new_result.nil?
|
255
|
-
output_lines << (is_ruby_style_result ? "```ruby RESULT\n" : "```RESULT\n")
|
256
|
-
output_lines << result_output
|
257
|
-
output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
|
258
|
-
output_lines << "```\n\n" # Ensures a blank line after the RESULT block
|
259
|
-
else
|
260
|
-
warn "Skipping empty code block for language '#{current_block_lang}'."
|
261
|
-
end
|
262
|
-
else # Do not execute; a RESULT block follows and was identified
|
263
|
-
lang_specific_result_type = is_ruby_style_result ? "```ruby RESULT" : "```RESULT"
|
264
|
-
warn "Found existing '#{lang_specific_result_type}' block for current #{current_block_lang} block, skipping execution."
|
265
|
-
|
266
|
-
output_lines.concat(lines_to_pass_through_if_skipped) # Add blank line (if any) and RESULT header
|
267
|
-
|
268
|
-
# Consume the rest of the RESULT block content until its closing ```
|
269
|
-
begin
|
270
|
-
loop do
|
271
|
-
result_block_line = file_enum.next
|
272
|
-
output_lines << result_block_line
|
273
|
-
break if result_block_line.strip == "```"
|
274
|
-
end
|
275
|
-
rescue StopIteration
|
276
|
-
warn "Warning: End of file reached inside a skipped 'RESULT' block."
|
277
|
-
break # Exit main loop
|
278
|
-
end
|
106
|
+
private
|
107
|
+
|
108
|
+
def parse_frontmatter(file_enum)
|
109
|
+
first_line = peek_next_line(file_enum)
|
110
|
+
return unless first_line&.strip == "---"
|
111
|
+
|
112
|
+
# Consume the opening ---
|
113
|
+
@output_lines << file_enum.next
|
114
|
+
frontmatter_lines = []
|
115
|
+
|
116
|
+
loop do
|
117
|
+
line = get_next_line(file_enum)
|
118
|
+
break unless line
|
119
|
+
|
120
|
+
if line.strip == "---"
|
121
|
+
@output_lines << line
|
122
|
+
break
|
279
123
|
end
|
280
124
|
|
281
|
-
|
282
|
-
|
283
|
-
next
|
284
|
-
# Case 5: Line inside an active code block
|
285
|
-
else
|
286
|
-
current_code_content += current_line
|
287
|
-
output_lines << current_line # This line is part of the original code block being recorded
|
288
|
-
next # Continue to the next line from the file
|
125
|
+
frontmatter_lines << line
|
126
|
+
@output_lines << line
|
289
127
|
end
|
290
|
-
end # loop
|
291
128
|
|
292
|
-
|
293
|
-
|
294
|
-
temp_output_file.write(output_lines.join(""))
|
295
|
-
temp_output_file.close
|
129
|
+
return if frontmatter_lines.empty?
|
130
|
+
|
296
131
|
begin
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
FileUtils.rm_f(temp_output_file.path)
|
132
|
+
frontmatter = YAML.safe_load(frontmatter_lines.join)
|
133
|
+
extract_aliases(frontmatter) if frontmatter.is_a?(Hash)
|
134
|
+
rescue YAML::SyntaxError => e
|
135
|
+
warn "Warning: Invalid YAML frontmatter: #{e.message}"
|
302
136
|
end
|
303
137
|
end
|
304
|
-
warn "Markdown processing complete. Output written to #{input_file_path}"
|
305
|
-
true # Indicate success
|
306
|
-
end
|
307
138
|
|
308
|
-
|
139
|
+
def extract_aliases(frontmatter)
|
140
|
+
markdown_run_config = frontmatter["markdown-run"]
|
141
|
+
return unless markdown_run_config.is_a?(Hash)
|
142
|
+
|
143
|
+
aliases = markdown_run_config["alias"]
|
144
|
+
return unless aliases.is_a?(Array)
|
145
|
+
|
146
|
+
aliases.each do |alias_config|
|
147
|
+
next unless alias_config.is_a?(Hash)
|
309
148
|
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
gem "minitest", "5.25.5" # Specify the required version
|
315
|
-
gem "rcodetools"
|
149
|
+
alias_config.each do |alias_name, target_lang|
|
150
|
+
@aliases[alias_name.to_s] = target_lang.to_s
|
151
|
+
end
|
152
|
+
end
|
316
153
|
end
|
317
154
|
|
318
|
-
|
319
|
-
|
155
|
+
def resolve_language(lang)
|
156
|
+
@aliases[lang] || lang
|
157
|
+
end
|
320
158
|
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
@temp_dir = Dir.mktmpdir("markdown_exec_tests")
|
325
|
-
@test_md_file_path = File.join(@temp_dir, "test.md")
|
159
|
+
def ruby_style_result?(lang)
|
160
|
+
lang_config = SUPPORTED_LANGUAGES[lang]
|
161
|
+
lang_config && lang_config[:result_block_type] == "ruby"
|
326
162
|
end
|
327
163
|
|
328
|
-
def
|
329
|
-
|
164
|
+
def result_block_header(lang)
|
165
|
+
ruby_style_result?(lang) ? "```ruby RESULT\n" : "```RESULT\n"
|
330
166
|
end
|
331
167
|
|
332
|
-
def
|
333
|
-
|
334
|
-
@test_md_file_path
|
168
|
+
def result_block_regex(lang)
|
169
|
+
ruby_style_result?(lang) ? /^```ruby\s+RESULT$/i : /^```RESULT$/i
|
335
170
|
end
|
336
171
|
|
337
|
-
def
|
338
|
-
|
172
|
+
def is_block_end?(line)
|
173
|
+
line.strip == "```"
|
339
174
|
end
|
340
175
|
|
341
|
-
def
|
342
|
-
|
343
|
-
assert process_markdown_file_main(@test_md_file_path), "Processing empty file should succeed"
|
344
|
-
assert_equal "", read_md_file.strip, "Empty file should remain empty after processing"
|
176
|
+
def has_content?(content)
|
177
|
+
!content.strip.empty?
|
345
178
|
end
|
346
179
|
|
347
|
-
def
|
348
|
-
|
180
|
+
def add_result_block(result_output, blank_line_before_new_result)
|
181
|
+
@output_lines << "\n" if blank_line_before_new_result.nil?
|
182
|
+
@output_lines << result_block_header(@current_block_lang)
|
183
|
+
@output_lines << result_output
|
184
|
+
@output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
|
185
|
+
@output_lines << "```\n\n"
|
186
|
+
end
|
349
187
|
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
```
|
354
|
-
MARKDOWN
|
355
|
-
create_md_file(md_content)
|
356
|
-
process_markdown_file_main(@test_md_file_path)
|
188
|
+
def line_matches_pattern?(line, pattern)
|
189
|
+
line && line.match?(pattern)
|
190
|
+
end
|
357
191
|
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
```
|
192
|
+
def is_blank_line?(line)
|
193
|
+
line && line.strip == ""
|
194
|
+
end
|
362
195
|
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
assert_equal expected_output, read_md_file.strip
|
196
|
+
def safe_enum_operation(file_enum, operation)
|
197
|
+
file_enum.send(operation)
|
198
|
+
rescue StopIteration
|
199
|
+
nil
|
368
200
|
end
|
369
201
|
|
370
|
-
def
|
371
|
-
|
372
|
-
|
373
|
-
puts "Hello from Ruby"
|
374
|
-
p 1 + 2
|
375
|
-
```
|
376
|
-
MARKDOWN
|
377
|
-
create_md_file(md_content)
|
378
|
-
process_markdown_file_main(@test_md_file_path)
|
202
|
+
def get_next_line(file_enum)
|
203
|
+
safe_enum_operation(file_enum, :next)
|
204
|
+
end
|
379
205
|
|
380
|
-
|
381
|
-
|
382
|
-
assert file_content.include?("```ruby RESULT\n"), "Ruby RESULT block should be created"
|
383
|
-
assert file_content.include?("3"), "Output from p 1 + 2 should be in the result"
|
206
|
+
def peek_next_line(file_enum)
|
207
|
+
safe_enum_operation(file_enum, :peek)
|
384
208
|
end
|
385
209
|
|
386
|
-
def
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
210
|
+
def handle_line(current_line, file_enum)
|
211
|
+
case @state
|
212
|
+
when :outside_code_block
|
213
|
+
handle_outside_code_block(current_line, file_enum)
|
214
|
+
when :inside_code_block
|
215
|
+
handle_inside_code_block(current_line, file_enum)
|
216
|
+
when :inside_result_block
|
217
|
+
handle_inside_result_block(current_line, file_enum)
|
218
|
+
end
|
219
|
+
end
|
391
220
|
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
221
|
+
def handle_outside_code_block(current_line, file_enum)
|
222
|
+
if current_line.match?(/^```ruby\s+RESULT$/i)
|
223
|
+
handle_existing_ruby_result_block(current_line, file_enum)
|
224
|
+
elsif (match_data = current_line.match(/^```(\w+)$/i))
|
225
|
+
lang = match_data[1].downcase
|
226
|
+
resolved_lang = resolve_language(lang)
|
227
|
+
if SUPPORTED_LANGUAGES.key?(resolved_lang)
|
228
|
+
start_code_block(current_line, lang)
|
229
|
+
else
|
230
|
+
@output_lines << current_line
|
231
|
+
end
|
232
|
+
else
|
233
|
+
@output_lines << current_line
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
def handle_inside_code_block(current_line, file_enum)
|
238
|
+
if is_block_end?(current_line)
|
239
|
+
end_code_block(current_line, file_enum)
|
240
|
+
else
|
241
|
+
accumulate_code_content(current_line)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
def handle_inside_result_block(current_line, file_enum)
|
246
|
+
@output_lines << current_line
|
247
|
+
if is_block_end?(current_line)
|
248
|
+
@state = :outside_code_block
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
def handle_existing_ruby_result_block(current_line, file_enum)
|
253
|
+
warn "Found existing '```ruby RESULT' block, passing through."
|
254
|
+
@output_lines << current_line
|
255
|
+
@state = :inside_result_block
|
256
|
+
end
|
257
|
+
|
258
|
+
def start_code_block(current_line, lang)
|
259
|
+
@output_lines << current_line
|
260
|
+
@current_block_lang = resolve_language(lang)
|
261
|
+
@state = :inside_code_block
|
262
|
+
@current_code_content = ""
|
263
|
+
end
|
264
|
+
|
265
|
+
def accumulate_code_content(current_line)
|
266
|
+
@current_code_content += current_line
|
267
|
+
@output_lines << current_line
|
268
|
+
end
|
269
|
+
|
270
|
+
def end_code_block(current_line, file_enum)
|
271
|
+
@output_lines << current_line
|
272
|
+
|
273
|
+
decision = decide_execution(file_enum)
|
274
|
+
|
275
|
+
if decision[:execute]
|
276
|
+
execute_and_add_result(decision[:blank_line])
|
277
|
+
else
|
278
|
+
skip_and_pass_through_result(decision[:lines_to_pass_through], file_enum)
|
279
|
+
end
|
280
|
+
|
281
|
+
reset_code_block_state
|
282
|
+
end
|
283
|
+
|
284
|
+
def decide_execution(file_enum)
|
285
|
+
peek1 = peek_next_line(file_enum)
|
286
|
+
expected_header_regex = result_block_regex(@current_block_lang)
|
287
|
+
|
288
|
+
if line_matches_pattern?(peek1, expected_header_regex)
|
289
|
+
return { execute: false, lines_to_pass_through: [file_enum.next] }
|
290
|
+
elsif is_blank_line?(peek1)
|
291
|
+
consumed_blank_line = file_enum.next
|
292
|
+
peek2 = peek_next_line(file_enum)
|
293
|
+
|
294
|
+
if line_matches_pattern?(peek2, expected_header_regex)
|
295
|
+
return { execute: false, lines_to_pass_through: [consumed_blank_line, file_enum.next] }
|
296
|
+
else
|
297
|
+
return { execute: true, blank_line: consumed_blank_line }
|
298
|
+
end
|
299
|
+
else
|
300
|
+
return { execute: true }
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
def execute_and_add_result(blank_line_before_new_result)
|
305
|
+
@output_lines << blank_line_before_new_result if blank_line_before_new_result
|
306
|
+
|
307
|
+
if has_content?(@current_code_content)
|
308
|
+
result_output = execute_code_block(@current_code_content, @current_block_lang, @temp_dir)
|
309
|
+
add_result_block(result_output, blank_line_before_new_result)
|
310
|
+
else
|
311
|
+
warn "Skipping empty code block for language '#{@current_block_lang}'."
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
def skip_and_pass_through_result(lines_to_pass_through, file_enum)
|
316
|
+
lang_specific_result_type = ruby_style_result?(@current_block_lang) ? "```ruby RESULT" : "```RESULT"
|
398
317
|
|
399
|
-
|
318
|
+
warn "Found existing '#{lang_specific_result_type}' block for current #{@current_block_lang} block, skipping execution."
|
319
|
+
|
320
|
+
@output_lines.concat(lines_to_pass_through)
|
321
|
+
|
322
|
+
consume_result_block_content(file_enum)
|
400
323
|
end
|
401
324
|
|
402
|
-
def
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
325
|
+
def consume_result_block_content(file_enum)
|
326
|
+
begin
|
327
|
+
loop do
|
328
|
+
result_block_line = file_enum.next
|
329
|
+
@output_lines << result_block_line
|
330
|
+
break if is_block_end?(result_block_line)
|
331
|
+
end
|
332
|
+
rescue StopIteration
|
333
|
+
warn "Warning: End of file reached inside a skipped 'RESULT' block."
|
334
|
+
end
|
335
|
+
end
|
407
336
|
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
process_markdown_file_main(@test_md_file_path)
|
337
|
+
def reset_code_block_state
|
338
|
+
@state = :outside_code_block
|
339
|
+
@current_code_content = ""
|
340
|
+
end
|
341
|
+
end
|
414
342
|
|
415
|
-
|
343
|
+
def process_markdown_file_main(input_file_path)
|
344
|
+
unless File.exist?(input_file_path) && File.readable?(input_file_path)
|
345
|
+
warn "Error: Input file '#{input_file_path}' not found or not readable."
|
346
|
+
return false # Indicate failure
|
416
347
|
end
|
348
|
+
|
349
|
+
temp_dir = File.dirname(File.expand_path(input_file_path))
|
350
|
+
file_enum = File.foreach(input_file_path, chomp: false).to_enum
|
351
|
+
|
352
|
+
processor = MarkdownProcessor.new(temp_dir)
|
353
|
+
output_lines = processor.process_file(file_enum)
|
354
|
+
|
355
|
+
# Write the modified content back to the input file
|
356
|
+
Tempfile.create([ "md_exec_out_", File.extname(input_file_path) ], temp_dir) do |temp_output_file|
|
357
|
+
temp_output_file.write(output_lines.join(""))
|
358
|
+
temp_output_file.close
|
359
|
+
begin
|
360
|
+
FileUtils.mv(temp_output_file.path, input_file_path)
|
361
|
+
rescue Errno::EACCES, Errno::EXDEV
|
362
|
+
warn "Atomic move failed. Falling back to copy and delete."
|
363
|
+
FileUtils.cp(temp_output_file.path, input_file_path)
|
364
|
+
FileUtils.rm_f(temp_output_file.path)
|
365
|
+
end
|
366
|
+
end
|
367
|
+
warn "Markdown processing complete. Output written to #{input_file_path}"
|
368
|
+
true # Indicate success
|
417
369
|
end
|
370
|
+
|
371
|
+
if ARGV.empty?
|
372
|
+
puts "Running tests..."
|
373
|
+
require_relative "../test_markdown_exec"
|
374
|
+
else
|
375
|
+
process_markdown_file_main(ARGV[0])
|
418
376
|
end
|
419
377
|
|
420
|
-
process_markdown_file_main(ARGV[0]) unless ARGV.empty?
|
@@ -0,0 +1,80 @@
|
|
1
|
+
JS_CONFIG = {
|
2
|
+
command: ->(_code_content, temp_file_path) {
|
3
|
+
# Check if bun is available
|
4
|
+
bun_exists = system("command -v bun > /dev/null 2>&1")
|
5
|
+
if bun_exists
|
6
|
+
[ "bun #{temp_file_path}", {} ]
|
7
|
+
else
|
8
|
+
# Fallback to node if bun is not available
|
9
|
+
[ "node #{temp_file_path}", {} ]
|
10
|
+
end
|
11
|
+
},
|
12
|
+
temp_file_suffix: ".js",
|
13
|
+
error_handling: :js_specific # For specific stderr appending on error
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
SQLITE_CONFIG = {
|
17
|
+
command: ->(code_content, temp_file_path) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
|
18
|
+
temp_file_suffix: ".db" # Temp file is the database
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
SUPPORTED_LANGUAGES = {
|
22
|
+
"psql" => {
|
23
|
+
command: ->(code_content, _temp_file_path) {
|
24
|
+
psql_exists = system("command -v psql > /dev/null 2>&1")
|
25
|
+
unless psql_exists
|
26
|
+
abort "Error: psql command not found. Please install PostgreSQL or ensure psql is in your PATH."
|
27
|
+
end
|
28
|
+
[ "psql -A -t -X", { stdin_data: code_content } ]
|
29
|
+
}
|
30
|
+
},
|
31
|
+
"ruby" => {
|
32
|
+
command: ->(_code_content, temp_file_path) {
|
33
|
+
xmpfilter_exists = system("command -v xmpfilter > /dev/null 2>&1")
|
34
|
+
unless xmpfilter_exists
|
35
|
+
abort "Error: xmpfilter command not found. Please install xmpfilter or ensure it is in your PATH."
|
36
|
+
end
|
37
|
+
[ "xmpfilter #{temp_file_path}", {} ]
|
38
|
+
},
|
39
|
+
temp_file_suffix: ".rb",
|
40
|
+
result_block_type: "ruby" # For special '```ruby RESULT' blocks
|
41
|
+
},
|
42
|
+
"js" => JS_CONFIG,
|
43
|
+
"javascript" => JS_CONFIG, # Alias for js
|
44
|
+
"sql" => SQLITE_CONFIG,
|
45
|
+
"sqlite" => SQLITE_CONFIG,
|
46
|
+
"sqlite3" => SQLITE_CONFIG, # Alias for sqlite
|
47
|
+
"bash" => {
|
48
|
+
command: ->(_code_content, temp_file_path) {
|
49
|
+
bash_exists = system("command -v bash > /dev/null 2>&1")
|
50
|
+
unless bash_exists
|
51
|
+
abort "Error: bash command not found. Please ensure bash is in your PATH."
|
52
|
+
end
|
53
|
+
[ "bash #{temp_file_path}", {} ]
|
54
|
+
},
|
55
|
+
temp_file_suffix: ".sh"
|
56
|
+
},
|
57
|
+
"zsh" => {
|
58
|
+
command: ->(_code_content, temp_file_path) {
|
59
|
+
zsh_exists = system("command -v zsh > /dev/null 2>&1")
|
60
|
+
unless zsh_exists
|
61
|
+
abort "Error: zsh command not found. Please ensure zsh is in your PATH."
|
62
|
+
end
|
63
|
+
[ "zsh #{temp_file_path}", {} ]
|
64
|
+
},
|
65
|
+
temp_file_suffix: ".zsh"
|
66
|
+
},
|
67
|
+
"sh" => {
|
68
|
+
command: ->(_code_content, temp_file_path) {
|
69
|
+
sh_exists = system("command -v sh > /dev/null 2>&1")
|
70
|
+
unless sh_exists
|
71
|
+
abort "Error: sh command not found. Please ensure sh is in your PATH."
|
72
|
+
end
|
73
|
+
[ "sh #{temp_file_path}", {} ]
|
74
|
+
},
|
75
|
+
temp_file_suffix: ".sh"
|
76
|
+
}
|
77
|
+
}.freeze
|
78
|
+
|
79
|
+
LANGUAGE_REGEX_PART = SUPPORTED_LANGUAGES.keys.map { |lang| Regexp.escape(lang) }.join("|").freeze
|
80
|
+
CODE_BLOCK_START_REGEX = /^```(#{LANGUAGE_REGEX_PART})$/i
|
data/lib/markdown/run/version.rb
CHANGED
@@ -0,0 +1,134 @@
|
|
1
|
+
require "bundler/inline"
|
2
|
+
gemfile(true) do
|
3
|
+
source "https://rubygems.org"
|
4
|
+
gem "minitest", "5.25.5" # Specify the required version
|
5
|
+
gem "rcodetools"
|
6
|
+
end
|
7
|
+
|
8
|
+
require "minitest/test"
|
9
|
+
require "minitest/autorun"
|
10
|
+
require "fileutils"
|
11
|
+
require "tmpdir"
|
12
|
+
|
13
|
+
# --- Minitest Test Class Definition ---
|
14
|
+
class TestMarkdownExec < Minitest::Test
|
15
|
+
def setup
|
16
|
+
@temp_dir = Dir.mktmpdir("markdown_exec_tests")
|
17
|
+
@test_md_file_path = File.join(@temp_dir, "test.md")
|
18
|
+
end
|
19
|
+
|
20
|
+
def teardown
|
21
|
+
FileUtils.remove_entry @temp_dir if @temp_dir && Dir.exist?(@temp_dir)
|
22
|
+
end
|
23
|
+
|
24
|
+
def create_md_file(content)
|
25
|
+
File.write(@test_md_file_path, content)
|
26
|
+
@test_md_file_path
|
27
|
+
end
|
28
|
+
|
29
|
+
def read_md_file
|
30
|
+
File.read(@test_md_file_path)
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_script_runs_without_error_on_empty_file
|
34
|
+
create_md_file("")
|
35
|
+
assert process_markdown_file_main(@test_md_file_path), "Processing empty file should succeed"
|
36
|
+
assert_equal "", read_md_file.strip, "Empty file should remain empty after processing"
|
37
|
+
end
|
38
|
+
|
39
|
+
def test_psql_block_execution
|
40
|
+
skip "Skipping test_psql_block_execution on GitHub CI" if ENV['CI']
|
41
|
+
|
42
|
+
md_content = <<~MARKDOWN
|
43
|
+
```psql
|
44
|
+
SELECT 'hello psql test';
|
45
|
+
```
|
46
|
+
MARKDOWN
|
47
|
+
create_md_file(md_content)
|
48
|
+
process_markdown_file_main(@test_md_file_path)
|
49
|
+
|
50
|
+
expected_output = <<~MARKDOWN.strip
|
51
|
+
```psql
|
52
|
+
SELECT 'hello psql test';
|
53
|
+
```
|
54
|
+
|
55
|
+
```RESULT
|
56
|
+
hello psql test
|
57
|
+
```
|
58
|
+
MARKDOWN
|
59
|
+
assert_equal expected_output, read_md_file.strip
|
60
|
+
end
|
61
|
+
|
62
|
+
def test_ruby_block_execution_and_result_generation
|
63
|
+
md_content = <<~MARKDOWN
|
64
|
+
```ruby
|
65
|
+
puts "Hello from Ruby"
|
66
|
+
p 1 + 2
|
67
|
+
```
|
68
|
+
MARKDOWN
|
69
|
+
create_md_file(md_content)
|
70
|
+
process_markdown_file_main(@test_md_file_path)
|
71
|
+
|
72
|
+
file_content = read_md_file
|
73
|
+
assert file_content.include?("```ruby\nputs \"Hello from Ruby\""), "Original Ruby code should be present"
|
74
|
+
assert file_content.include?("```ruby RESULT\n"), "Ruby RESULT block should be created"
|
75
|
+
assert file_content.include?("3"), "Output from p 1 + 2 should be in the result"
|
76
|
+
end
|
77
|
+
|
78
|
+
def test_skip_execution_if_result_block_exists
|
79
|
+
original_content = <<~MARKDOWN
|
80
|
+
```psql
|
81
|
+
SELECT 'this should not run';
|
82
|
+
```
|
83
|
+
|
84
|
+
```RESULT
|
85
|
+
pre-existing result
|
86
|
+
```
|
87
|
+
MARKDOWN
|
88
|
+
create_md_file(original_content)
|
89
|
+
process_markdown_file_main(@test_md_file_path)
|
90
|
+
|
91
|
+
assert_equal original_content.strip, read_md_file.strip, "Should not execute if RESULT block exists"
|
92
|
+
end
|
93
|
+
|
94
|
+
def test_skip_execution_if_ruby_result_block_exists
|
95
|
+
original_content = <<~MARKDOWN
|
96
|
+
```ruby
|
97
|
+
puts "this should not run either"
|
98
|
+
```
|
99
|
+
|
100
|
+
```ruby RESULT
|
101
|
+
this is a pre-existing ruby result
|
102
|
+
```
|
103
|
+
MARKDOWN
|
104
|
+
create_md_file(original_content)
|
105
|
+
process_markdown_file_main(@test_md_file_path)
|
106
|
+
|
107
|
+
assert_equal original_content.strip, read_md_file.strip, "Should not execute if ```ruby RESULT block exists"
|
108
|
+
end
|
109
|
+
|
110
|
+
def test_frontmatter_alias_functionality
|
111
|
+
skip "Skipping test_frontmatter_alias_functionality on GitHub CI" if ENV['CI']
|
112
|
+
|
113
|
+
md_content = <<~MARKDOWN
|
114
|
+
---
|
115
|
+
markdown-run:
|
116
|
+
alias:
|
117
|
+
- sql: psql
|
118
|
+
---
|
119
|
+
|
120
|
+
# Test Document
|
121
|
+
|
122
|
+
```sql
|
123
|
+
SELECT 'aliased to psql' as test;
|
124
|
+
```
|
125
|
+
MARKDOWN
|
126
|
+
create_md_file(md_content)
|
127
|
+
process_markdown_file_main(@test_md_file_path)
|
128
|
+
|
129
|
+
file_content = read_md_file
|
130
|
+
assert file_content.include?("```sql\nSELECT 'aliased to psql' as test;"), "Original SQL code should be present"
|
131
|
+
assert file_content.include?("```RESULT\n"), "RESULT block should be created for aliased language"
|
132
|
+
assert file_content.include?("aliased to psql"), "Output should contain the expected result"
|
133
|
+
end
|
134
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: markdown-run
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.6
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Aurélien Bottazini
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-06-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rcodetools
|
@@ -52,10 +52,11 @@ files:
|
|
52
52
|
- CODE_OF_CONDUCT.md
|
53
53
|
- LICENSE.txt
|
54
54
|
- README.md
|
55
|
-
- docs/markdown-run-vscode.gif
|
56
55
|
- exe/markdown-run
|
56
|
+
- lib/language_configs.rb
|
57
57
|
- lib/markdown/run/version.rb
|
58
58
|
- markdown-run-sample.md
|
59
|
+
- test_markdown_exec.rb
|
59
60
|
homepage: https://rubygems.org/gems/markdown-run
|
60
61
|
licenses:
|
61
62
|
- MIT
|
Binary file
|