markymark 0.1.0

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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +29 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +255 -0
  6. data/Rakefile +8 -0
  7. data/assets/.gitkeep +0 -0
  8. data/assets/Markymark.icns +0 -0
  9. data/assets/Markymark.iconset/icon_128x128.png +0 -0
  10. data/assets/Markymark.iconset/icon_128x128@2x.png +0 -0
  11. data/assets/Markymark.iconset/icon_16x16.png +0 -0
  12. data/assets/Markymark.iconset/icon_16x16@2x.png +0 -0
  13. data/assets/Markymark.iconset/icon_256x256.png +0 -0
  14. data/assets/Markymark.iconset/icon_256x256@2x.png +0 -0
  15. data/assets/Markymark.iconset/icon_32x32.png +0 -0
  16. data/assets/Markymark.iconset/icon_32x32@2x.png +0 -0
  17. data/assets/Markymark.iconset/icon_512x512.png +0 -0
  18. data/assets/Markymark.iconset/icon_512x512@2x.png +0 -0
  19. data/assets/README.md +3 -0
  20. data/assets/marky-mark-dj.jpg +0 -0
  21. data/assets/marky-mark-icon.png +0 -0
  22. data/assets/marky-mark-icon2.png +0 -0
  23. data/config.ru +19 -0
  24. data/docs/for_llms.md +141 -0
  25. data/docs/plans/2025-12-18-macos-app-installer-design.md +149 -0
  26. data/exe/markymark +5 -0
  27. data/lib/markymark/app_installer.rb +437 -0
  28. data/lib/markymark/cli.rb +497 -0
  29. data/lib/markymark/init_wizard.rb +186 -0
  30. data/lib/markymark/pumadev_manager.rb +194 -0
  31. data/lib/markymark/server_simple.rb +452 -0
  32. data/lib/markymark/version.rb +5 -0
  33. data/lib/markymark.rb +12 -0
  34. data/lib/public/css/style.css +350 -0
  35. data/lib/public/js/app.js +186 -0
  36. data/lib/public/js/theme.js +79 -0
  37. data/lib/public/js/tree.js +124 -0
  38. data/lib/views/browse.erb +225 -0
  39. data/lib/views/index.erb +37 -0
  40. data/lib/views/simple.erb +806 -0
  41. data/sig/markymark.rbs +4 -0
  42. metadata +242 -0
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Markymark
6
+ # Manages pumadev integration for markymark
7
+ class PumadevManager
8
+ class << self
9
+ def symlink_path
10
+ File.expand_path('~/.puma-dev/markymark')
11
+ end
12
+
13
+ def env_file_path
14
+ File.expand_path('~/.markymark/.pumadev_root')
15
+ end
16
+
17
+ def marker_file_path
18
+ File.expand_path('~/.markymark/.pumadev_mode')
19
+ end
20
+
21
+ # Check if pumadev mode is currently active
22
+ def active?
23
+ File.exist?(marker_file_path) && File.symlink?(symlink_path)
24
+ end
25
+
26
+ # Check if puma-dev is installed
27
+ def puma_dev_installed?
28
+ `which puma-dev`.strip != ''
29
+ end
30
+
31
+ # Get the gem installation directory
32
+ def gem_directory
33
+ gem_spec = Gem::Specification.find_by_name('markymark')
34
+ gem_spec.gem_dir
35
+ rescue Gem::LoadError
36
+ # If gem not found, we're in development mode
37
+ File.expand_path(File.join(__dir__, '..', '..'))
38
+ end
39
+
40
+ # Setup pumadev integration
41
+ def setup(path = nil)
42
+ ensure_puma_dev_installed!
43
+
44
+ gem_dir = gem_directory
45
+ puma_dev_dir = File.dirname(symlink_path)
46
+
47
+ # Create ~/.puma-dev if it doesn't exist
48
+ FileUtils.mkdir_p(puma_dev_dir) unless File.exist?(puma_dev_dir)
49
+
50
+ # Create symlink to gem directory
51
+ if File.symlink?(symlink_path)
52
+ existing_target = File.readlink(symlink_path)
53
+ if existing_target == gem_dir
54
+ puts "Pumadev symlink already exists and points to correct location"
55
+ else
56
+ puts "Updating pumadev symlink from #{existing_target} to #{gem_dir}"
57
+ File.delete(symlink_path)
58
+ File.symlink(gem_dir, symlink_path)
59
+ end
60
+ elsif File.exist?(symlink_path)
61
+ raise "#{symlink_path} exists but is not a symlink. Please remove it manually."
62
+ else
63
+ puts "Creating pumadev symlink: #{symlink_path} -> #{gem_dir}"
64
+ File.symlink(gem_dir, symlink_path)
65
+ end
66
+
67
+ # Store the root path if provided
68
+ if path
69
+ expanded_path = File.expand_path(path)
70
+ unless File.directory?(expanded_path)
71
+ raise ArgumentError, "Path is not a directory: #{path}"
72
+ end
73
+ save_root_path(expanded_path)
74
+ puts "Set markymark root directory to: #{expanded_path}"
75
+ else
76
+ save_root_path(Dir.pwd)
77
+ puts "Set markymark root directory to: #{Dir.pwd}"
78
+ end
79
+
80
+ # Create marker file
81
+ FileUtils.mkdir_p(File.dirname(marker_file_path))
82
+ File.write(marker_file_path, Time.now.to_s)
83
+
84
+ puts "\nPumadev setup complete!"
85
+ puts "Access markymark at: http://markymark.test"
86
+ puts "\nNote: It may take a few seconds for pumadev to start the app on first access."
87
+
88
+ true
89
+ rescue => e
90
+ warn "Failed to setup pumadev: #{e.message}"
91
+ false
92
+ end
93
+
94
+ # Teardown pumadev integration
95
+ def teardown
96
+ removed_anything = false
97
+
98
+ # Remove symlink
99
+ if File.symlink?(symlink_path)
100
+ File.delete(symlink_path)
101
+ puts "Removed pumadev symlink"
102
+ removed_anything = true
103
+ end
104
+
105
+ # Remove root path file
106
+ if File.exist?(env_file_path)
107
+ File.delete(env_file_path)
108
+ removed_anything = true
109
+ end
110
+
111
+ # Remove marker file
112
+ if File.exist?(marker_file_path)
113
+ File.delete(marker_file_path)
114
+ removed_anything = true
115
+ end
116
+
117
+ if removed_anything
118
+ puts "Pumadev integration removed"
119
+ puts "The app will stop automatically when pumadev next restarts"
120
+ else
121
+ puts "Pumadev integration was not active"
122
+ end
123
+
124
+ true
125
+ rescue => e
126
+ warn "Error during pumadev teardown: #{e.message}"
127
+ false
128
+ end
129
+
130
+ # Get current status
131
+ def status
132
+ if active?
133
+ root_path = load_root_path
134
+ {
135
+ mode: :pumadev,
136
+ url: 'http://markymark.test',
137
+ root_path: root_path,
138
+ message: "Running via pumadev at http://markymark.test\nServing: #{root_path}"
139
+ }
140
+ else
141
+ nil
142
+ end
143
+ end
144
+
145
+ # Switch directory in pumadev mode
146
+ def switch_directory(new_path)
147
+ expanded_path = File.expand_path(new_path)
148
+
149
+ unless File.directory?(expanded_path)
150
+ raise ArgumentError, "Path is not a directory: #{new_path}"
151
+ end
152
+
153
+ save_root_path(expanded_path)
154
+ puts "Switched to #{expanded_path}"
155
+ puts "Restart required - touch your ~/.puma-dev/markymark symlink or wait for next request"
156
+
157
+ # Trigger a restart by touching the restart.txt file
158
+ restart_txt = File.join(gem_directory, 'tmp', 'restart.txt')
159
+ FileUtils.mkdir_p(File.dirname(restart_txt))
160
+ FileUtils.touch(restart_txt)
161
+
162
+ true
163
+ rescue => e
164
+ warn "Failed to switch directory: #{e.message}"
165
+ false
166
+ end
167
+
168
+ private
169
+
170
+ def ensure_puma_dev_installed!
171
+ unless puma_dev_installed?
172
+ raise "puma-dev is not installed. Install it with: gem install puma-dev\n" \
173
+ "Then run: sudo puma-dev -setup && puma-dev -install"
174
+ end
175
+ end
176
+
177
+ def save_root_path(path)
178
+ FileUtils.mkdir_p(File.dirname(env_file_path))
179
+ File.write(env_file_path, path)
180
+
181
+ # Also set it as an environment variable for the current process
182
+ ENV['MARKYMARK_ROOT'] = path
183
+ end
184
+
185
+ def load_root_path
186
+ if File.exist?(env_file_path)
187
+ File.read(env_file_path).strip
188
+ else
189
+ ENV['MARKYMARK_ROOT'] || Dir.pwd
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra/base'
4
+ require 'json'
5
+ require 'kramdown'
6
+ require 'rouge'
7
+ require 'launchy'
8
+ require 'pathname'
9
+ require 'fileutils'
10
+ require 'cgi'
11
+
12
+ module Markymark
13
+ # Bulletproof simple Sinatra server for markdown browsing
14
+ # No SSE, no watcher, no threading, no JavaScript complexity
15
+ class ServerSimple < Sinatra::Base
16
+ set :public_folder, File.join(File.dirname(__FILE__), '..', 'public')
17
+ set :views, File.join(File.dirname(__FILE__), '..', 'views')
18
+ set :server, :puma
19
+ set :bind, '0.0.0.0'
20
+
21
+ # Disable static file caching in development
22
+ configure :development do
23
+ set :static_cache_control, [:no_cache, :no_store, :must_revalidate]
24
+ end
25
+
26
+ class << self
27
+ attr_accessor :root_path, :real_root_path
28
+
29
+ def launch(cli)
30
+ @root_path = cli.root_path
31
+ @real_root_path = File.realpath(@root_path)
32
+
33
+ # Print startup message
34
+ base_url = "http://localhost:#{cli.port}"
35
+ puts "markymark serving #{@root_path} on #{base_url}"
36
+
37
+ # Build URL with optional file parameter
38
+ url = if cli.initial_file
39
+ "#{base_url}/?file=#{CGI.escape(cli.initial_file)}"
40
+ else
41
+ base_url
42
+ end
43
+
44
+ # Open browser if requested (before forking)
45
+ Launchy.open(url) if cli.open_browser
46
+
47
+ # Fork the process to run server in background
48
+ pid = fork do
49
+ # In child process - run the server
50
+
51
+ # Detach from terminal
52
+ Process.setsid
53
+
54
+ # Redirect output to /dev/null
55
+ $stdout.reopen('/dev/null', 'w')
56
+ $stderr.reopen('/dev/null', 'w')
57
+
58
+ # Write PID file for server detection
59
+ write_pid_file(cli.port)
60
+
61
+ # Clean up PID file on exit
62
+ at_exit do
63
+ delete_pid_file
64
+ end
65
+
66
+ # Start server
67
+ set :port, cli.port
68
+ run!
69
+ end
70
+
71
+ # In parent process - detach child and exit
72
+ Process.detach(pid)
73
+
74
+ # Give server a moment to start
75
+ sleep 1
76
+
77
+ puts "Server started in background (PID: #{pid})"
78
+ end
79
+
80
+ def find_markdown_files(root_path = @root_path)
81
+ pattern = File.join(root_path, '**', '*.{md,markdown}')
82
+ begin
83
+ Dir.glob(pattern, File::FNM_CASEFOLD).map do |full_path|
84
+ Pathname.new(full_path).relative_path_from(Pathname.new(root_path)).to_s
85
+ end.sort
86
+ rescue Errno::EPERM, Errno::EACCES => e
87
+ # Permission denied on some subdirectory - fall back to non-recursive scan
88
+ warn "Warning: Permission denied scanning #{root_path}, using non-recursive scan"
89
+ find_markdown_files_safe(root_path)
90
+ end
91
+ end
92
+
93
+ def find_markdown_files_safe(root_path)
94
+ # Non-recursive scan that skips protected directories
95
+ files = []
96
+ dirs_to_scan = [root_path]
97
+
98
+ while dirs_to_scan.any?
99
+ dir = dirs_to_scan.shift
100
+ begin
101
+ Dir.entries(dir).each do |entry|
102
+ next if entry.start_with?('.')
103
+ full_path = File.join(dir, entry)
104
+ if File.directory?(full_path)
105
+ # Skip known protected directories
106
+ next if full_path.include?('/Library/')
107
+ dirs_to_scan << full_path
108
+ elsif entry.match?(/\.(md|markdown)$/i)
109
+ files << Pathname.new(full_path).relative_path_from(Pathname.new(root_path)).to_s
110
+ end
111
+ end
112
+ rescue Errno::EPERM, Errno::EACCES
113
+ # Skip directories we can't access
114
+ next
115
+ end
116
+ end
117
+ files.sort
118
+ end
119
+
120
+ def group_files_by_directory(files)
121
+ grouped = {}
122
+ files.each do |file|
123
+ dir = File.dirname(file)
124
+ dir = "." if dir == "."
125
+ grouped[dir] ||= []
126
+ grouped[dir] << File.basename(file)
127
+ end
128
+ # Sort directories, with "." (root) first
129
+ sorted_dirs = grouped.keys.sort do |a, b|
130
+ if a == "."
131
+ -1
132
+ elsif b == "."
133
+ 1
134
+ else
135
+ a <=> b
136
+ end
137
+ end
138
+ sorted_dirs.map { |dir| [dir, grouped[dir].sort] }.to_h
139
+ end
140
+
141
+ def render_markdown(file_path, root_path = @root_path)
142
+ full_path = File.join(root_path, file_path)
143
+ return nil unless File.exist?(full_path) && File.file?(full_path)
144
+
145
+ content = File.read(full_path, encoding: 'UTF-8')
146
+ html = Kramdown::Document.new(content, input: 'GFM', syntax_highlighter: 'rouge').to_html
147
+
148
+ # Convert mermaid code blocks to divs for mermaid.js rendering
149
+ html = html.gsub(/<pre><code class="language-mermaid">(.*?)<\/code><\/pre>/m) do
150
+ "<div class=\"mermaid\">#{$1}</div>"
151
+ end
152
+
153
+ # Rewrite relative markdown links to use query parameters
154
+ html = rewrite_markdown_links(html, file_path, root_path)
155
+
156
+ html
157
+ rescue => e
158
+ "<p>Error rendering markdown: #{e.message}</p>"
159
+ end
160
+
161
+ def rewrite_markdown_links(html, current_file, root_path)
162
+ # Get the directory of the current file for resolving relative paths
163
+ current_dir = File.dirname(current_file)
164
+ current_dir = "." if current_dir == "."
165
+
166
+ # Rewrite relative links to .md or .markdown files
167
+ html.gsub(/<a\s+href=["']([^"']+)["']([^>]*)>/i) do
168
+ full_match = $&
169
+ href = $1
170
+ rest_of_tag = $2
171
+
172
+ # Skip if it's an absolute URL (http://, https://, //, ftp://, mailto:, etc.)
173
+ if href =~ %r{^([a-z][a-z0-9+.-]*:|//)}i
174
+ next full_match
175
+ end
176
+
177
+ # Skip if it's an anchor link
178
+ if href.start_with?('#')
179
+ next full_match
180
+ end
181
+
182
+ # Only rewrite links to markdown files
183
+ if href =~ /\.(md|markdown)$/i
184
+ # Resolve the relative path from the current file's directory
185
+ if current_dir == "."
186
+ target_file = href
187
+ else
188
+ target_file = File.join(current_dir, href)
189
+ end
190
+
191
+ # Normalize the path (remove ./ and resolve ../)
192
+ target_file = Pathname.new(target_file).cleanpath.to_s
193
+
194
+ # Rewrite to use query parameters
195
+ encoded_file = CGI.escape(target_file)
196
+ encoded_dir = CGI.escape(root_path)
197
+ %Q{<a href="/?file=#{encoded_file}&dir=#{encoded_dir}"#{rest_of_tag}>}
198
+ else
199
+ # Not a markdown file, leave as-is
200
+ full_match
201
+ end
202
+ end
203
+ end
204
+
205
+ def within_root?(real_path, real_root_path = @real_root_path)
206
+ return false unless real_path && real_root_path
207
+ real_path == real_root_path || real_path.start_with?(File.join(real_root_path, ''))
208
+ end
209
+
210
+ # Bookmark management methods
211
+ def bookmarks_file
212
+ File.expand_path('~/.markymark/bookmarks.json')
213
+ end
214
+
215
+ def load_bookmarks
216
+ return [] unless File.exist?(bookmarks_file)
217
+ JSON.parse(File.read(bookmarks_file))
218
+ rescue JSON::ParserError, Errno::ENOENT
219
+ []
220
+ end
221
+
222
+ def save_bookmarks(bookmarks)
223
+ FileUtils.mkdir_p(File.dirname(bookmarks_file))
224
+ File.write(bookmarks_file, JSON.pretty_generate(bookmarks))
225
+ end
226
+
227
+ def add_bookmark(name, path)
228
+ bookmarks = load_bookmarks
229
+ # Avoid duplicates
230
+ return if bookmarks.any? { |b| b['path'] == path }
231
+ bookmarks << { 'name' => name, 'path' => path }
232
+ save_bookmarks(bookmarks)
233
+ end
234
+
235
+ def remove_bookmark(index)
236
+ bookmarks = load_bookmarks
237
+ bookmarks.delete_at(index.to_i)
238
+ save_bookmarks(bookmarks)
239
+ end
240
+
241
+ # PID file management for server detection
242
+ def pid_file_path
243
+ File.expand_path('~/.markymark/server.pid')
244
+ end
245
+
246
+ def write_pid_file(port)
247
+ FileUtils.mkdir_p(File.dirname(pid_file_path))
248
+ File.write(pid_file_path, "port=#{port}\npid=#{Process.pid}\n")
249
+ end
250
+
251
+ def delete_pid_file
252
+ File.delete(pid_file_path) if File.exist?(pid_file_path)
253
+ end
254
+ end
255
+
256
+ # Helper methods for per-tab directory isolation via URL parameters
257
+ helpers do
258
+ def get_directory_from_params
259
+ # Get directory from URL parameter, fall back to server default
260
+ dir_param = params[:dir]
261
+
262
+ if dir_param && !dir_param.empty?
263
+ expanded = File.expand_path(dir_param)
264
+ # Validate it exists and is a directory
265
+ if File.exist?(expanded) && File.directory?(expanded)
266
+ return File.realpath(expanded)
267
+ end
268
+ end
269
+
270
+ # Fall back to server default
271
+ self.class.root_path
272
+ end
273
+ end
274
+
275
+ # Main page - shows file list and optional file content
276
+ get '/' do
277
+ current_dir = get_directory_from_params
278
+ @current_dir = current_dir # Make available to template for preserving in links
279
+ @files = self.class.find_markdown_files(current_dir)
280
+ @files_grouped = self.class.group_files_by_directory(@files)
281
+ @bookmarks = self.class.load_bookmarks
282
+ @current_file = params[:file]
283
+
284
+ if @current_file
285
+ # Security: ensure the requested file path (not symlink target) is within root
286
+ # This allows symlinks that point outside the root, which is useful for
287
+ # linking to shared documentation directories
288
+ full_path = File.join(current_dir, @current_file)
289
+
290
+ # Check the file exists and prevent directory traversal
291
+ unless File.exist?(full_path) && (File.file?(full_path) || File.symlink?(full_path))
292
+ halt 404, 'File not found'
293
+ end
294
+
295
+ # Prevent path traversal attacks by ensuring the normalized path is within root
296
+ normalized_path = File.expand_path(full_path)
297
+ unless normalized_path.start_with?(File.expand_path(current_dir) + File::SEPARATOR) ||
298
+ normalized_path == File.expand_path(current_dir)
299
+ halt 403, 'Access denied'
300
+ end
301
+
302
+ @html_content = self.class.render_markdown(@current_file, current_dir)
303
+ else
304
+ # Default to first file if available
305
+ @current_file = @files.first
306
+ @html_content = @current_file ? self.class.render_markdown(@current_file, current_dir) : nil
307
+ end
308
+
309
+ erb :simple
310
+ end
311
+
312
+ # Server identification for CLI detection
313
+ get '/api/status' do
314
+ content_type :json
315
+ {
316
+ app: 'markymark',
317
+ version: Markymark::VERSION,
318
+ port: settings.port,
319
+ root_path: get_directory_from_params
320
+ }.to_json
321
+ end
322
+
323
+ # Browse directories via web UI
324
+ get '/browse-dir' do
325
+ current_dir = get_directory_from_params
326
+ @browse_path = params[:path] || current_dir
327
+ @current_dir = current_dir # Pass to template for preserving in form actions
328
+
329
+ # Expand and validate the path
330
+ begin
331
+ @browse_path = File.expand_path(@browse_path)
332
+
333
+ unless File.exist?(@browse_path)
334
+ @browse_path = current_dir
335
+ end
336
+
337
+ unless File.directory?(@browse_path)
338
+ @browse_path = File.dirname(@browse_path)
339
+ end
340
+
341
+ # Get parent directory
342
+ @parent_dir = File.dirname(@browse_path)
343
+
344
+ # Get subdirectories
345
+ @directories = Dir.entries(@browse_path)
346
+ .select { |entry| entry != '.' && entry != '..' }
347
+ .select { |entry| File.directory?(File.join(@browse_path, entry)) }
348
+ .sort
349
+ rescue => e
350
+ @browse_path = current_dir
351
+ @parent_dir = File.dirname(@browse_path)
352
+ @directories = []
353
+ @error = "Error browsing directory: #{e.message}"
354
+ end
355
+
356
+ erb :browse
357
+ end
358
+
359
+ # Helper method for dual-format error responses
360
+ def json_or_text_error(message, status)
361
+ if request.accept?('application/json') || request.env['HTTP_ACCEPT']&.include?('application/json')
362
+ content_type :json
363
+ halt status, { error: message }.to_json
364
+ else
365
+ halt status, message
366
+ end
367
+ end
368
+
369
+ # Change directory endpoint
370
+ post '/change-dir' do
371
+ new_path = params[:path]&.strip
372
+
373
+ unless new_path && !new_path.empty?
374
+ json_or_text_error('Path cannot be empty', 400)
375
+ end
376
+
377
+ expanded_path = File.expand_path(new_path)
378
+
379
+ unless File.exist?(expanded_path)
380
+ json_or_text_error("Directory does not exist: #{new_path}", 400)
381
+ end
382
+
383
+ unless File.directory?(expanded_path)
384
+ json_or_text_error("Path is not a directory: #{new_path}", 400)
385
+ end
386
+
387
+ real_path = File.realpath(expanded_path)
388
+
389
+ # Update server default for CLI directory switching
390
+ self.class.root_path = real_path
391
+ self.class.real_root_path = real_path
392
+
393
+ # Redirect to root with dir parameter for tab isolation
394
+ redirect "/?dir=#{CGI.escape(real_path)}"
395
+ end
396
+
397
+ # Add bookmark
398
+ post '/bookmark' do
399
+ name = params[:name]&.strip
400
+ path = params[:path]&.strip
401
+
402
+ unless name && !name.empty? && path && !path.empty?
403
+ halt 400, 'Name and path are required'
404
+ end
405
+
406
+ expanded_path = File.expand_path(path)
407
+
408
+ unless File.exist?(expanded_path) && File.directory?(expanded_path)
409
+ halt 400, 'Invalid directory path'
410
+ end
411
+
412
+ self.class.add_bookmark(name, File.realpath(expanded_path))
413
+ redirect '/'
414
+ end
415
+
416
+ # Remove bookmark
417
+ delete '/bookmark/:index' do
418
+ index = params[:index]
419
+ self.class.remove_bookmark(index)
420
+ redirect '/'
421
+ end
422
+
423
+ # Static file serving from application assets or document root (for images, etc.)
424
+ get '/assets/*' do
425
+ file_path = params[:splat].first
426
+
427
+ # First, check application assets (e.g., markymark icon)
428
+ app_assets_path = File.join(File.dirname(__FILE__), '..', '..', 'assets', file_path)
429
+ if File.exist?(app_assets_path) && File.file?(app_assets_path)
430
+ send_file app_assets_path
431
+ else
432
+ # Then check document root assets (user's images)
433
+ current_dir = get_directory_from_params
434
+ full_path = File.join(current_dir, 'assets', file_path)
435
+
436
+ # Security: ensure path is within root
437
+ real_path = File.realpath(full_path) rescue nil
438
+ real_current_dir = File.realpath(current_dir)
439
+
440
+ if real_path.nil? || !self.class.within_root?(real_path, real_current_dir)
441
+ halt 403, 'Access denied'
442
+ end
443
+
444
+ if File.exist?(full_path) && File.file?(full_path)
445
+ send_file full_path
446
+ else
447
+ halt 404, 'File not found'
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markymark
4
+ VERSION = "0.1.0"
5
+ end
data/lib/markymark.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "markymark/version"
4
+ require_relative "markymark/pumadev_manager"
5
+ require_relative "markymark/app_installer"
6
+ require_relative "markymark/init_wizard"
7
+ require_relative "markymark/server_simple"
8
+ require_relative "markymark/cli"
9
+
10
+ module Markymark
11
+ class Error < StandardError; end
12
+ end