markdown-run 0.1.8 → 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.
@@ -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
+ "![Mermaid Diagram](#{relative_path})"
136
+ end
137
+ end
@@ -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
@@ -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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Markdown
4
4
  module Run
5
- VERSION = "0.1.8"
5
+ VERSION = "0.1.9"
6
6
  end
7
7
  end
@@ -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