markdownr 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c3e87f1a091aae321ef6ed25f1780ffc034278328d6432762d7126947029cf63
4
+ data.tar.gz: aced5406c9386d7f09f46484bec59c4e6ef4df7dab40d0fa497ed37c1a4da83c
5
+ SHA512:
6
+ metadata.gz: 923029aa7a8065c52983debabd69ae1e411a24cf8168b13629457ff8f0dbb4ceafc93db7b4755d73cbb2f443469200534e06b3316901ef0a03cf70f157551ef1
7
+ data.tar.gz: 533e4b984b363692a3fa7a8269818d246cbd5f5846a92103f3eb9b250e57376bcf030953fbc71496502190a93d7951bebbc9610271874c1ec4f25e2ef78426d1
data/bin/markdownr ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env ruby
2
+ require "optparse"
3
+ require_relative "../lib/markdown_server"
4
+
5
+ options = { port: 4567, bind: "0.0.0.0" }
6
+
7
+ OptionParser.new do |opts|
8
+ opts.banner = "Usage: markdownr [options] [directory]"
9
+
10
+ opts.on("-p", "--port PORT", Integer, "Port to listen on (default: 4567)") do |p|
11
+ options[:port] = p
12
+ end
13
+
14
+ opts.on("-b", "--bind ADDRESS", "Address to bind to (default: 0.0.0.0)") do |b|
15
+ options[:bind] = b
16
+ end
17
+
18
+ opts.on("-t", "--title TITLE", "Custom page title") do |t|
19
+ options[:title] = t
20
+ end
21
+
22
+ opts.on("-v", "--version", "Show version") do
23
+ puts "markdownr #{MarkdownServer::VERSION}"
24
+ exit
25
+ end
26
+ end.parse!
27
+
28
+ dir = ARGV.first || "."
29
+ dir = File.expand_path(dir)
30
+
31
+ unless File.directory?(dir)
32
+ $stderr.puts "Error: #{dir} is not a directory"
33
+ exit 1
34
+ end
35
+
36
+ MarkdownServer::App.set :root_dir, dir
37
+ MarkdownServer::App.set :custom_title, options[:title]
38
+ MarkdownServer::App.set :port, options[:port]
39
+ MarkdownServer::App.set :bind, options[:bind]
40
+
41
+ puts "Serving #{dir} on http://#{options[:bind]}:#{options[:port]}/"
42
+ MarkdownServer::App.run!
@@ -0,0 +1,331 @@
1
+ require "sinatra/base"
2
+ require "kramdown"
3
+ require "kramdown-parser-gfm"
4
+ require "rouge"
5
+ require "yaml"
6
+ require "json"
7
+ require "uri"
8
+ require "cgi"
9
+ require "pathname"
10
+
11
+ module MarkdownServer
12
+ class App < Sinatra::Base
13
+ EXCLUDED = %w[.git .obsidian __pycache__ .DS_Store node_modules .claude].freeze
14
+
15
+ set :views, File.expand_path("../../views", __dir__)
16
+
17
+ configure do
18
+ set :root_dir, Dir.pwd
19
+ set :custom_title, nil
20
+ set :show_exceptions, false
21
+ end
22
+
23
+ helpers do
24
+ def root_dir
25
+ settings.root_dir
26
+ end
27
+
28
+ def h(text)
29
+ CGI.escapeHTML(text.to_s)
30
+ end
31
+
32
+ def encode_path_component(str)
33
+ URI.encode_www_form_component(str).gsub("+", "%20")
34
+ end
35
+
36
+ def safe_path(requested)
37
+ base = File.realpath(root_dir)
38
+ full = File.join(base, requested)
39
+
40
+ begin
41
+ real = File.realpath(full)
42
+ rescue Errno::ENOENT
43
+ halt 404, erb(:layout) { "<h1>Not Found</h1><p>#{h(requested)}</p>" }
44
+ end
45
+
46
+ unless real.start_with?(base)
47
+ halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
48
+ end
49
+
50
+ relative = real.sub("#{base}/", "")
51
+ first_segment = relative.split("/").first
52
+ if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
53
+ halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
54
+ end
55
+
56
+ real
57
+ end
58
+
59
+ def format_size(bytes)
60
+ if bytes < 1024
61
+ "#{bytes} B"
62
+ elsif bytes < 1024 * 1024
63
+ "%.1f KB" % (bytes / 1024.0)
64
+ else
65
+ "%.1f MB" % (bytes / (1024.0 * 1024))
66
+ end
67
+ end
68
+
69
+ def format_date(time)
70
+ time.strftime("%Y-%m-%d %H:%M")
71
+ end
72
+
73
+ def icon_for(entry_name, is_dir)
74
+ if is_dir
75
+ "\u{1F4C1}"
76
+ else
77
+ ext = File.extname(entry_name).downcase
78
+ case ext
79
+ when ".md" then "\u{1F4DD}"
80
+ when ".pdf" then "\u{1F4D5}"
81
+ when ".json" then "\u{1F4CB}"
82
+ when ".py" then "\u{1F40D}"
83
+ when ".rb" then "\u{1F48E}"
84
+ when ".csv" then "\u{1F4CA}"
85
+ when ".epub" then "\u{1F4D6}"
86
+ else "\u{1F4C4}"
87
+ end
88
+ end
89
+ end
90
+
91
+ def breadcrumbs(path)
92
+ parts = path.split("/").reject(&:empty?)
93
+ crumbs = [{ name: "home", href: "/browse/" }]
94
+ parts.each_with_index do |part, i|
95
+ href = "/browse/" + parts[0..i].map { |p| encode_path_component(p) }.join("/") + "/"
96
+ crumbs << { name: part, href: href }
97
+ end
98
+ crumbs
99
+ end
100
+
101
+ def parse_frontmatter(content)
102
+ if content =~ /\A---\s*\n(.*?\n)---\s*\n(.*)/m
103
+ begin
104
+ meta = YAML.safe_load($1, permitted_classes: [Date, Time])
105
+ body = $2
106
+ [meta, body]
107
+ rescue => e
108
+ [nil, content]
109
+ end
110
+ else
111
+ [nil, content]
112
+ end
113
+ end
114
+
115
+ def render_markdown(text)
116
+ html = Kramdown::Document.new(
117
+ text,
118
+ input: "GFM",
119
+ syntax_highlighter: "rouge",
120
+ syntax_highlighter_opts: { default_lang: "text" },
121
+ hard_wrap: false
122
+ ).to_html
123
+
124
+ html.gsub(/\[\[([^\]]+)\]\]/) do
125
+ link_text = $1
126
+ resolved = resolve_wiki_link(link_text)
127
+ if resolved
128
+ %(<a class="wiki-link" href="/browse/#{encode_path_component(resolved).gsub('%2F', '/')}">#{h(link_text)}</a>)
129
+ else
130
+ %(<span class="wiki-link broken">#{h(link_text)}</span>)
131
+ end
132
+ end
133
+ end
134
+
135
+ def resolve_wiki_link(name)
136
+ filename = "#{name}.md"
137
+
138
+ # Search all subdirectories
139
+ Dir.glob(File.join(root_dir, "**", filename)).each do |path|
140
+ real = File.realpath(path)
141
+ base = File.realpath(root_dir)
142
+ relative = real.sub("#{base}/", "")
143
+ first_segment = relative.split("/").first
144
+ next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
145
+ return relative
146
+ end
147
+
148
+ # Try root level
149
+ path = File.join(root_dir, filename)
150
+ return filename if File.exist?(path)
151
+
152
+ nil
153
+ end
154
+
155
+ def render_frontmatter_value(value)
156
+ case value
157
+ when Array
158
+ value.map { |v| %(<span class="tag">#{h(v)}</span>) }.join(" ")
159
+ when String
160
+ if value =~ /\Ahttps?:\/\//
161
+ %(<a href="#{h(value)}" target="_blank" rel="noopener">#{h(value)}</a>)
162
+ elsif value.length > 120
163
+ render_markdown(value)
164
+ else
165
+ h(value)
166
+ end
167
+ when Numeric, TrueClass, FalseClass
168
+ h(value.to_s)
169
+ when NilClass
170
+ %(<span class="empty">—</span>)
171
+ else
172
+ h(value.to_s)
173
+ end
174
+ end
175
+
176
+ def syntax_highlight(code, language)
177
+ formatter = Rouge::Formatters::HTML.new
178
+ lexer = Rouge::Lexer.find_fancy(language) || Rouge::Lexers::PlainText.new
179
+ formatter.format(lexer.lex(code))
180
+ end
181
+
182
+ def extract_toc(html)
183
+ headings = []
184
+ html.scan(/<h([1-6])\s[^>]*id="([^"]*)"[^>]*>(.*?)<\/h\1>/mi) do |level, id, text|
185
+ clean_text = text.gsub(/<[^>]+>/, "").strip
186
+ headings << { level: level.to_i, id: id, text: clean_text }
187
+ end
188
+ headings
189
+ end
190
+
191
+ def dir_title
192
+ return settings.custom_title if settings.respond_to?(:custom_title) && settings.custom_title
193
+ File.basename(root_dir).gsub(/[-_]/, " ").gsub(/\b\w/, &:upcase)
194
+ end
195
+ end
196
+
197
+ # Routes
198
+
199
+ get "/" do
200
+ redirect "/browse/"
201
+ end
202
+
203
+ get "/browse/?*" do
204
+ requested = params["splat"].first.to_s
205
+ requested = requested.chomp("/")
206
+
207
+ if requested.empty?
208
+ real_path = File.realpath(root_dir)
209
+ else
210
+ real_path = safe_path(requested)
211
+ end
212
+
213
+ if File.directory?(real_path)
214
+ render_directory(real_path, requested)
215
+ else
216
+ render_file(real_path, requested)
217
+ end
218
+ end
219
+
220
+ get "/download/*" do
221
+ requested = params["splat"].first.to_s
222
+ real_path = safe_path(requested)
223
+ halt 404 unless File.file?(real_path)
224
+
225
+ send_file real_path, disposition: "attachment"
226
+ end
227
+
228
+ private
229
+
230
+ def render_directory(real_path, relative_path)
231
+ entries = Dir.entries(real_path).reject do |e|
232
+ e.start_with?(".") || EXCLUDED.include?(e)
233
+ end
234
+
235
+ items = entries.map do |name|
236
+ full = File.join(real_path, name)
237
+ stat = File.stat(full) rescue next
238
+ btime = stat.respond_to?(:birthtime) ? stat.birthtime : stat.mtime rescue stat.mtime
239
+ {
240
+ name: name,
241
+ is_dir: stat.directory?,
242
+ size: stat.directory? ? nil : stat.size,
243
+ mtime: stat.mtime,
244
+ ctime: btime,
245
+ href: "/browse/" + (relative_path.empty? ? "" : relative_path + "/") +
246
+ encode_path_component(name) + (stat.directory? ? "/" : "")
247
+ }
248
+ end.compact
249
+
250
+ @sort = %w[name mtime ctime].include?(params[:sort]) ? params[:sort] : "mtime"
251
+ @order = %w[asc desc].include?(params[:order]) ? params[:order] : nil
252
+ # Default order: name=asc, times=desc
253
+ effective_order = @order || (@sort == "name" ? "asc" : "desc")
254
+ @order_display = effective_order
255
+
256
+ sort_items = ->(list) do
257
+ sorted = case @sort
258
+ when "name" then list.sort_by { |i| i[:name].downcase }
259
+ when "ctime" then list.sort_by { |i| i[:ctime].to_f }
260
+ else list.sort_by { |i| i[:mtime].to_f }
261
+ end
262
+ effective_order == "desc" ? sorted.reverse : sorted
263
+ end
264
+
265
+ dirs = sort_items.call(items.select { |i| i[:is_dir] })
266
+ files = sort_items.call(items.reject { |i| i[:is_dir] })
267
+
268
+ @items = dirs + files
269
+ @path = relative_path
270
+ @crumbs = breadcrumbs(relative_path)
271
+ @title = relative_path.empty? ? dir_title : File.basename(relative_path)
272
+ erb :directory
273
+ end
274
+
275
+ def render_file(real_path, relative_path)
276
+ ext = File.extname(real_path).downcase
277
+ @crumbs = breadcrumbs(relative_path)
278
+ @title = File.basename(real_path)
279
+ @download_href = "/download/" + relative_path.split("/").map { |p| encode_path_component(p) }.join("/")
280
+
281
+ case ext
282
+ when ".md"
283
+ content = File.read(real_path, encoding: "utf-8")
284
+ @meta, body = parse_frontmatter(content)
285
+ @content = render_markdown(body)
286
+ @toc = extract_toc(@content)
287
+ @has_toc = @toc.length > 1
288
+ erb :markdown
289
+
290
+ when ".json"
291
+ raw = File.read(real_path, encoding: "utf-8")
292
+ begin
293
+ data = JSON.parse(raw)
294
+ yaml_str = YAML.dump(data)
295
+ @code = syntax_highlight(yaml_str, "yaml")
296
+ @language = "yaml"
297
+ rescue JSON::ParserError
298
+ @code = syntax_highlight(raw, "json")
299
+ @language = "json"
300
+ end
301
+ erb :raw
302
+
303
+ when ".pdf"
304
+ send_file real_path, type: "application/pdf", disposition: "inline"
305
+
306
+ when ".epub"
307
+ redirect @download_href
308
+
309
+ else
310
+ content = File.read(real_path, encoding: "utf-8") rescue nil
311
+ if content.nil? || content.encoding == Encoding::BINARY || !content.valid_encoding?
312
+ send_file real_path, disposition: "inline"
313
+ else
314
+ lang = case ext
315
+ when ".py" then "python"
316
+ when ".rb" then "ruby"
317
+ when ".csv" then "text"
318
+ when ".sh" then "bash"
319
+ when ".yaml", ".yml" then "yaml"
320
+ when ".html", ".erb" then "html"
321
+ when ".js" then "javascript"
322
+ else "text"
323
+ end
324
+ @code = syntax_highlight(content, lang)
325
+ @language = lang
326
+ erb :raw
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,3 @@
1
+ module MarkdownServer
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,2 @@
1
+ require_relative "markdown_server/version"
2
+ require_relative "markdown_server/app"
@@ -0,0 +1,77 @@
1
+ <script>
2
+ (function() {
3
+ var p = new URLSearchParams(window.location.search);
4
+ if (!p.has('sort')) {
5
+ try {
6
+ var pref = JSON.parse(localStorage.getItem('mdSortPref'));
7
+ if (pref && pref.sort) {
8
+ var url = new URL(window.location);
9
+ url.searchParams.set('sort', pref.sort);
10
+ if (pref.order) url.searchParams.set('order', pref.order);
11
+ window.location.replace(url.toString());
12
+ }
13
+ } catch(e) {}
14
+ }
15
+ })();
16
+ </script>
17
+
18
+ <h1 class="page-title"><%= h(@title) %></h1>
19
+
20
+ <div class="dir-header">
21
+ <div class="dir-count">
22
+ <%= @items.count { |i| i[:is_dir] } %> folders, <%= @items.count { |i| !i[:is_dir] } %> files
23
+ </div>
24
+ <div class="sort-controls">
25
+ <%
26
+ sort_options = [
27
+ ["name", "Name"],
28
+ ["mtime", "Modified"],
29
+ ["ctime", "Created"],
30
+ ]
31
+ base = request.path
32
+ %>
33
+ <% sort_options.each_with_index do |(key, label), i| %>
34
+ <% active = @sort == key %>
35
+ <% if active %>
36
+ <%
37
+ # Clicking active sort reverses direction
38
+ default_order = key == "name" ? "asc" : "desc"
39
+ toggled = @order_display == "asc" ? "desc" : "asc"
40
+ next_order = @order ? toggled : (default_order == "asc" ? "desc" : "asc")
41
+ arrow = @order_display == "asc" ? "\u25B2" : "\u25BC"
42
+ %>
43
+ <a href="<%= base %>?sort=<%= key %>&order=<%= next_order %>" class="sort-active"><%= label %> <span class="sort-arrow"><%= arrow %></span></a>
44
+ <% else %>
45
+ <a href="<%= base %>?sort=<%= key %>"><%= label %></a>
46
+ <% end %>
47
+ <% end %>
48
+ </div>
49
+ </div>
50
+
51
+ <ul class="dir-listing">
52
+ <% @items.each do |item| %>
53
+ <li>
54
+ <a href="<%= item[:href] %>">
55
+ <span class="icon"><%= icon_for(item[:name], item[:is_dir]) %></span>
56
+ <span class="name"><%= h(item[:name]) %><%= "/" if item[:is_dir] %></span>
57
+ <span class="meta">
58
+ <% unless item[:is_dir] %>
59
+ <span class="size"><%= format_size(item[:size]) %></span>
60
+ <span class="date"><%= format_date(item[:mtime]) %></span>
61
+ <% end %>
62
+ </span>
63
+ </a>
64
+ </li>
65
+ <% end %>
66
+ </ul>
67
+
68
+ <script>
69
+ document.querySelectorAll('.sort-controls a').forEach(function(link) {
70
+ link.addEventListener('click', function() {
71
+ var url = new URL(link.href);
72
+ var sort = url.searchParams.get('sort');
73
+ var order = url.searchParams.get('order') || (sort === 'name' ? 'asc' : 'desc');
74
+ localStorage.setItem('mdSortPref', JSON.stringify({sort: sort, order: order}));
75
+ });
76
+ });
77
+ </script>
data/views/layout.erb ADDED
@@ -0,0 +1,510 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= h(@title || dir_title) %></title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; }
9
+
10
+ body {
11
+ font-family: Georgia, "Times New Roman", serif;
12
+ background: #faf8f4;
13
+ color: #2c2c2c;
14
+ margin: 0;
15
+ padding: 0;
16
+ line-height: 1.7;
17
+ }
18
+
19
+ .container {
20
+ max-width: 900px;
21
+ margin: 0 auto;
22
+ padding: 1.5rem 2rem 3rem;
23
+ }
24
+ .container.has-toc {
25
+ max-width: 1150px;
26
+ }
27
+
28
+ /* Breadcrumbs */
29
+ .breadcrumbs {
30
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
31
+ font-size: 0.85rem;
32
+ margin-bottom: 1.5rem;
33
+ color: #888;
34
+ }
35
+ .breadcrumbs a {
36
+ color: #8b6914;
37
+ text-decoration: none;
38
+ }
39
+ .breadcrumbs a:hover { text-decoration: underline; }
40
+ .breadcrumbs .sep { margin: 0 0.4rem; color: #ccc; }
41
+
42
+ /* Page title */
43
+ h1.page-title {
44
+ font-size: 1.6rem;
45
+ margin: 0 0 1.2rem;
46
+ color: #3a3a3a;
47
+ border-bottom: 2px solid #d4b96a;
48
+ padding-bottom: 0.5rem;
49
+ }
50
+
51
+ /* Directory listing */
52
+ .dir-listing {
53
+ list-style: none;
54
+ padding: 0;
55
+ margin: 0;
56
+ }
57
+ .dir-listing li {
58
+ border-bottom: 1px solid #eee;
59
+ }
60
+ .dir-listing a {
61
+ display: flex;
62
+ align-items: center;
63
+ padding: 0.6rem 0.4rem;
64
+ text-decoration: none;
65
+ color: #2c2c2c;
66
+ transition: background 0.15s;
67
+ }
68
+ .dir-listing a:hover {
69
+ background: #f0ece3;
70
+ }
71
+ .dir-listing .icon {
72
+ margin-right: 0.6rem;
73
+ font-size: 1.1rem;
74
+ flex-shrink: 0;
75
+ }
76
+ .dir-listing .name {
77
+ flex: 1;
78
+ font-size: 0.95rem;
79
+ color: #8b6914;
80
+ word-break: break-word;
81
+ }
82
+ .dir-listing .meta {
83
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
84
+ font-size: 0.75rem;
85
+ color: #aaa;
86
+ text-align: right;
87
+ white-space: nowrap;
88
+ margin-left: 1rem;
89
+ }
90
+ .dir-listing .meta .size {
91
+ margin-right: 1rem;
92
+ }
93
+
94
+ /* Frontmatter */
95
+ details.frontmatter {
96
+ margin-bottom: 1.5rem;
97
+ border: 1px solid #e0d8c8;
98
+ border-radius: 6px;
99
+ background: #fdfcf9;
100
+ }
101
+ details.frontmatter summary {
102
+ cursor: pointer;
103
+ padding: 0.6rem 1rem;
104
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
105
+ font-size: 0.85rem;
106
+ font-weight: 600;
107
+ color: #8b6914;
108
+ background: #f5f0e4;
109
+ border-radius: 6px 6px 0 0;
110
+ user-select: none;
111
+ }
112
+ details.frontmatter[open] summary {
113
+ border-bottom: 1px solid #e0d8c8;
114
+ }
115
+ .meta-table {
116
+ width: 100%;
117
+ border-collapse: collapse;
118
+ font-size: 0.88rem;
119
+ }
120
+ .meta-table th {
121
+ text-align: left;
122
+ vertical-align: top;
123
+ padding: 0.4rem 1rem;
124
+ width: 140px;
125
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
126
+ font-weight: 600;
127
+ color: #666;
128
+ white-space: nowrap;
129
+ }
130
+ .meta-table td {
131
+ padding: 0.4rem 1rem;
132
+ vertical-align: top;
133
+ word-break: break-word;
134
+ }
135
+ .meta-table tr:nth-child(even) { background: #faf8f2; }
136
+
137
+ .tag {
138
+ display: inline-block;
139
+ background: #e8dfc8;
140
+ color: #5a4a1e;
141
+ padding: 0.15rem 0.55rem;
142
+ border-radius: 3px;
143
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
144
+ font-size: 0.8rem;
145
+ margin: 0.1rem 0.2rem;
146
+ }
147
+
148
+ .empty { color: #ccc; }
149
+
150
+ /* Markdown content */
151
+ .md-content {
152
+ line-height: 1.8;
153
+ }
154
+ .md-content h1 { font-size: 1.5rem; margin: 1.5rem 0 0.8rem; color: #3a3a3a; }
155
+ .md-content h2 {
156
+ font-size: 1.25rem;
157
+ margin: 1.8rem 0 0.6rem;
158
+ color: #3a3a3a;
159
+ border-bottom: 1px solid #e0d8c8;
160
+ padding-bottom: 0.3rem;
161
+ }
162
+ .md-content h3 { font-size: 1.1rem; margin: 1.4rem 0 0.5rem; color: #555; }
163
+
164
+ .md-content blockquote {
165
+ border-left: 4px solid #d4b96a;
166
+ margin: 1rem 0;
167
+ padding: 0.5rem 1.2rem;
168
+ background: #fdfcf6;
169
+ color: #4a4a4a;
170
+ font-style: italic;
171
+ }
172
+ .md-content blockquote p {
173
+ white-space: pre-wrap;
174
+ margin: 0;
175
+ }
176
+
177
+ .md-content a {
178
+ color: #8b6914;
179
+ text-decoration: none;
180
+ border-bottom: 1px solid #d4b96a;
181
+ }
182
+ .md-content a:hover { border-bottom-color: #8b6914; }
183
+
184
+ .md-content code {
185
+ font-family: "SF Mono", Menlo, Consolas, monospace;
186
+ font-size: 0.88em;
187
+ background: #f0ece3;
188
+ padding: 0.15em 0.4em;
189
+ border-radius: 3px;
190
+ }
191
+ .md-content pre {
192
+ background: #2d2d2d;
193
+ color: #f0f0f0;
194
+ padding: 1rem 1.2rem;
195
+ border-radius: 6px;
196
+ overflow-x: auto;
197
+ font-size: 0.85rem;
198
+ line-height: 1.5;
199
+ }
200
+ .md-content pre code {
201
+ background: none;
202
+ padding: 0;
203
+ color: inherit;
204
+ }
205
+
206
+ .md-content hr {
207
+ border: none;
208
+ border-top: 1px solid #e0d8c8;
209
+ margin: 2rem 0;
210
+ }
211
+
212
+ .md-content ul, .md-content ol { padding-left: 1.5rem; }
213
+ .md-content li { margin-bottom: 0.3rem; }
214
+
215
+ /* Tables */
216
+ .table-wrap {
217
+ overflow-x: auto;
218
+ margin: 1rem 0;
219
+ -webkit-overflow-scrolling: touch;
220
+ }
221
+ .md-content table {
222
+ border-collapse: collapse;
223
+ width: 100%;
224
+ font-size: 0.88rem;
225
+ min-width: 500px;
226
+ }
227
+ .md-content th, .md-content td {
228
+ border: 1px solid #ddd;
229
+ padding: 0.45rem 0.7rem;
230
+ text-align: left;
231
+ vertical-align: top;
232
+ }
233
+ .md-content th {
234
+ background: #f5f0e4;
235
+ font-weight: 600;
236
+ color: #555;
237
+ white-space: nowrap;
238
+ }
239
+ .md-content tr:nth-child(even) { background: #fdfcf9; }
240
+
241
+ /* Wiki links */
242
+ a.wiki-link {
243
+ color: #6a8e3e;
244
+ border-bottom: 1px dashed #6a8e3e;
245
+ }
246
+ span.wiki-link.broken {
247
+ color: #c44;
248
+ border-bottom: 1px dashed #c44;
249
+ }
250
+
251
+ /* Raw/code view */
252
+ .raw-view {
253
+ background: #2d2d2d;
254
+ color: #f0f0f0;
255
+ padding: 1.2rem 1.5rem;
256
+ border-radius: 6px;
257
+ overflow-x: auto;
258
+ font-family: "SF Mono", Menlo, Consolas, monospace;
259
+ font-size: 0.85rem;
260
+ line-height: 1.5;
261
+ }
262
+
263
+ /* Download link */
264
+ .toolbar {
265
+ display: flex;
266
+ gap: 1rem;
267
+ align-items: center;
268
+ margin-bottom: 1rem;
269
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
270
+ font-size: 0.85rem;
271
+ }
272
+ .toolbar a {
273
+ color: #8b6914;
274
+ text-decoration: none;
275
+ border-bottom: 1px solid #d4b96a;
276
+ }
277
+ .toolbar a:hover { border-bottom-color: #8b6914; }
278
+
279
+ /* Directory header */
280
+ .dir-header {
281
+ display: flex;
282
+ justify-content: space-between;
283
+ align-items: baseline;
284
+ margin-bottom: 1rem;
285
+ }
286
+ .dir-count {
287
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
288
+ font-size: 0.8rem;
289
+ color: #aaa;
290
+ }
291
+ .sort-controls {
292
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
293
+ font-size: 0.75rem;
294
+ display: flex;
295
+ gap: 0.6rem;
296
+ }
297
+ .sort-controls a {
298
+ color: #bbb;
299
+ text-decoration: none;
300
+ transition: color 0.15s;
301
+ }
302
+ .sort-controls a:hover { color: #8b6914; }
303
+ .sort-controls a.sort-active {
304
+ color: #8b6914;
305
+ font-weight: 600;
306
+ }
307
+ .sort-arrow { font-size: 0.6rem; }
308
+
309
+ /* Rouge syntax highlighting (Monokai-inspired) */
310
+ .highlight .k, .highlight .kd, .highlight .kn, .highlight .kp,
311
+ .highlight .kr, .highlight .kt { color: #66d9ef; }
312
+ .highlight .s, .highlight .s1, .highlight .s2, .highlight .sb,
313
+ .highlight .sc, .highlight .sd, .highlight .sh, .highlight .sx { color: #e6db74; }
314
+ .highlight .c, .highlight .c1, .highlight .cm, .highlight .cs { color: #75715e; font-style: italic; }
315
+ .highlight .na { color: #a6e22e; }
316
+ .highlight .nf, .highlight .nb { color: #a6e22e; }
317
+ .highlight .nn, .highlight .nc { color: #66d9ef; }
318
+ .highlight .no { color: #ae81ff; }
319
+ .highlight .mi, .highlight .mf, .highlight .mh, .highlight .mo { color: #ae81ff; }
320
+ .highlight .o, .highlight .ow { color: #f92672; }
321
+ .highlight .p { color: #f0f0f0; }
322
+ .highlight .gi { color: #a6e22e; }
323
+ .highlight .gd { color: #f92672; }
324
+
325
+ /* TOC sidebar */
326
+ .page-with-toc {
327
+ display: flex;
328
+ gap: 2rem;
329
+ align-items: flex-start;
330
+ }
331
+ .toc-sidebar {
332
+ width: 220px;
333
+ flex-shrink: 0;
334
+ position: sticky;
335
+ top: 1rem;
336
+ max-height: calc(100vh - 2rem);
337
+ overflow-y: auto;
338
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
339
+ font-size: 0.8rem;
340
+ line-height: 1.4;
341
+ border-right: 1px solid #e0d8c8;
342
+ padding-right: 1rem;
343
+ }
344
+ .toc-sidebar .toc-title {
345
+ font-weight: 600;
346
+ color: #8b6914;
347
+ margin-bottom: 0.6rem;
348
+ font-size: 0.75rem;
349
+ text-transform: uppercase;
350
+ letter-spacing: 0.05em;
351
+ }
352
+ .toc-sidebar ul {
353
+ list-style: none;
354
+ padding: 0;
355
+ margin: 0;
356
+ }
357
+ .toc-sidebar li {
358
+ margin-bottom: 0.25rem;
359
+ }
360
+ .toc-sidebar a {
361
+ color: #666;
362
+ text-decoration: none;
363
+ display: block;
364
+ padding: 0.15rem 0;
365
+ border-left: 2px solid transparent;
366
+ padding-left: 0.5rem;
367
+ transition: color 0.15s, border-color 0.15s;
368
+ }
369
+ .toc-sidebar a:hover {
370
+ color: #8b6914;
371
+ border-left-color: #d4b96a;
372
+ }
373
+ .toc-sidebar a.active {
374
+ color: #8b6914;
375
+ border-left-color: #8b6914;
376
+ font-weight: 600;
377
+ }
378
+ .toc-sidebar .toc-h3 { padding-left: 1.1rem; font-size: 0.76rem; }
379
+ .toc-sidebar .toc-h4 { padding-left: 1.7rem; font-size: 0.73rem; }
380
+ .page-with-toc .page-main {
381
+ flex: 1;
382
+ min-width: 0;
383
+ }
384
+
385
+ /* Mobile TOC (replaces sidebar) */
386
+ .toc-mobile {
387
+ display: none;
388
+ margin-bottom: 1rem;
389
+ border: 1px solid #e0d8c8;
390
+ border-radius: 6px;
391
+ background: #fdfcf9;
392
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
393
+ font-size: 0.82rem;
394
+ }
395
+ .toc-mobile summary {
396
+ cursor: pointer;
397
+ padding: 0.5rem 0.8rem;
398
+ font-weight: 600;
399
+ color: #8b6914;
400
+ font-size: 0.8rem;
401
+ user-select: none;
402
+ }
403
+ .toc-mobile ul {
404
+ list-style: none;
405
+ padding: 0 0.8rem 0.5rem;
406
+ margin: 0;
407
+ }
408
+ .toc-mobile li { margin-bottom: 0.2rem; }
409
+ .toc-mobile a {
410
+ color: #666;
411
+ text-decoration: none;
412
+ padding: 0.2rem 0;
413
+ display: block;
414
+ }
415
+ .toc-mobile a:hover { color: #8b6914; }
416
+ .toc-mobile .toc-h3 { padding-left: 0.8rem; }
417
+ .toc-mobile .toc-h4 { padding-left: 1.4rem; }
418
+
419
+ /* Responsive */
420
+ @media (max-width: 768px) {
421
+ .container { padding: 1rem; }
422
+ .container.has-toc { max-width: 900px; }
423
+ h1.page-title { font-size: 1.3rem; }
424
+
425
+ .page-with-toc { display: block; }
426
+ .toc-sidebar { display: none; }
427
+ .toc-mobile { display: block; }
428
+
429
+ .meta-table th, .meta-table td {
430
+ display: block;
431
+ width: 100%;
432
+ padding: 0.2rem 0.8rem;
433
+ }
434
+ .meta-table th {
435
+ padding-top: 0.5rem;
436
+ padding-bottom: 0;
437
+ }
438
+
439
+ .dir-listing a { padding: 0.8rem 0.4rem; }
440
+ .dir-listing .name { font-size: 1rem; }
441
+ .dir-listing .meta .date { display: none; }
442
+ }
443
+
444
+ @media (max-width: 480px) {
445
+ .container { padding: 0.8rem; }
446
+ .breadcrumbs { font-size: 0.8rem; }
447
+ .md-content { font-size: 0.95rem; }
448
+ .md-content table { font-size: 0.8rem; }
449
+ }
450
+ </style>
451
+ </head>
452
+ <body>
453
+ <div class="container<%= ' has-toc' if @has_toc %>">
454
+ <% if @crumbs %>
455
+ <nav class="breadcrumbs">
456
+ <% @crumbs.each_with_index do |crumb, i| %>
457
+ <% if i > 0 %><span class="sep">/</span><% end %>
458
+ <% if i == @crumbs.length - 1 %>
459
+ <%= h(crumb[:name]) %>
460
+ <% else %>
461
+ <a href="<%= crumb[:href] %>"><%= h(crumb[:name]) %></a>
462
+ <% end %>
463
+ <% end %>
464
+ </nav>
465
+ <% end %>
466
+
467
+ <%= yield %>
468
+ </div>
469
+
470
+ <script>
471
+ // Wrap tables in scrollable containers
472
+ document.querySelectorAll('.md-content table').forEach(function(table) {
473
+ if (!table.parentElement.classList.contains('table-wrap')) {
474
+ var wrapper = document.createElement('div');
475
+ wrapper.className = 'table-wrap';
476
+ table.parentNode.insertBefore(wrapper, table);
477
+ wrapper.appendChild(table);
478
+ }
479
+ });
480
+
481
+ // TOC scroll spy — highlight the nearest heading
482
+ (function() {
483
+ var tocLinks = document.querySelectorAll('.toc-sidebar a');
484
+ if (!tocLinks.length) return;
485
+
486
+ var headings = [];
487
+ tocLinks.forEach(function(link) {
488
+ var id = link.getAttribute('href').slice(1);
489
+ var el = document.getElementById(id);
490
+ if (el) headings.push({ el: el, link: link });
491
+ });
492
+
493
+ function update() {
494
+ var scrollY = window.scrollY + 80;
495
+ var current = null;
496
+ for (var i = 0; i < headings.length; i++) {
497
+ if (headings[i].el.offsetTop <= scrollY) {
498
+ current = headings[i];
499
+ }
500
+ }
501
+ tocLinks.forEach(function(l) { l.classList.remove('active'); });
502
+ if (current) current.link.classList.add('active');
503
+ }
504
+
505
+ window.addEventListener('scroll', update, { passive: true });
506
+ update();
507
+ })();
508
+ </script>
509
+ </body>
510
+ </html>
@@ -0,0 +1,53 @@
1
+ <h1 class="page-title"><%= h(@title) %></h1>
2
+
3
+ <div class="toolbar">
4
+ <a href="<%= @download_href %>">Download</a>
5
+ </div>
6
+
7
+ <% if @has_toc %>
8
+ <details class="toc-mobile">
9
+ <summary>Table of Contents</summary>
10
+ <ul>
11
+ <% @toc.each do |entry| %>
12
+ <li class="toc-h<%= entry[:level] %>">
13
+ <a href="#<%= h(entry[:id]) %>"><%= h(entry[:text]) %></a>
14
+ </li>
15
+ <% end %>
16
+ </ul>
17
+ </details>
18
+ <% end %>
19
+
20
+ <div class="<%= @has_toc ? 'page-with-toc' : '' %>">
21
+ <% if @has_toc %>
22
+ <nav class="toc-sidebar">
23
+ <div class="toc-title">Contents</div>
24
+ <ul>
25
+ <% @toc.each do |entry| %>
26
+ <li class="toc-h<%= entry[:level] %>">
27
+ <a href="#<%= h(entry[:id]) %>"><%= h(entry[:text]) %></a>
28
+ </li>
29
+ <% end %>
30
+ </ul>
31
+ </nav>
32
+ <% end %>
33
+
34
+ <div class="page-main">
35
+ <% if @meta && !@meta.empty? %>
36
+ <details class="frontmatter" open>
37
+ <summary>Metadata</summary>
38
+ <table class="meta-table">
39
+ <% @meta.each do |key, value| %>
40
+ <tr>
41
+ <th><%= h(key) %></th>
42
+ <td><%= render_frontmatter_value(value) %></td>
43
+ </tr>
44
+ <% end %>
45
+ </table>
46
+ </details>
47
+ <% end %>
48
+
49
+ <div class="md-content" data-file="<%= h(@title) %>">
50
+ <%= @content %>
51
+ </div>
52
+ </div>
53
+ </div>
data/views/raw.erb ADDED
@@ -0,0 +1,10 @@
1
+ <h1 class="page-title"><%= h(@title) %></h1>
2
+
3
+ <div class="toolbar">
4
+ <a href="<%= @download_href %>">Download</a>
5
+ <span style="color: #aaa; font-size: 0.8rem;">(<%= h(@language) %>)</span>
6
+ </div>
7
+
8
+ <div class="raw-view">
9
+ <pre class="highlight"><%= @code %></pre>
10
+ </div>
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: markdownr
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brian Dunn
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sinatra
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '4.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '4.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rackup
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: puma
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: kramdown
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.4'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.4'
68
+ - !ruby/object:Gem::Dependency
69
+ name: kramdown-parser-gfm
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rouge
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '4.0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '4.0'
96
+ description: Serve markdown files from any directory with a clean web interface, syntax
97
+ highlighting, YAML frontmatter support, and wiki-link resolution.
98
+ executables:
99
+ - markdownr
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - bin/markdownr
104
+ - lib/markdown_server.rb
105
+ - lib/markdown_server/app.rb
106
+ - lib/markdown_server/version.rb
107
+ - views/directory.erb
108
+ - views/layout.erb
109
+ - views/markdown.erb
110
+ - views/raw.erb
111
+ homepage: https://github.com/brianmd/markdown-server
112
+ licenses:
113
+ - MIT
114
+ metadata:
115
+ source_code_uri: https://github.com/brianmd/markdown-server
116
+ changelog_uri: https://github.com/brianmd/markdown-server/releases
117
+ rdoc_options: []
118
+ require_paths:
119
+ - lib
120
+ required_ruby_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '3.0'
125
+ required_rubygems_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
130
+ requirements: []
131
+ rubygems_version: 3.6.9
132
+ specification_version: 4
133
+ summary: A local markdown file server with directory browsing
134
+ test_files: []