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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c11a9addb3e251e9830fe2126d2d752dc8c04c879b607d1ee0ad80998d51b294
4
- data.tar.gz: '014954e8178d28729dfa86350a9e92aa91010892e6d177a5f7616dfd0e9ad72b'
3
+ metadata.gz: d87607302aa1e1f4b95d1da2b1857f792d34b3d3d7985299db1a6730d7f49b57
4
+ data.tar.gz: d10dd3193558ce3c59770bd5b9ba7f73e8a5f3459cea9482deeff7bfc21333a1
5
5
  SHA512:
6
- metadata.gz: 5a26f680f607d52d7ceb0b0f31cca84af2a811ed2a70f6f73f9ab6ae3f9032f9135943c9e765df4d9714cbbd766179de14bab8006148e1d65d27a26388dd935c
7
- data.tar.gz: 3ed109437211b62afd2dcdf1469e3103db9bd36a13be207f0ea636d67163c269e4a199e9a4b901ed4f7756319b5aa9c20f00e7613106be463c5139965f209fbf
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,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.9] - 2025-06-02
4
+
5
+ - mermaid codeblocks
6
+
7
+ ## [0.1.8] - 2025-06-01
8
+
9
+ - Added run option
10
+
3
11
  ## [0.1.7] - 2025-06-01
4
12
 
5
13
  - Added rerun functionality
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
 
@@ -54,14 +55,43 @@ example vscode keybinding
54
55
 
55
56
  ### Code block options
56
57
 
57
- - `rerun=true` or `rerun=false` for a code block to rerun or skip execution. `rerun=true` is the default if not specified
58
+ - `run=true` or `run=false` to control whether a code block should be executed at all. `run=true` is the default if not specified
59
+ - `rerun=true` or `rerun=false` to control whether a code block should be re-executed if a result block already exists. `rerun=false` is the default if not specified
58
60
 
59
- example:
61
+ Options can be combined. If `run=false` is specified, the code block will not execute regardless of the `rerun` setting.
60
62
 
61
- ```js rerun=false
62
- console.log("hello world");
63
+ Examples:
64
+
65
+ ```js run=false
66
+ console.log("This will not execute at all");
67
+ ```
68
+
69
+ ```js rerun=true
70
+ console.log("This will re-execute even if result exists");
71
+ ```
72
+
73
+ ```js run=true rerun=false
74
+ console.log("This will execute only if no result exists");
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]
63
85
  ```
64
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
+
65
95
  ## Frontmatter
66
96
 
67
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,419 +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
- @aliases = {}
93
- end
94
-
95
- def process_file(file_enum)
96
- parse_frontmatter(file_enum)
97
-
98
- loop do
99
- current_line = get_next_line(file_enum)
100
- break unless current_line
101
-
102
- handle_line(current_line, file_enum)
103
- end
104
- @output_lines
105
- end
106
-
107
- private
108
-
109
- def parse_frontmatter(file_enum)
110
- first_line = peek_next_line(file_enum)
111
- return unless first_line&.strip == "---"
112
-
113
- # Consume the opening ---
114
- @output_lines << file_enum.next
115
- frontmatter_lines = []
116
-
117
- loop do
118
- line = get_next_line(file_enum)
119
- break unless line
120
-
121
- if line.strip == "---"
122
- @output_lines << line
123
- break
124
- end
125
-
126
- frontmatter_lines << line
127
- @output_lines << line
128
- end
129
-
130
- return if frontmatter_lines.empty?
131
-
132
- begin
133
- frontmatter = YAML.safe_load(frontmatter_lines.join)
134
- extract_aliases(frontmatter) if frontmatter.is_a?(Hash)
135
- rescue YAML::SyntaxError => e
136
- warn "Warning: Invalid YAML frontmatter: #{e.message}"
137
- end
138
- end
139
-
140
- def extract_aliases(frontmatter)
141
- markdown_run_config = frontmatter["markdown-run"]
142
- return unless markdown_run_config.is_a?(Hash)
143
-
144
- aliases = markdown_run_config["alias"]
145
- return unless aliases.is_a?(Array)
146
-
147
- aliases.each do |alias_config|
148
- next unless alias_config.is_a?(Hash)
149
-
150
- alias_config.each do |alias_name, target_lang|
151
- @aliases[alias_name.to_s] = target_lang.to_s
152
- end
153
- end
154
- end
155
-
156
- def resolve_language(lang)
157
- @aliases[lang] || lang
158
- end
159
-
160
- def ruby_style_result?(lang)
161
- lang_config = SUPPORTED_LANGUAGES[lang]
162
- lang_config && lang_config[:result_block_type] == "ruby"
163
- end
164
-
165
- def result_block_header(lang)
166
- ruby_style_result?(lang) ? "```ruby RESULT\n" : "```RESULT\n"
167
- end
168
-
169
- def result_block_regex(lang)
170
- ruby_style_result?(lang) ? /^```ruby\s+RESULT$/i : /^```RESULT$/i
171
- end
172
-
173
- def is_block_end?(line)
174
- line.strip == "```"
175
- end
176
-
177
- def has_content?(content)
178
- !content.strip.empty?
179
- end
180
-
181
- def add_result_block(result_output, blank_line_before_new_result)
182
- @output_lines << "\n" if blank_line_before_new_result.nil?
183
- @output_lines << result_block_header(@current_block_lang)
184
- @output_lines << result_output
185
- @output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
186
- @output_lines << "```\n\n"
187
- end
188
-
189
- def line_matches_pattern?(line, pattern)
190
- line && line.match?(pattern)
191
- end
192
-
193
- def is_blank_line?(line)
194
- line && line.strip == ""
195
- end
196
-
197
- def parse_rerun_option(options_string)
198
- return false unless options_string
199
-
200
- # Match rerun=true or rerun=false
201
- match = options_string.match(/rerun\s*=\s*(true|false)/i)
202
- return false unless match
203
-
204
- match[1].downcase == "true"
205
- end
206
-
207
- def safe_enum_operation(file_enum, operation)
208
- file_enum.send(operation)
209
- rescue StopIteration
210
- nil
211
- end
212
-
213
- def get_next_line(file_enum)
214
- safe_enum_operation(file_enum, :next)
215
- end
216
-
217
- def peek_next_line(file_enum)
218
- safe_enum_operation(file_enum, :peek)
219
- end
220
-
221
- def handle_line(current_line, file_enum)
222
- case @state
223
- when :outside_code_block
224
- handle_outside_code_block(current_line, file_enum)
225
- when :inside_code_block
226
- handle_inside_code_block(current_line, file_enum)
227
- when :inside_result_block
228
- handle_inside_result_block(current_line, file_enum)
229
- end
230
- end
231
-
232
- def handle_outside_code_block(current_line, file_enum)
233
- if current_line.match?(/^```ruby\s+RESULT$/i)
234
- handle_existing_ruby_result_block(current_line, file_enum)
235
- elsif (match_data = current_line.match(/^```(\w+)(?:\s+(.*))?$/i))
236
- lang = match_data[1].downcase
237
- options_string = match_data[2]
238
- resolved_lang = resolve_language(lang)
239
- if SUPPORTED_LANGUAGES.key?(resolved_lang)
240
- start_code_block(current_line, lang, options_string)
241
- else
242
- @output_lines << current_line
243
- end
244
- else
245
- @output_lines << current_line
246
- end
247
- end
248
-
249
- def handle_inside_code_block(current_line, file_enum)
250
- if is_block_end?(current_line)
251
- end_code_block(current_line, file_enum)
252
- else
253
- accumulate_code_content(current_line)
254
- end
255
- end
256
-
257
- def handle_inside_result_block(current_line, file_enum)
258
- @output_lines << current_line
259
- if is_block_end?(current_line)
260
- @state = :outside_code_block
261
- end
262
- end
263
-
264
- def handle_existing_ruby_result_block(current_line, file_enum)
265
- warn "Found existing '```ruby RESULT' block, passing through."
266
- @output_lines << current_line
267
- @state = :inside_result_block
268
- end
269
-
270
- def start_code_block(current_line, lang, options_string = nil)
271
- @output_lines << current_line
272
- @current_block_lang = resolve_language(lang)
273
- @current_block_rerun = parse_rerun_option(options_string)
274
- @state = :inside_code_block
275
- @current_code_content = ""
276
- end
277
-
278
- def accumulate_code_content(current_line)
279
- @current_code_content += current_line
280
- @output_lines << current_line
281
- end
282
-
283
- def end_code_block(current_line, file_enum)
284
- @output_lines << current_line
285
-
286
- decision = decide_execution(file_enum)
287
-
288
- if decision[:execute]
289
- # If we consumed lines for rerun, don't add them to output (they'll be replaced)
290
- execute_and_add_result(decision[:blank_line])
291
- else
292
- skip_and_pass_through_result(decision[:lines_to_pass_through], file_enum)
293
- end
294
-
295
- reset_code_block_state
296
- end
297
-
298
- def decide_execution(file_enum)
299
- peek1 = peek_next_line(file_enum)
300
- expected_header_regex = result_block_regex(@current_block_lang)
301
-
302
- if line_matches_pattern?(peek1, expected_header_regex)
303
- # If rerun=true, execute even if result block exists
304
- if @current_block_rerun
305
- # Consume the existing result block and execute
306
- consumed_lines = [file_enum.next]
307
- consume_existing_result_block(file_enum, consumed_lines)
308
- return { execute: true, consumed_lines: consumed_lines }
309
- else
310
- return { execute: false, lines_to_pass_through: [file_enum.next] }
311
- end
312
- elsif is_blank_line?(peek1)
313
- consumed_blank_line = file_enum.next
314
- peek2 = peek_next_line(file_enum)
315
-
316
- if line_matches_pattern?(peek2, expected_header_regex)
317
- if @current_block_rerun
318
- # Consume the blank line and existing result block, then execute
319
- consumed_lines = [consumed_blank_line, file_enum.next]
320
- consume_existing_result_block(file_enum, consumed_lines)
321
- return { execute: true, consumed_lines: consumed_lines, blank_line: consumed_blank_line }
322
- else
323
- return { execute: false, lines_to_pass_through: [consumed_blank_line, file_enum.next] }
324
- end
325
- else
326
- return { execute: true, blank_line: consumed_blank_line }
327
- end
328
- else
329
- return { execute: true }
330
- end
331
- end
332
-
333
- def execute_and_add_result(blank_line_before_new_result)
334
- @output_lines << blank_line_before_new_result if blank_line_before_new_result
335
-
336
- if has_content?(@current_code_content)
337
- result_output = execute_code_block(@current_code_content, @current_block_lang, @temp_dir)
338
- add_result_block(result_output, blank_line_before_new_result)
339
- else
340
- warn "Skipping empty code block for language '#{@current_block_lang}'."
341
- end
342
- end
343
-
344
- def skip_and_pass_through_result(lines_to_pass_through, file_enum)
345
- lang_specific_result_type = ruby_style_result?(@current_block_lang) ? "```ruby RESULT" : "```RESULT"
346
-
347
- warn "Found existing '#{lang_specific_result_type}' block for current #{@current_block_lang} block, skipping execution."
348
-
349
- @output_lines.concat(lines_to_pass_through)
350
-
351
- consume_result_block_content(file_enum)
352
- end
353
-
354
- def consume_result_block_content(file_enum)
355
- begin
356
- loop do
357
- result_block_line = file_enum.next
358
- @output_lines << result_block_line
359
- break if is_block_end?(result_block_line)
360
- end
361
- rescue StopIteration
362
- warn "Warning: End of file reached inside a skipped 'RESULT' block."
363
- end
364
- end
365
-
366
- def consume_existing_result_block(file_enum, consumed_lines)
367
- begin
368
- loop do
369
- result_block_line = file_enum.next
370
- consumed_lines << result_block_line
371
- break if is_block_end?(result_block_line)
372
- end
373
- rescue StopIteration
374
- warn "Warning: End of file reached while consuming existing result block for rerun."
375
- end
376
- end
377
-
378
- def reset_code_block_state
379
- @state = :outside_code_block
380
- @current_code_content = ""
381
- @current_block_rerun = false
382
- end
383
- end
384
-
385
- def process_markdown_file_main(input_file_path)
386
- unless File.exist?(input_file_path) && File.readable?(input_file_path)
387
- warn "Error: Input file '#{input_file_path}' not found or not readable."
388
- return false # Indicate failure
389
- end
390
-
391
- temp_dir = File.dirname(File.expand_path(input_file_path))
392
- file_enum = File.foreach(input_file_path, chomp: false).to_enum
393
-
394
- processor = MarkdownProcessor.new(temp_dir)
395
- output_lines = processor.process_file(file_enum)
396
-
397
- # Write the modified content back to the input file
398
- Tempfile.create([ "md_exec_out_", File.extname(input_file_path) ], temp_dir) do |temp_output_file|
399
- temp_output_file.write(output_lines.join(""))
400
- temp_output_file.close
401
- begin
402
- FileUtils.mv(temp_output_file.path, input_file_path)
403
- rescue Errno::EACCES, Errno::EXDEV
404
- warn "Atomic move failed. Falling back to copy and delete."
405
- FileUtils.cp(temp_output_file.path, input_file_path)
406
- FileUtils.rm_f(temp_output_file.path)
407
- end
408
- end
409
- warn "Markdown processing complete. Output written to #{input_file_path}"
410
- true # Indicate success
411
- end
412
-
413
8
  if ARGV.empty?
414
- puts "Running tests..."
415
- 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
416
12
  else
417
- 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
418
15
  end
419
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