markdown-run 0.1.7 → 0.1.9
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/.tool-versions +1 -0
- data/CHANGELOG.md +8 -0
- data/README.md +34 -4
- data/Rakefile +22 -0
- data/exe/markdown-run +6 -409
- data/lib/code_block_parser.rb +60 -0
- data/lib/code_executor.rb +137 -0
- data/lib/enum_helper.rb +17 -0
- data/lib/execution_decider.rb +97 -0
- data/lib/frontmatter_parser.rb +72 -0
- data/lib/language_configs.rb +42 -7
- data/lib/markdown/run/version.rb +1 -1
- data/lib/markdown_file_writer.rb +25 -0
- data/lib/markdown_processor.rb +262 -0
- data/lib/markdown_run.rb +20 -0
- metadata +49 -11
- data/test_markdown_exec.rb +0 -204
@@ -0,0 +1,137 @@
|
|
1
|
+
require "tempfile"
|
2
|
+
require "open3"
|
3
|
+
require_relative "language_configs"
|
4
|
+
|
5
|
+
class CodeExecutor
|
6
|
+
def self.execute(code_content, lang, temp_dir, input_file_path = nil)
|
7
|
+
new.execute(code_content, lang, temp_dir, input_file_path)
|
8
|
+
end
|
9
|
+
|
10
|
+
def execute(code_content, lang, temp_dir, input_file_path = nil)
|
11
|
+
lang_key = lang.downcase
|
12
|
+
lang_config = SUPPORTED_LANGUAGES[lang_key]
|
13
|
+
|
14
|
+
return handle_unsupported_language(lang) unless lang_config
|
15
|
+
|
16
|
+
warn "Executing #{lang_key} code block..."
|
17
|
+
|
18
|
+
result = execute_with_config(code_content, lang_config, temp_dir, lang_key, input_file_path)
|
19
|
+
process_execution_result(result, lang_config, lang_key)
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def handle_unsupported_language(lang)
|
25
|
+
warn "Unsupported language: #{lang}"
|
26
|
+
"ERROR: Unsupported language: #{lang}"
|
27
|
+
end
|
28
|
+
|
29
|
+
def execute_with_config(code_content, lang_config, temp_dir, lang_key, input_file_path = nil)
|
30
|
+
cmd_lambda = lang_config[:command]
|
31
|
+
temp_file_suffix = lang_config[:temp_file_suffix]
|
32
|
+
|
33
|
+
if temp_file_suffix
|
34
|
+
execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path)
|
35
|
+
else
|
36
|
+
execute_direct_command(code_content, cmd_lambda)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path = nil)
|
41
|
+
result = nil
|
42
|
+
Tempfile.create([lang_key, temp_file_suffix], temp_dir) do |temp_file|
|
43
|
+
temp_file.write(code_content)
|
44
|
+
temp_file.close
|
45
|
+
command_to_run, exec_options = cmd_lambda.call(code_content, temp_file.path, input_file_path)
|
46
|
+
|
47
|
+
# Extract output_path if present (for mermaid)
|
48
|
+
output_path = exec_options.delete(:output_path) if exec_options.is_a?(Hash)
|
49
|
+
|
50
|
+
captured_stdout, captured_stderr, captured_status_obj = Open3.capture3(command_to_run, **exec_options)
|
51
|
+
result = {
|
52
|
+
stdout: captured_stdout,
|
53
|
+
stderr: captured_stderr,
|
54
|
+
status: captured_status_obj,
|
55
|
+
output_path: output_path # For mermaid SVG output
|
56
|
+
}
|
57
|
+
end
|
58
|
+
result
|
59
|
+
end
|
60
|
+
|
61
|
+
def execute_direct_command(code_content, cmd_lambda)
|
62
|
+
command_to_run, exec_options = cmd_lambda.call(code_content, nil)
|
63
|
+
captured_stdout, captured_stderr, captured_status_obj = Open3.capture3(command_to_run, **exec_options)
|
64
|
+
{ stdout: captured_stdout, stderr: captured_stderr, status: captured_status_obj }
|
65
|
+
end
|
66
|
+
|
67
|
+
def process_execution_result(result, lang_config, lang_key)
|
68
|
+
exit_status, result_output, stderr_output = format_captured_output(result, lang_config)
|
69
|
+
|
70
|
+
if exit_status != 0
|
71
|
+
result_output = add_error_to_output(exit_status, lang_config, lang_key, result_output, stderr_output)
|
72
|
+
elsif lang_config && lang_config[:result_handling] == :mermaid_svg
|
73
|
+
result_output = handle_mermaid_svg_result(result, lang_key)
|
74
|
+
end
|
75
|
+
|
76
|
+
result_output
|
77
|
+
end
|
78
|
+
|
79
|
+
def format_captured_output(result, lang_config)
|
80
|
+
result_output = result[:stdout]
|
81
|
+
stderr_output = result[:stderr]
|
82
|
+
exit_status = result[:status].exitstatus
|
83
|
+
|
84
|
+
# JS-specific: Append stderr to result if execution failed and stderr has content
|
85
|
+
if lang_config && lang_config[:error_handling] == :js_specific && exit_status != 0 && stderr_has_content?(stderr_output)
|
86
|
+
result_output += "\nStderr:\n#{stderr_output.strip}"
|
87
|
+
end
|
88
|
+
|
89
|
+
[exit_status, result_output, stderr_output]
|
90
|
+
end
|
91
|
+
|
92
|
+
def add_error_to_output(exit_status, lang_config, lang_key, result_output, stderr_output)
|
93
|
+
warn "Code execution failed for language '#{lang_key}' with status #{exit_status}."
|
94
|
+
warn "Stderr:\n#{stderr_output}" if stderr_has_content?(stderr_output)
|
95
|
+
|
96
|
+
is_js_error_already_formatted = lang_config && lang_config[:error_handling] == :js_specific && result_output.include?("Stderr:")
|
97
|
+
unless result_output.downcase.include?("error:") || is_js_error_already_formatted
|
98
|
+
error_prefix = "Execution failed (status: #{exit_status})."
|
99
|
+
error_prefix += " Stderr: #{stderr_output.strip}" if stderr_has_content?(stderr_output)
|
100
|
+
result_output = "#{error_prefix}\n#{result_output}"
|
101
|
+
end
|
102
|
+
result_output
|
103
|
+
end
|
104
|
+
|
105
|
+
def stderr_has_content?(stderr_output)
|
106
|
+
stderr_output && !stderr_output.strip.empty?
|
107
|
+
end
|
108
|
+
|
109
|
+
def handle_mermaid_svg_result(result, lang_key)
|
110
|
+
output_path = result[:output_path]
|
111
|
+
|
112
|
+
unless output_path && File.exist?(output_path)
|
113
|
+
warn "Warning: Mermaid SVG file not generated at expected path: #{output_path}"
|
114
|
+
return "Error: SVG file not generated"
|
115
|
+
end
|
116
|
+
|
117
|
+
# Generate relative path for the SVG file
|
118
|
+
# If the SVG is in a subdirectory, include the directory in the path
|
119
|
+
output_dir = File.dirname(output_path)
|
120
|
+
svg_filename = File.basename(output_path)
|
121
|
+
|
122
|
+
# Check if SVG is in a subdirectory (new behavior) or same directory (fallback)
|
123
|
+
parent_dir = File.dirname(output_dir)
|
124
|
+
if File.basename(output_dir) != File.basename(parent_dir)
|
125
|
+
# SVG is in a subdirectory, use relative path with directory
|
126
|
+
relative_path = "#{File.basename(output_dir)}/#{svg_filename}"
|
127
|
+
else
|
128
|
+
# SVG is in same directory (fallback behavior)
|
129
|
+
relative_path = svg_filename
|
130
|
+
end
|
131
|
+
|
132
|
+
warn "Generated Mermaid SVG: #{relative_path}"
|
133
|
+
|
134
|
+
# Return markdown image tag instead of typical result content
|
135
|
+
""
|
136
|
+
end
|
137
|
+
end
|
data/lib/enum_helper.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
module EnumHelper
|
2
|
+
private
|
3
|
+
|
4
|
+
def safe_enum_operation(file_enum, operation)
|
5
|
+
file_enum.send(operation)
|
6
|
+
rescue StopIteration
|
7
|
+
nil
|
8
|
+
end
|
9
|
+
|
10
|
+
def get_next_line(file_enum)
|
11
|
+
safe_enum_operation(file_enum, :next)
|
12
|
+
end
|
13
|
+
|
14
|
+
def peek_next_line(file_enum)
|
15
|
+
safe_enum_operation(file_enum, :peek)
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require_relative "enum_helper"
|
2
|
+
|
3
|
+
class ExecutionDecider
|
4
|
+
include EnumHelper
|
5
|
+
|
6
|
+
def initialize(current_block_run, current_block_rerun, current_block_lang)
|
7
|
+
@current_block_run = current_block_run
|
8
|
+
@current_block_rerun = current_block_rerun
|
9
|
+
@current_block_lang = current_block_lang
|
10
|
+
end
|
11
|
+
|
12
|
+
def decide(file_enum, result_block_regex_method)
|
13
|
+
return skip_execution_run_false if run_disabled?
|
14
|
+
|
15
|
+
expected_header_regex = result_block_regex_method.call(@current_block_lang)
|
16
|
+
peek1 = peek_next_line(file_enum)
|
17
|
+
|
18
|
+
if line_matches_pattern?(peek1, expected_header_regex)
|
19
|
+
handle_immediate_result_block(file_enum)
|
20
|
+
elsif is_blank_line?(peek1)
|
21
|
+
handle_blank_line_scenario(file_enum, expected_header_regex)
|
22
|
+
else
|
23
|
+
execute_without_existing_result
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def run_disabled?
|
30
|
+
!@current_block_run
|
31
|
+
end
|
32
|
+
|
33
|
+
def skip_execution_run_false
|
34
|
+
{ execute: false, lines_to_pass_through: [] }
|
35
|
+
end
|
36
|
+
|
37
|
+
def handle_immediate_result_block(file_enum)
|
38
|
+
if @current_block_rerun
|
39
|
+
execute_with_consumed_result(file_enum)
|
40
|
+
else
|
41
|
+
skip_with_existing_result(file_enum)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def handle_blank_line_scenario(file_enum, expected_header_regex)
|
46
|
+
consumed_blank_line = file_enum.next
|
47
|
+
peek2 = peek_next_line(file_enum)
|
48
|
+
|
49
|
+
if line_matches_pattern?(peek2, expected_header_regex)
|
50
|
+
handle_result_after_blank_line(file_enum, consumed_blank_line)
|
51
|
+
else
|
52
|
+
execute_with_blank_line(consumed_blank_line)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def handle_result_after_blank_line(file_enum, consumed_blank_line)
|
57
|
+
if @current_block_rerun
|
58
|
+
execute_with_consumed_result_and_blank(file_enum, consumed_blank_line)
|
59
|
+
else
|
60
|
+
skip_with_blank_and_result(file_enum, consumed_blank_line)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def execute_with_consumed_result(file_enum)
|
65
|
+
consumed_lines = [file_enum.next]
|
66
|
+
{ execute: true, consumed_lines: consumed_lines, consume_existing: true }
|
67
|
+
end
|
68
|
+
|
69
|
+
def skip_with_existing_result(file_enum)
|
70
|
+
{ execute: false, lines_to_pass_through: [file_enum.next] }
|
71
|
+
end
|
72
|
+
|
73
|
+
def execute_with_consumed_result_and_blank(file_enum, consumed_blank_line)
|
74
|
+
consumed_lines = [consumed_blank_line, file_enum.next]
|
75
|
+
{ execute: true, consumed_lines: consumed_lines, blank_line: consumed_blank_line, consume_existing: true }
|
76
|
+
end
|
77
|
+
|
78
|
+
def skip_with_blank_and_result(file_enum, consumed_blank_line)
|
79
|
+
{ execute: false, lines_to_pass_through: [consumed_blank_line, file_enum.next] }
|
80
|
+
end
|
81
|
+
|
82
|
+
def execute_with_blank_line(consumed_blank_line)
|
83
|
+
{ execute: true, blank_line: consumed_blank_line }
|
84
|
+
end
|
85
|
+
|
86
|
+
def execute_without_existing_result
|
87
|
+
{ execute: true }
|
88
|
+
end
|
89
|
+
|
90
|
+
def line_matches_pattern?(line, pattern)
|
91
|
+
line && line.match?(pattern)
|
92
|
+
end
|
93
|
+
|
94
|
+
def is_blank_line?(line)
|
95
|
+
line && line.strip == ""
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require "yaml"
|
2
|
+
require_relative "enum_helper"
|
3
|
+
|
4
|
+
class FrontmatterParser
|
5
|
+
include EnumHelper
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@aliases = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :aliases
|
12
|
+
|
13
|
+
def parse_frontmatter(file_enum, output_lines)
|
14
|
+
first_line = peek_next_line(file_enum)
|
15
|
+
return unless first_line&.strip == "---"
|
16
|
+
|
17
|
+
frontmatter_lines = collect_frontmatter_lines(file_enum, output_lines)
|
18
|
+
process_frontmatter_content(frontmatter_lines) unless frontmatter_lines.empty?
|
19
|
+
end
|
20
|
+
|
21
|
+
def resolve_language(lang)
|
22
|
+
@aliases[lang] || lang
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def collect_frontmatter_lines(file_enum, output_lines)
|
28
|
+
# Consume the opening ---
|
29
|
+
output_lines << file_enum.next
|
30
|
+
frontmatter_lines = []
|
31
|
+
|
32
|
+
loop do
|
33
|
+
line = get_next_line(file_enum)
|
34
|
+
break unless line
|
35
|
+
|
36
|
+
if line.strip == "---"
|
37
|
+
output_lines << line
|
38
|
+
break
|
39
|
+
end
|
40
|
+
|
41
|
+
frontmatter_lines << line
|
42
|
+
output_lines << line
|
43
|
+
end
|
44
|
+
|
45
|
+
frontmatter_lines
|
46
|
+
end
|
47
|
+
|
48
|
+
def process_frontmatter_content(frontmatter_lines)
|
49
|
+
begin
|
50
|
+
frontmatter = YAML.safe_load(frontmatter_lines.join)
|
51
|
+
extract_aliases(frontmatter) if frontmatter.is_a?(Hash)
|
52
|
+
rescue YAML::SyntaxError => e
|
53
|
+
warn "Warning: Invalid YAML frontmatter: #{e.message}"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def extract_aliases(frontmatter)
|
58
|
+
markdown_run_config = frontmatter["markdown-run"]
|
59
|
+
return unless markdown_run_config.is_a?(Hash)
|
60
|
+
|
61
|
+
aliases = markdown_run_config["alias"]
|
62
|
+
return unless aliases.is_a?(Array)
|
63
|
+
|
64
|
+
aliases.each do |alias_config|
|
65
|
+
next unless alias_config.is_a?(Hash)
|
66
|
+
|
67
|
+
alias_config.each do |alias_name, target_lang|
|
68
|
+
@aliases[alias_name.to_s] = target_lang.to_s
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
data/lib/language_configs.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
1
3
|
JS_CONFIG = {
|
2
|
-
command: ->(_code_content, temp_file_path) {
|
4
|
+
command: ->(_code_content, temp_file_path, input_file_path = nil) {
|
3
5
|
# Check if bun is available
|
4
6
|
bun_exists = system("command -v bun > /dev/null 2>&1")
|
5
7
|
if bun_exists
|
@@ -14,13 +16,13 @@ JS_CONFIG = {
|
|
14
16
|
}.freeze
|
15
17
|
|
16
18
|
SQLITE_CONFIG = {
|
17
|
-
command: ->(code_content, temp_file_path) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
|
19
|
+
command: ->(code_content, temp_file_path, input_file_path = nil) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
|
18
20
|
temp_file_suffix: ".db" # Temp file is the database
|
19
21
|
}.freeze
|
20
22
|
|
21
23
|
SUPPORTED_LANGUAGES = {
|
22
24
|
"psql" => {
|
23
|
-
command: ->(code_content, _temp_file_path) {
|
25
|
+
command: ->(code_content, _temp_file_path, input_file_path = nil) {
|
24
26
|
psql_exists = system("command -v psql > /dev/null 2>&1")
|
25
27
|
unless psql_exists
|
26
28
|
abort "Error: psql command not found. Please install PostgreSQL or ensure psql is in your PATH."
|
@@ -29,7 +31,7 @@ SUPPORTED_LANGUAGES = {
|
|
29
31
|
}
|
30
32
|
},
|
31
33
|
"ruby" => {
|
32
|
-
command: ->(_code_content, temp_file_path) {
|
34
|
+
command: ->(_code_content, temp_file_path, input_file_path = nil) {
|
33
35
|
xmpfilter_exists = system("command -v xmpfilter > /dev/null 2>&1")
|
34
36
|
unless xmpfilter_exists
|
35
37
|
abort "Error: xmpfilter command not found. Please install xmpfilter or ensure it is in your PATH."
|
@@ -45,7 +47,7 @@ SUPPORTED_LANGUAGES = {
|
|
45
47
|
"sqlite" => SQLITE_CONFIG,
|
46
48
|
"sqlite3" => SQLITE_CONFIG, # Alias for sqlite
|
47
49
|
"bash" => {
|
48
|
-
command: ->(_code_content, temp_file_path) {
|
50
|
+
command: ->(_code_content, temp_file_path, input_file_path = nil) {
|
49
51
|
bash_exists = system("command -v bash > /dev/null 2>&1")
|
50
52
|
unless bash_exists
|
51
53
|
abort "Error: bash command not found. Please ensure bash is in your PATH."
|
@@ -55,7 +57,7 @@ SUPPORTED_LANGUAGES = {
|
|
55
57
|
temp_file_suffix: ".sh"
|
56
58
|
},
|
57
59
|
"zsh" => {
|
58
|
-
command: ->(_code_content, temp_file_path) {
|
60
|
+
command: ->(_code_content, temp_file_path, input_file_path = nil) {
|
59
61
|
zsh_exists = system("command -v zsh > /dev/null 2>&1")
|
60
62
|
unless zsh_exists
|
61
63
|
abort "Error: zsh command not found. Please ensure zsh is in your PATH."
|
@@ -65,7 +67,7 @@ SUPPORTED_LANGUAGES = {
|
|
65
67
|
temp_file_suffix: ".zsh"
|
66
68
|
},
|
67
69
|
"sh" => {
|
68
|
-
command: ->(_code_content, temp_file_path) {
|
70
|
+
command: ->(_code_content, temp_file_path, input_file_path = nil) {
|
69
71
|
sh_exists = system("command -v sh > /dev/null 2>&1")
|
70
72
|
unless sh_exists
|
71
73
|
abort "Error: sh command not found. Please ensure sh is in your PATH."
|
@@ -73,6 +75,39 @@ SUPPORTED_LANGUAGES = {
|
|
73
75
|
[ "sh #{temp_file_path}", {} ]
|
74
76
|
},
|
75
77
|
temp_file_suffix: ".sh"
|
78
|
+
},
|
79
|
+
"mermaid" => {
|
80
|
+
command: ->(code_content, temp_file_path, input_file_path = nil) {
|
81
|
+
mmdc_exists = system("command -v mmdc > /dev/null 2>&1")
|
82
|
+
unless mmdc_exists
|
83
|
+
abort "Error: mmdc command not found. Please install @mermaid-js/mermaid-cli: npm install -g @mermaid-js/mermaid-cli"
|
84
|
+
end
|
85
|
+
|
86
|
+
# Generate SVG output file path with directory structure based on markdown file
|
87
|
+
if input_file_path
|
88
|
+
# Extract markdown file basename without extension
|
89
|
+
md_basename = File.basename(input_file_path, ".*")
|
90
|
+
|
91
|
+
# Create directory named after the markdown file
|
92
|
+
output_dir = File.join(File.dirname(input_file_path), md_basename)
|
93
|
+
Dir.mkdir(output_dir) unless Dir.exist?(output_dir)
|
94
|
+
|
95
|
+
# Generate unique filename with markdown basename prefix
|
96
|
+
timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
97
|
+
random_suffix = SecureRandom.hex(6)
|
98
|
+
svg_filename = "#{md_basename}-#{timestamp}-#{random_suffix}.svg"
|
99
|
+
output_path = File.join(output_dir, svg_filename)
|
100
|
+
else
|
101
|
+
# Fallback to old behavior if no input file path provided
|
102
|
+
input_dir = File.dirname(temp_file_path)
|
103
|
+
base_name = File.basename(temp_file_path, ".*")
|
104
|
+
output_path = File.join(input_dir, "#{base_name}.svg")
|
105
|
+
end
|
106
|
+
|
107
|
+
[ "mmdc -i #{temp_file_path} -o #{output_path}", { output_path: output_path } ]
|
108
|
+
},
|
109
|
+
temp_file_suffix: ".mmd",
|
110
|
+
result_handling: :mermaid_svg # Special handling for SVG generation
|
76
111
|
}
|
77
112
|
}.freeze
|
78
113
|
|
data/lib/markdown/run/version.rb
CHANGED
@@ -0,0 +1,25 @@
|
|
1
|
+
require "tempfile"
|
2
|
+
require "fileutils"
|
3
|
+
|
4
|
+
class MarkdownFileWriter
|
5
|
+
def self.write_output_to_file(output_lines, input_file_path)
|
6
|
+
temp_dir = File.dirname(File.expand_path(input_file_path))
|
7
|
+
|
8
|
+
# Write the modified content back to the input file using atomic operations
|
9
|
+
Tempfile.create([ "md_exec_out_", File.extname(input_file_path) ], temp_dir) do |temp_output_file|
|
10
|
+
temp_output_file.write(output_lines.join(""))
|
11
|
+
temp_output_file.close
|
12
|
+
|
13
|
+
begin
|
14
|
+
FileUtils.mv(temp_output_file.path, input_file_path)
|
15
|
+
rescue Errno::EACCES, Errno::EXDEV
|
16
|
+
warn "Atomic move failed. Falling back to copy and delete."
|
17
|
+
FileUtils.cp(temp_output_file.path, input_file_path)
|
18
|
+
FileUtils.rm_f(temp_output_file.path)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
warn "Markdown processing complete. Output written to #{input_file_path}"
|
23
|
+
true # Indicate success
|
24
|
+
end
|
25
|
+
end
|