markdownr 0.7.2 → 0.8.1

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.
@@ -73,7 +73,7 @@ module MarkdownServer
73
73
  %(<sup id="fnref:#{name}" role="doc-noteref"><a href="#fn:#{name}" class="footnote" rel="footnote">#{h(name)}</a></sup>)
74
74
  end
75
75
  end
76
- html.gsub(%r{<li id="fn:([^"]+)"[^>]*>\s*<p>}m) do
76
+ html = html.gsub(%r{<li id="fn:([^"]+)"[^>]*>\s*<p>}m) do
77
77
  full_match = $&
78
78
  name = $1
79
79
  if name =~ /\A\d+\z/
@@ -82,6 +82,107 @@ module MarkdownServer
82
82
  %(<li id="fn:#{name}"><p><strong>#{h(name)}:</strong> )
83
83
  end
84
84
  end
85
+
86
+ transform_callouts(html)
87
+ end
88
+
89
+ # Obsidian-style callouts: `> [!TYPE] Optional Title` at the start of a
90
+ # blockquote becomes a styled .callout panel. `[!TYPE]+` makes it a
91
+ # foldable <details open>; `[!TYPE]-` makes it foldable and collapsed.
92
+ CALLOUT_TYPES = {
93
+ "note" => { label: "Note", color: "#448aff" },
94
+ "abstract" => { label: "Abstract", color: "#00b0ff" },
95
+ "summary" => { label: "Summary", color: "#00b0ff" },
96
+ "tldr" => { label: "TL;DR", color: "#00b0ff" },
97
+ "info" => { label: "Info", color: "#00b8d4" },
98
+ "todo" => { label: "Todo", color: "#00b8d4" },
99
+ "tip" => { label: "Tip", color: "#00bfa5" },
100
+ "hint" => { label: "Hint", color: "#00bfa5" },
101
+ "important"=> { label: "Important",color: "#00bfa5" },
102
+ "success" => { label: "Success", color: "#00c853" },
103
+ "check" => { label: "Check", color: "#00c853" },
104
+ "done" => { label: "Done", color: "#00c853" },
105
+ "question" => { label: "Question", color: "#64dd17" },
106
+ "help" => { label: "Help", color: "#64dd17" },
107
+ "faq" => { label: "FAQ", color: "#64dd17" },
108
+ "warning" => { label: "Warning", color: "#ff9100" },
109
+ "caution" => { label: "Caution", color: "#ff9100" },
110
+ "attention"=> { label: "Attention",color: "#ff9100" },
111
+ "failure" => { label: "Failure", color: "#ff5252" },
112
+ "fail" => { label: "Fail", color: "#ff5252" },
113
+ "missing" => { label: "Missing", color: "#ff5252" },
114
+ "danger" => { label: "Danger", color: "#ff1744" },
115
+ "error" => { label: "Error", color: "#ff1744" },
116
+ "bug" => { label: "Bug", color: "#f50057" },
117
+ "example" => { label: "Example", color: "#7c4dff" },
118
+ "quote" => { label: "Quote", color: "#9e9e9e" },
119
+ "cite" => { label: "Cite", color: "#9e9e9e" }
120
+ }.freeze
121
+
122
+ CALLOUT_ICONS = {
123
+ "note" => '<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z"/>',
124
+ "abstract" => '<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h6"/>',
125
+ "info" => '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
126
+ "todo" => '<circle cx="12" cy="12" r="10"/><polyline points="9 12 11 14 15 10"/>',
127
+ "tip" => '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
128
+ "success" => '<polyline points="20 6 9 17 4 12"/>',
129
+ "question" => '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
130
+ "warning" => '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
131
+ "failure" => '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
132
+ "danger" => '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
133
+ "bug" => '<rect x="8" y="6" width="8" height="14" rx="4"/><path d="M19 7l-3 2M5 7l3 2M19 13h-3M8 13H5M19 19l-3-2M5 19l3-2M9 2l1 2M15 2l-1 2"/>',
134
+ "example" => '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
135
+ "quote" => '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>'
136
+ }.freeze
137
+
138
+ CALLOUT_ICON_ALIASES = {
139
+ "summary" => "abstract", "tldr" => "abstract",
140
+ "hint" => "tip", "important" => "tip",
141
+ "check" => "success", "done" => "success",
142
+ "help" => "question", "faq" => "question",
143
+ "caution" => "warning", "attention" => "warning",
144
+ "fail" => "failure", "missing" => "failure",
145
+ "error" => "danger",
146
+ "cite" => "quote"
147
+ }.freeze
148
+
149
+ def callout_meta(type)
150
+ CALLOUT_TYPES[type] || { label: type.capitalize, color: "#888888" }
151
+ end
152
+
153
+ def callout_icon_svg(type)
154
+ key = CALLOUT_ICON_ALIASES[type] || type
155
+ body = CALLOUT_ICONS[key] || CALLOUT_ICONS["note"]
156
+ %(<svg class="callout-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">#{body}</svg>)
157
+ end
158
+
159
+ def transform_callouts(html)
160
+ html.gsub(
161
+ %r{<blockquote>\s*<p>\[!(\w+)\]([+-])?[ \t]*([^\n<]*?)[ \t]*(?:<br\s*/?>\n?|\n)?(.*?)</p>(.*?)</blockquote>}m
162
+ ) do
163
+ type = $1.downcase
164
+ fold = $2
165
+ custom_title = $3.to_s.strip
166
+ first_p_rest = $4.to_s.strip
167
+ rest = $5.to_s.strip
168
+
169
+ meta = callout_meta(type)
170
+ title = custom_title.empty? ? meta[:label] : custom_title
171
+
172
+ body = +""
173
+ body << "<p>#{first_p_rest}</p>" unless first_p_rest.empty?
174
+ body << rest
175
+
176
+ icon = callout_icon_svg(type)
177
+ title_html = %(#{icon}<span class="callout-title-inner">#{h(title)}</span>)
178
+
179
+ if fold
180
+ open_attr = fold == "+" ? " open" : ""
181
+ %(<div class="callout callout-#{type}" data-callout="#{type}" style="--callout-color: #{meta[:color]}"><details#{open_attr}><summary class="callout-title">#{title_html}</summary><div class="callout-content">#{body}</div></details></div>)
182
+ else
183
+ %(<div class="callout callout-#{type}" data-callout="#{type}" style="--callout-color: #{meta[:color]}"><div class="callout-title">#{title_html}</div><div class="callout-content">#{body}</div></div>)
184
+ end
185
+ end
85
186
  end
86
187
 
87
188
  def resolve_wiki_link(name)
@@ -101,8 +202,7 @@ module MarkdownServer
101
202
  real = File.realpath(path) rescue next
102
203
  next unless real.start_with?(base)
103
204
  relative = real.sub("#{base}/", "")
104
- first_segment = relative.split("/").first
105
- next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
205
+ next unless MarkdownServer::Unhide.visible?(relative.split("/"), Array(settings.unhide_rules))
106
206
  if File.basename(real) == filename
107
207
  local_exact = relative
108
208
  break
@@ -124,8 +224,7 @@ module MarkdownServer
124
224
  real = File.realpath(path) rescue next
125
225
  next unless real.start_with?(base)
126
226
  relative = real.sub("#{base}/", "")
127
- first_segment = relative.split("/").first
128
- next if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
227
+ next unless MarkdownServer::Unhide.visible?(relative.split("/"), Array(settings.unhide_rules))
129
228
  if File.basename(real) == filename
130
229
  exact_match ||= relative
131
230
  else
@@ -203,6 +302,34 @@ module MarkdownServer
203
302
  formatter.format(lexer.lex(code))
204
303
  end
205
304
 
305
+ def detect_source_language(real_path, content)
306
+ ext = File.extname(real_path).downcase
307
+ by_ext = case ext
308
+ when ".py" then "python"
309
+ when ".rb" then "ruby"
310
+ when ".csv" then "text"
311
+ when ".sh" then "bash"
312
+ when ".yaml", ".yml" then "yaml"
313
+ when ".erb" then "html"
314
+ when ".html" then "html"
315
+ when ".js" then "javascript"
316
+ when ".css" then "css"
317
+ when ".json" then "json"
318
+ end
319
+ return by_ext if by_ext
320
+
321
+ if content.is_a?(String) && content.start_with?("#!")
322
+ shebang = content.lines.first.to_s
323
+ return "ruby" if shebang.match?(/\bruby\b/)
324
+ return "python" if shebang.match?(/\bpython[\d.]*\b/)
325
+ return "bash" if shebang.match?(/\b(bash|zsh|dash|ksh|sh)\b/)
326
+ return "javascript" if shebang.match?(/\bnode\b/)
327
+ return "perl" if shebang.match?(/\bperl\b/)
328
+ end
329
+
330
+ "text"
331
+ end
332
+
206
333
  def extract_toc(html)
207
334
  headings = []
208
335
  html.scan(/<h([1-6])\s[^>]*id="([^"]*)"[^>]*>(.*?)<\/h\1>/mi) do |level, id, text|
@@ -13,6 +13,27 @@ module MarkdownServer
13
13
  URI.encode_www_form_component(str).gsub("+", "%20")
14
14
  end
15
15
 
16
+ # Returns the permitted base (root realpath or a followed-link target realpath)
17
+ # that contains `real`, or nil if no permitted base contains it.
18
+ def permitted_base_for(real)
19
+ MarkdownServer::PermittedBases.base_for(real, permitted_bases)
20
+ end
21
+
22
+ def permitted_bases
23
+ [File.realpath(root_dir), *Array(settings.followed_links)]
24
+ end
25
+
26
+ # Returns true if `real` is under a permitted base AND every restricted
27
+ # segment of the path under that base is admitted by the configured
28
+ # unhide rules (per Unhide.visible? algorithm).
29
+ def permitted_path?(real)
30
+ base = permitted_base_for(real)
31
+ return false unless base
32
+ return true if real == base
33
+ rel = real.sub("#{base}/", "")
34
+ MarkdownServer::Unhide.visible?(rel.split("/"), Array(settings.unhide_rules))
35
+ end
36
+
16
37
  def safe_path(requested)
17
38
  base = File.realpath(root_dir)
18
39
  full = File.join(base, requested)
@@ -23,17 +44,45 @@ module MarkdownServer
23
44
  halt 404, erb(:layout) { "<h1>Not Found</h1><p>#{h(requested)}</p>" }
24
45
  end
25
46
 
26
- unless real.start_with?(base)
27
- halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
47
+ halt 403, erb(:layout) { "<h1>Forbidden</h1>" } unless permitted_path?(real)
48
+ real
49
+ end
50
+
51
+ # Name-gate check for an entry in a directory listing.
52
+ #
53
+ # Boundary gate (against served root) is NOT applied here so non-followed
54
+ # external non-dot symlinks still appear in listings — matching today's
55
+ # UX; clicking them produces a 403 via safe_path/permitted_path?.
56
+ #
57
+ # When the name gate admits a normally-restricted entry (dotfile or
58
+ # EXCLUDED) that is also a symlink, additionally require the symlink's
59
+ # realpath to be explicitly listed in --follow-link. Otherwise the
60
+ # unhide rule does not surface it. Keeps `unhide` (visibility by name)
61
+ # and `--follow-link` (which symlinks may be entered) as orthogonal
62
+ # opt-ins; prevents internal-aliased dotfile symlinks like
63
+ # `.claude → claude` from showing up as duplicates of their targets.
64
+ def entry_admitted?(parent_real, parent_relative_str, entry_name)
65
+ rules = Array(settings.unhide_rules)
66
+ parent_segs = parent_relative_str.to_s.empty? ? [] : parent_relative_str.split("/")
67
+
68
+ mode = :open
69
+ parent_segs.each_with_index do |seg, i|
70
+ ok, mode = MarkdownServer::Unhide.step(mode, parent_segs, i, seg, rules)
71
+ return false unless ok
28
72
  end
29
73
 
30
- relative = real.sub("#{base}/", "")
31
- first_segment = relative.split("/").first
32
- if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
33
- halt 403, erb(:layout) { "<h1>Forbidden</h1>" }
74
+ ok, _ = MarkdownServer::Unhide.entry_step(mode, parent_segs, entry_name, rules)
75
+ return false unless ok
76
+
77
+ if MarkdownServer::Unhide.restricted?(entry_name)
78
+ full = File.join(parent_real, entry_name)
79
+ if File.symlink?(full)
80
+ real = File.realpath(full) rescue (return false)
81
+ return false unless Array(settings.followed_links).include?(real)
82
+ end
34
83
  end
35
84
 
36
- real
85
+ true
37
86
  end
38
87
  end
39
88
  end
@@ -60,13 +60,41 @@ module MarkdownServer
60
60
  results
61
61
  end
62
62
 
63
- def walk_directory(dir_path, &block)
63
+ def walk_directory(dir_path, parent_segs = nil, parent_mode = nil, &block)
64
+ rules = Array(settings.unhide_rules)
65
+ bases = permitted_bases
66
+
67
+ if parent_segs.nil?
68
+ base = File.realpath(root_dir)
69
+ real_dir = File.realpath(dir_path) rescue dir_path
70
+ parent_segs = (real_dir == base || !real_dir.start_with?("#{base}/")) ? [] : real_dir.sub("#{base}/", "").split("/")
71
+ parent_mode = :open
72
+ parent_segs.each_with_index do |seg, i|
73
+ ok, parent_mode = MarkdownServer::Unhide.step(parent_mode, parent_segs, i, seg, rules)
74
+ return unless ok
75
+ end
76
+ end
77
+
64
78
  Dir.entries(dir_path).sort.each do |entry|
65
- next if entry.start_with?(".") || EXCLUDED.include?(entry)
79
+ next if entry == "." || entry == ".."
80
+
81
+ visible, child_mode = MarkdownServer::Unhide.entry_step(parent_mode, parent_segs, entry, rules)
82
+ next unless visible
83
+
66
84
  full = File.join(dir_path, entry)
85
+ real = File.realpath(full) rescue next
86
+ next unless MarkdownServer::PermittedBases.base_for(real, bases)
87
+
88
+ # Restricted entries that are symlinks: only descend / index when
89
+ # the symlink's realpath is explicitly in --follow-link. Mirrors
90
+ # entry_admitted? in path_helpers; prevents unhide from
91
+ # double-walking internal-aliased dotfile symlinks.
92
+ if MarkdownServer::Unhide.restricted?(entry) && File.symlink?(full)
93
+ next unless Array(settings.followed_links).include?(real)
94
+ end
67
95
 
68
96
  if File.directory?(full)
69
- walk_directory(full, &block)
97
+ walk_directory(full, parent_segs + [entry], child_mode, &block)
70
98
  elsif File.file?(full)
71
99
  ext = File.extname(entry).downcase
72
100
  next if BINARY_EXTENSIONS.include?(ext)
@@ -0,0 +1,13 @@
1
+ module MarkdownServer
2
+ module PermittedBases
3
+ # Returns the permitted base (one of `bases`) that contains `real`,
4
+ # or nil if no base contains it. Each base is the realpath of the
5
+ # served root or a --follow-link target.
6
+ def self.base_for(real, bases)
7
+ Array(bases).each do |b|
8
+ return b if real == b || real.start_with?("#{b}/")
9
+ end
10
+ nil
11
+ end
12
+ end
13
+ end
@@ -33,6 +33,10 @@ module MarkdownServer
33
33
 
34
34
  # Hook: post-process rendered markdown HTML (runs after render_markdown)
35
35
  def post_render(html, meta, app) html end
36
+
37
+ # Hook: claim a file for custom popup rendering in /browser
38
+ # Return { type:, title:, ... } hash or nil to pass
39
+ def browser_render(relative_path, real_path, app) nil end
36
40
  end
37
41
 
38
42
  class PluginRegistry
@@ -53,6 +57,13 @@ module MarkdownServer
53
57
  end
54
58
 
55
59
  config = resolve_config(root_dir, cli_overrides)
60
+ known_names = registered.map(&:plugin_name)
61
+ config.each do |name, cfg|
62
+ next unless cfg.is_a?(Hash) && cfg["enabled"]
63
+ unless known_names.include?(name)
64
+ $stderr.puts "\n\e[1;33mWarning: unknown plugin '#{name}' (available: #{known_names.join(", ")})\e[0m\n\n"
65
+ end
66
+ end
56
67
  registered.filter_map do |klass|
57
68
  plugin_config = config.fetch(klass.plugin_name, {})
58
69
  enabled = plugin_config.fetch("enabled", klass.enabled_by_default?)
@@ -90,18 +90,18 @@ module MarkdownServer
90
90
  end
91
91
 
92
92
  def self.scrip_url(canonical, verse, version: DEFAULT_VERSION)
93
- # Parse chapter and optional starting verse from the verse string
93
+ # Parse chapter and optional verse range from the verse string
94
94
  # verse is e.g. "1:1", "1:1-5", "1", "1-2", "1:1,3,5"
95
- if verse =~ /\A(\d+)(?::(\d+))?/
95
+ if verse =~ /\A(\d+)(?::(\d+(?:[–—\-]\d+)?))?/
96
96
  chapter = format("%03d", $1.to_i)
97
- start_verse = $2
97
+ verse_part = $2&.gsub(/[–—]/, "-")
98
98
  else
99
99
  return biblegateway_url(canonical, verse, version: version)
100
100
  end
101
101
 
102
102
  encode = ->(s) { URI.encode_www_form_component(s).gsub("+", "%20") }
103
103
  url = "https://scrip.risensavior.com/browse/#{encode[version]}/#{encode[canonical]}/#{chapter}.html"
104
- url += "#v#{start_verse}" if start_verse
104
+ url += "#v#{verse_part}" if verse_part
105
105
  url
106
106
  end
107
107
 
@@ -0,0 +1,114 @@
1
+ module MarkdownServer
2
+ module Unhide
3
+ Rule = Struct.new(:kind, :segments)
4
+
5
+ class CompileError < StandardError; end
6
+
7
+ def self.compile(raw_entries)
8
+ Array(raw_entries).compact.map { |e| compile_one(e) }.uniq
9
+ end
10
+
11
+ def self.compile_one(raw)
12
+ entry = raw.to_s.strip
13
+ raise CompileError, "unhide entry is empty" if entry.empty?
14
+
15
+ if entry.start_with?("@/")
16
+ path = entry.sub(%r{^@/+}, "")
17
+ raise CompileError, "unhide entry '#{raw}' has no path after '@/'" if path.empty?
18
+ segments = normalize_segments(path)
19
+ raise CompileError, "unhide entry '#{raw}' has no usable segments" if segments.empty?
20
+ Rule.new(:anchored, segments)
21
+ else
22
+ if entry.start_with?("/")
23
+ raise CompileError,
24
+ "unhide entry '#{raw}' has a leading '/'. " \
25
+ "Use '@/...' for project-root-anchored, or omit the slash for any-depth match."
26
+ end
27
+ segments = normalize_segments(entry)
28
+ raise CompileError, "unhide entry '#{raw}' has no usable segments" if segments.empty?
29
+ Rule.new(segments.length == 1 ? :basename : :suffix, segments)
30
+ end
31
+ end
32
+
33
+ def self.normalize_segments(path)
34
+ path.split("/").reject(&:empty?)
35
+ end
36
+
37
+ def self.restricted?(name)
38
+ return false if name.nil? || name.empty?
39
+ name.start_with?(".") || MarkdownServer::EXCLUDED.include?(name)
40
+ end
41
+
42
+ # Returns :leaf if any rule matches segment i as its leaf,
43
+ # :prefix if any rule matches segment i as a navigational prefix,
44
+ # :none otherwise.
45
+ def self.match_at(path_segs, i, rules)
46
+ best = :none
47
+ rules.each do |r|
48
+ case r.kind
49
+ when :basename
50
+ return :leaf if r.segments[0] == path_segs[i]
51
+ when :suffix
52
+ r.segments.length.times do |k|
53
+ next unless i >= k
54
+ next unless path_segs[(i - k)..i] == r.segments[0..k]
55
+ if k + 1 == r.segments.length
56
+ return :leaf
57
+ else
58
+ best = :prefix
59
+ end
60
+ end
61
+ when :anchored
62
+ if i < r.segments.length && path_segs[0..i] == r.segments[0..i]
63
+ if i + 1 == r.segments.length
64
+ return :leaf
65
+ else
66
+ best = :prefix
67
+ end
68
+ end
69
+ end
70
+ end
71
+ best
72
+ end
73
+
74
+ # Cold check: walks segment by segment from root, tracking mode.
75
+ # Returns true iff every segment of path_segments is admitted.
76
+ def self.visible?(path_segments, rules)
77
+ mode = :open
78
+ path_segments.each_with_index do |seg, i|
79
+ visible, mode = step(mode, path_segments, i, seg, rules)
80
+ return false unless visible
81
+ end
82
+ true
83
+ end
84
+
85
+ # Amortized step for an enumeration. parent_mode and parent_segs are known
86
+ # from the enclosing recursion; this checks one entry. Returns [visible, child_mode].
87
+ def self.entry_step(parent_mode, parent_segs, entry_name, rules)
88
+ path_segs = parent_segs + [entry_name]
89
+ step(parent_mode, path_segs, path_segs.length - 1, entry_name, rules)
90
+ end
91
+
92
+ def self.step(mode, path_segs, i, seg, rules)
93
+ restricted = restricted?(seg)
94
+
95
+ if restricted
96
+ case match_at(path_segs, i, rules)
97
+ when :leaf then [true, :open]
98
+ when :prefix then [true, :narrow]
99
+ else [false, nil]
100
+ end
101
+ else
102
+ if mode == :open
103
+ [true, :open]
104
+ else
105
+ case match_at(path_segs, i, rules)
106
+ when :leaf then [true, :open]
107
+ when :prefix then [true, :narrow]
108
+ else [false, nil]
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.7.2"
2
+ VERSION = "0.8.1"
3
3
  end