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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.txt +21 -0
- data/README.md +255 -0
- data/Rakefile +8 -0
- data/assets/.gitkeep +0 -0
- data/assets/Markymark.icns +0 -0
- data/assets/Markymark.iconset/icon_128x128.png +0 -0
- data/assets/Markymark.iconset/icon_128x128@2x.png +0 -0
- data/assets/Markymark.iconset/icon_16x16.png +0 -0
- data/assets/Markymark.iconset/icon_16x16@2x.png +0 -0
- data/assets/Markymark.iconset/icon_256x256.png +0 -0
- data/assets/Markymark.iconset/icon_256x256@2x.png +0 -0
- data/assets/Markymark.iconset/icon_32x32.png +0 -0
- data/assets/Markymark.iconset/icon_32x32@2x.png +0 -0
- data/assets/Markymark.iconset/icon_512x512.png +0 -0
- data/assets/Markymark.iconset/icon_512x512@2x.png +0 -0
- data/assets/README.md +3 -0
- data/assets/marky-mark-dj.jpg +0 -0
- data/assets/marky-mark-icon.png +0 -0
- data/assets/marky-mark-icon2.png +0 -0
- data/config.ru +19 -0
- data/docs/for_llms.md +141 -0
- data/docs/plans/2025-12-18-macos-app-installer-design.md +149 -0
- data/exe/markymark +5 -0
- data/lib/markymark/app_installer.rb +437 -0
- data/lib/markymark/cli.rb +497 -0
- data/lib/markymark/init_wizard.rb +186 -0
- data/lib/markymark/pumadev_manager.rb +194 -0
- data/lib/markymark/server_simple.rb +452 -0
- data/lib/markymark/version.rb +5 -0
- data/lib/markymark.rb +12 -0
- data/lib/public/css/style.css +350 -0
- data/lib/public/js/app.js +186 -0
- data/lib/public/js/theme.js +79 -0
- data/lib/public/js/tree.js +124 -0
- data/lib/views/browse.erb +225 -0
- data/lib/views/index.erb +37 -0
- data/lib/views/simple.erb +806 -0
- data/sig/markymark.rbs +4 -0
- 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
|
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
|