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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 921255b90c33af932d6ac33284a24d4f0fc61b5fe3ec0afb45f912c8fcc6e678
4
- data.tar.gz: 076f40ed6dc79bafbc7bdba3198f39fabf56754d74e1ef6bd38f451cc34ccafd
3
+ metadata.gz: d87607302aa1e1f4b95d1da2b1857f792d34b3d3d7985299db1a6730d7f49b57
4
+ data.tar.gz: d10dd3193558ce3c59770bd5b9ba7f73e8a5f3459cea9482deeff7bfc21333a1
5
5
  SHA512:
6
- metadata.gz: 01c7168af16a7788b80acb43553f2ae81ee0a8d1dfe7bf7cf048a6c7a77a898257fb1308b277282d9494464189322eab67b164a8c694f0aa8437db8bc8654d36
7
- data.tar.gz: 4f719733a2fbea191224f8c0fbcbb44f559d2d9bec1352f9d6c6caf851550593eb3622b2453cfdd584513d3b186f085157cf17cde5dfe61706f60a6d8b2e8050
6
+ metadata.gz: 1bbf13073d892d85165b1396a78ec6f274cb75a941fd8dc50f21b4c9636572966c2983f001851286fcb14531eed0a3d2177df42f8d1851e98f62175c5b83c78e
7
+ data.tar.gz: 97f9f35734c1440d8b21cc62176a65458bce898cb223368c65af4e0888f4bbd986dc6e403a382f982561dc6ad572f4f9c24c3f2f41d8c0aa6186cc6d5d25f656
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.3.5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.9] - 2025-06-02
4
+
5
+ - mermaid codeblocks
6
+
3
7
  ## [0.1.8] - 2025-06-01
4
8
 
5
9
  - Added run option
data/README.md CHANGED
@@ -16,6 +16,7 @@ Supported languages:
16
16
  - postgresql
17
17
  - bash
18
18
  - zsh
19
+ - mermaid (generates SVG diagrams)
19
20
 
20
21
  ## Installation
21
22
 
@@ -73,6 +74,24 @@ console.log("This will re-execute even if result exists");
73
74
  console.log("This will execute only if no result exists");
74
75
  ```
75
76
 
77
+ ### Mermaid diagrams
78
+
79
+ Mermaid blocks generate SVG files and insert markdown image tags:
80
+
81
+ ```mermaid
82
+ graph TD
83
+ A[Start] --> B[Process]
84
+ B --> C[End]
85
+ ```
86
+
87
+ This generates an SVG file in a directory named after the markdown file, with a filename that includes the markdown file's basename and a timestamp:
88
+
89
+ - Directory: `my-document/` (if the markdown file is `my-document.md`)
90
+ - Filename: `my-document-20250602-215507-a1289a799c29.svg`
91
+ - Image tag: `![Mermaid Diagram](my-document/my-document-20250602-215507-a1289a799c29.svg)`
92
+
93
+ Note: Requires `@mermaid-js/mermaid-cli` to be installed: `npm install -g @mermaid-js/mermaid-cli`
94
+
76
95
  ## Frontmatter
77
96
 
78
97
  You can add a yaml frontmatter to redefine code block behavior.
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ require "rake/testtask"
2
+
3
+ Rake::TestTask.new(:test) do |t|
4
+ t.libs << "test"
5
+ t.libs << "lib"
6
+ t.test_files = FileList["test/**/*_test.rb"]
7
+ t.verbose = true
8
+ end
9
+
10
+ desc "Run flog complexity analysis on the entire project"
11
+ task :flog do
12
+ puts "Running flog complexity analysis..."
13
+ system("flog lib/ exe/ test/")
14
+ end
15
+
16
+ desc "Run flog with detailed method breakdown"
17
+ task :flog_detailed do
18
+ puts "Running detailed flog analysis..."
19
+ system("flog -d lib/ exe/ test/")
20
+ end
21
+
22
+ task default: :test
data/exe/markdown-run CHANGED
@@ -1,443 +1,16 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require "tempfile"
4
- require "open3"
5
- require "fileutils"
6
- require "yaml"
7
- require_relative "../lib/language_configs"
3
+ require_relative "../lib/markdown_run"
8
4
 
9
5
  # Script to process markdown files, execute code blocks based on language,
10
6
  # and insert their results back into the markdown.
11
7
 
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
-
41
- def execute_code_block(code_content, lang, temp_dir)
42
- captured_status_obj = nil
43
-
44
- lang_key = lang.downcase # Normalize lang input for lookup
45
- lang_config = SUPPORTED_LANGUAGES[lang_key]
46
-
47
- if lang_config
48
- exit_status = 0
49
- warn "Executing #{lang_key} code block..." # Generic description
50
- cmd_lambda = lang_config[:command]
51
- temp_file_suffix = lang_config[:temp_file_suffix]
52
-
53
- captured_stdout = nil
54
- if temp_file_suffix # Needs a temporary file. Use lang_key as prefix.
55
- Tempfile.create([ lang_key, temp_file_suffix ], temp_dir) do |temp_file|
56
- temp_file.write(code_content)
57
- temp_file.close
58
- # Pass temp_file.path. Lambda decides if it needs code_content directly.
59
- command_to_run, exec_options = cmd_lambda.call(code_content, temp_file.path)
60
- captured_stdout, _, captured_status_obj = Open3.capture3(command_to_run, **exec_options)
61
- end
62
- else # Direct command execution (e.g., psql that takes stdin)
63
- # Pass nil for temp_file_path. Lambda decides if it needs code_content.
64
- command_to_run, exec_options = cmd_lambda.call(code_content, nil)
65
- captured_stdout, captured_stderr, captured_status_obj = Open3.capture3(command_to_run, **exec_options)
66
- end
67
- else
68
- warn "Unsupported language: #{lang}"
69
- result_output = "ERROR: Unsupported language: #{lang}"
70
- exit_status = 1 # Indicate an error
71
- # captured_status_obj remains nil, so common assignments below won't run
72
- end
73
-
74
- if captured_status_obj
75
- exit_status, result_output, stderr_output = format_captured_output(captured_status_obj, captured_stderr, captured_stdout, lang_config)
76
- end
77
-
78
- if exit_status != 0
79
- result_output = add_error_to_output(exit_status, lang_config, lang_key, result_output, stderr_output)
80
- end
81
- result_output
82
- end
83
-
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
- @current_block_rerun = false
92
- @current_block_run = true
93
- @aliases = {}
94
- end
95
-
96
- def process_file(file_enum)
97
- parse_frontmatter(file_enum)
98
-
99
- loop do
100
- current_line = get_next_line(file_enum)
101
- break unless current_line
102
-
103
- handle_line(current_line, file_enum)
104
- end
105
- @output_lines
106
- end
107
-
108
- private
109
-
110
- def parse_frontmatter(file_enum)
111
- first_line = peek_next_line(file_enum)
112
- return unless first_line&.strip == "---"
113
-
114
- # Consume the opening ---
115
- @output_lines << file_enum.next
116
- frontmatter_lines = []
117
-
118
- loop do
119
- line = get_next_line(file_enum)
120
- break unless line
121
-
122
- if line.strip == "---"
123
- @output_lines << line
124
- break
125
- end
126
-
127
- frontmatter_lines << line
128
- @output_lines << line
129
- end
130
-
131
- return if frontmatter_lines.empty?
132
-
133
- begin
134
- frontmatter = YAML.safe_load(frontmatter_lines.join)
135
- extract_aliases(frontmatter) if frontmatter.is_a?(Hash)
136
- rescue YAML::SyntaxError => e
137
- warn "Warning: Invalid YAML frontmatter: #{e.message}"
138
- end
139
- end
140
-
141
- def extract_aliases(frontmatter)
142
- markdown_run_config = frontmatter["markdown-run"]
143
- return unless markdown_run_config.is_a?(Hash)
144
-
145
- aliases = markdown_run_config["alias"]
146
- return unless aliases.is_a?(Array)
147
-
148
- aliases.each do |alias_config|
149
- next unless alias_config.is_a?(Hash)
150
-
151
- alias_config.each do |alias_name, target_lang|
152
- @aliases[alias_name.to_s] = target_lang.to_s
153
- end
154
- end
155
- end
156
-
157
- def resolve_language(lang)
158
- @aliases[lang] || lang
159
- end
160
-
161
- def ruby_style_result?(lang)
162
- lang_config = SUPPORTED_LANGUAGES[lang]
163
- lang_config && lang_config[:result_block_type] == "ruby"
164
- end
165
-
166
- def result_block_header(lang)
167
- ruby_style_result?(lang) ? "```ruby RESULT\n" : "```RESULT\n"
168
- end
169
-
170
- def result_block_regex(lang)
171
- ruby_style_result?(lang) ? /^```ruby\s+RESULT$/i : /^```RESULT$/i
172
- end
173
-
174
- def is_block_end?(line)
175
- line.strip == "```"
176
- end
177
-
178
- def has_content?(content)
179
- !content.strip.empty?
180
- end
181
-
182
- def add_result_block(result_output, blank_line_before_new_result)
183
- @output_lines << "\n" if blank_line_before_new_result.nil?
184
- @output_lines << result_block_header(@current_block_lang)
185
- @output_lines << result_output
186
- @output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
187
- @output_lines << "```\n\n"
188
- end
189
-
190
- def line_matches_pattern?(line, pattern)
191
- line && line.match?(pattern)
192
- end
193
-
194
- def is_blank_line?(line)
195
- line && line.strip == ""
196
- end
197
-
198
- def parse_rerun_option(options_string)
199
- return false unless options_string
200
-
201
- # Match rerun=true or rerun=false
202
- match = options_string.match(/rerun\s*=\s*(true|false)/i)
203
- return false unless match
204
-
205
- match[1].downcase == "true"
206
- end
207
-
208
- def parse_run_option(options_string)
209
- return true unless options_string
210
-
211
- # Match run=true or run=false
212
- match = options_string.match(/run\s*=\s*(true|false)/i)
213
- return true unless match
214
-
215
- match[1].downcase == "true"
216
- end
217
-
218
- def safe_enum_operation(file_enum, operation)
219
- file_enum.send(operation)
220
- rescue StopIteration
221
- nil
222
- end
223
-
224
- def get_next_line(file_enum)
225
- safe_enum_operation(file_enum, :next)
226
- end
227
-
228
- def peek_next_line(file_enum)
229
- safe_enum_operation(file_enum, :peek)
230
- end
231
-
232
- def handle_line(current_line, file_enum)
233
- case @state
234
- when :outside_code_block
235
- handle_outside_code_block(current_line, file_enum)
236
- when :inside_code_block
237
- handle_inside_code_block(current_line, file_enum)
238
- when :inside_result_block
239
- handle_inside_result_block(current_line, file_enum)
240
- end
241
- end
242
-
243
- def handle_outside_code_block(current_line, file_enum)
244
- if current_line.match?(/^```ruby\s+RESULT$/i)
245
- handle_existing_ruby_result_block(current_line, file_enum)
246
- elsif (match_data = current_line.match(/^```(\w+)(?:\s+(.*))?$/i))
247
- lang = match_data[1].downcase
248
- options_string = match_data[2]
249
- resolved_lang = resolve_language(lang)
250
- if SUPPORTED_LANGUAGES.key?(resolved_lang)
251
- start_code_block(current_line, lang, options_string)
252
- else
253
- @output_lines << current_line
254
- end
255
- else
256
- @output_lines << current_line
257
- end
258
- end
259
-
260
- def handle_inside_code_block(current_line, file_enum)
261
- if is_block_end?(current_line)
262
- end_code_block(current_line, file_enum)
263
- else
264
- accumulate_code_content(current_line)
265
- end
266
- end
267
-
268
- def handle_inside_result_block(current_line, file_enum)
269
- @output_lines << current_line
270
- if is_block_end?(current_line)
271
- @state = :outside_code_block
272
- end
273
- end
274
-
275
- def handle_existing_ruby_result_block(current_line, file_enum)
276
- warn "Found existing '```ruby RESULT' block, passing through."
277
- @output_lines << current_line
278
- @state = :inside_result_block
279
- end
280
-
281
- def start_code_block(current_line, lang, options_string = nil)
282
- @output_lines << current_line
283
- @current_block_lang = resolve_language(lang)
284
- @current_block_rerun = parse_rerun_option(options_string)
285
- @current_block_run = parse_run_option(options_string)
286
- @state = :inside_code_block
287
- @current_code_content = ""
288
- end
289
-
290
- def accumulate_code_content(current_line)
291
- @current_code_content += current_line
292
- @output_lines << current_line
293
- end
294
-
295
- def end_code_block(current_line, file_enum)
296
- @output_lines << current_line
297
-
298
- decision = decide_execution(file_enum)
299
-
300
- if decision[:execute]
301
- # If we consumed lines for rerun, don't add them to output (they'll be replaced)
302
- execute_and_add_result(decision[:blank_line])
303
- else
304
- skip_and_pass_through_result(decision[:lines_to_pass_through], file_enum)
305
- end
306
-
307
- reset_code_block_state
308
- end
309
-
310
- def decide_execution(file_enum)
311
- # If run=false, skip execution entirely (no result block creation)
312
- unless @current_block_run
313
- return { execute: false, lines_to_pass_through: [] }
314
- end
315
-
316
- peek1 = peek_next_line(file_enum)
317
- expected_header_regex = result_block_regex(@current_block_lang)
318
-
319
- if line_matches_pattern?(peek1, expected_header_regex)
320
- # If rerun=true, execute even if result block exists
321
- if @current_block_rerun
322
- # Consume the existing result block and execute
323
- consumed_lines = [file_enum.next]
324
- consume_existing_result_block(file_enum, consumed_lines)
325
- return { execute: true, consumed_lines: consumed_lines }
326
- else
327
- return { execute: false, lines_to_pass_through: [file_enum.next] }
328
- end
329
- elsif is_blank_line?(peek1)
330
- consumed_blank_line = file_enum.next
331
- peek2 = peek_next_line(file_enum)
332
-
333
- if line_matches_pattern?(peek2, expected_header_regex)
334
- if @current_block_rerun
335
- # Consume the blank line and existing result block, then execute
336
- consumed_lines = [consumed_blank_line, file_enum.next]
337
- consume_existing_result_block(file_enum, consumed_lines)
338
- return { execute: true, consumed_lines: consumed_lines, blank_line: consumed_blank_line }
339
- else
340
- return { execute: false, lines_to_pass_through: [consumed_blank_line, file_enum.next] }
341
- end
342
- else
343
- return { execute: true, blank_line: consumed_blank_line }
344
- end
345
- else
346
- return { execute: true }
347
- end
348
- end
349
-
350
- def execute_and_add_result(blank_line_before_new_result)
351
- @output_lines << blank_line_before_new_result if blank_line_before_new_result
352
-
353
- if has_content?(@current_code_content)
354
- result_output = execute_code_block(@current_code_content, @current_block_lang, @temp_dir)
355
- add_result_block(result_output, blank_line_before_new_result)
356
- else
357
- warn "Skipping empty code block for language '#{@current_block_lang}'."
358
- end
359
- end
360
-
361
- def skip_and_pass_through_result(lines_to_pass_through, file_enum)
362
- # Handle run=false case where there are no lines to pass through
363
- if lines_to_pass_through.empty?
364
- warn "Skipping execution due to run=false option."
365
- return
366
- end
367
-
368
- lang_specific_result_type = ruby_style_result?(@current_block_lang) ? "```ruby RESULT" : "```RESULT"
369
-
370
- warn "Found existing '#{lang_specific_result_type}' block for current #{@current_block_lang} block, skipping execution."
371
-
372
- @output_lines.concat(lines_to_pass_through)
373
-
374
- consume_result_block_content(file_enum)
375
- end
376
-
377
- def consume_result_block_content(file_enum)
378
- begin
379
- loop do
380
- result_block_line = file_enum.next
381
- @output_lines << result_block_line
382
- break if is_block_end?(result_block_line)
383
- end
384
- rescue StopIteration
385
- warn "Warning: End of file reached inside a skipped 'RESULT' block."
386
- end
387
- end
388
-
389
- def consume_existing_result_block(file_enum, consumed_lines)
390
- begin
391
- loop do
392
- result_block_line = file_enum.next
393
- consumed_lines << result_block_line
394
- break if is_block_end?(result_block_line)
395
- end
396
- rescue StopIteration
397
- warn "Warning: End of file reached while consuming existing result block for rerun."
398
- end
399
- end
400
-
401
- def reset_code_block_state
402
- @state = :outside_code_block
403
- @current_code_content = ""
404
- @current_block_rerun = false
405
- @current_block_run = true
406
- end
407
- end
408
-
409
- def process_markdown_file_main(input_file_path)
410
- unless File.exist?(input_file_path) && File.readable?(input_file_path)
411
- warn "Error: Input file '#{input_file_path}' not found or not readable."
412
- return false # Indicate failure
413
- end
414
-
415
- temp_dir = File.dirname(File.expand_path(input_file_path))
416
- file_enum = File.foreach(input_file_path, chomp: false).to_enum
417
-
418
- processor = MarkdownProcessor.new(temp_dir)
419
- output_lines = processor.process_file(file_enum)
420
-
421
- # Write the modified content back to the input file
422
- Tempfile.create([ "md_exec_out_", File.extname(input_file_path) ], temp_dir) do |temp_output_file|
423
- temp_output_file.write(output_lines.join(""))
424
- temp_output_file.close
425
- begin
426
- FileUtils.mv(temp_output_file.path, input_file_path)
427
- rescue Errno::EACCES, Errno::EXDEV
428
- warn "Atomic move failed. Falling back to copy and delete."
429
- FileUtils.cp(temp_output_file.path, input_file_path)
430
- FileUtils.rm_f(temp_output_file.path)
431
- end
432
- end
433
- warn "Markdown processing complete. Output written to #{input_file_path}"
434
- true # Indicate success
435
- end
436
-
437
8
  if ARGV.empty?
438
- puts "Running tests..."
439
- require_relative "../test_markdown_exec"
9
+ puts "Usage: markdown-run <file.md>"
10
+ puts "Processes a markdown file and executes code blocks, inserting their results."
11
+ exit 1 unless $0 != __FILE__ # Don't exit when loaded as a library
440
12
  else
441
- process_markdown_file_main(ARGV[0])
13
+ success = MarkdownRun.run_code_blocks(ARGV[0])
14
+ exit success ? 0 : 1 unless $0 != __FILE__ # Don't exit when loaded as a library
442
15
  end
443
16
 
@@ -0,0 +1,60 @@
1
+ require_relative "language_configs"
2
+
3
+ class CodeBlockParser
4
+ # Code block header pattern: ```language options
5
+ CODE_BLOCK_START_PATTERN = /^```(\w+)(?:\s+(.*))?$/i
6
+ RUBY_RESULT_BLOCK_PATTERN = /^```ruby\s+RESULT$/i
7
+ BLOCK_END_PATTERN = "```"
8
+
9
+ def initialize(frontmatter_parser)
10
+ @frontmatter_parser = frontmatter_parser
11
+ end
12
+
13
+ def parse_code_block_header(line)
14
+ match_data = line.match(CODE_BLOCK_START_PATTERN)
15
+ return nil unless match_data
16
+
17
+ lang = match_data[1].downcase
18
+ options_string = match_data[2]
19
+ resolved_lang = resolve_language(lang)
20
+
21
+ {
22
+ original_lang: lang,
23
+ resolved_lang: resolved_lang,
24
+ options_string: options_string,
25
+ is_supported: SUPPORTED_LANGUAGES.key?(resolved_lang)
26
+ }
27
+ end
28
+
29
+ def is_ruby_result_block?(line)
30
+ line.match?(RUBY_RESULT_BLOCK_PATTERN)
31
+ end
32
+
33
+ def is_block_end?(line)
34
+ line.strip == BLOCK_END_PATTERN
35
+ end
36
+
37
+ def parse_run_option(options_string)
38
+ parse_boolean_option(options_string, "run", true)
39
+ end
40
+
41
+ def parse_rerun_option(options_string)
42
+ parse_boolean_option(options_string, "rerun", false)
43
+ end
44
+
45
+ private
46
+
47
+ def resolve_language(lang)
48
+ @frontmatter_parser.resolve_language(lang)
49
+ end
50
+
51
+ def parse_boolean_option(options_string, option_name, default_value)
52
+ return default_value unless options_string
53
+
54
+ # Match option=true or option=false
55
+ match = options_string.match(/#{option_name}\s*=\s*(true|false)/i)
56
+ return default_value unless match
57
+
58
+ match[1].downcase == "true"
59
+ end
60
+ end