markdowndocs 0.6.0 → 0.8.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.
@@ -5,11 +5,12 @@ module Markdowndocs
5
5
  # Represents markdown documentation files from a configurable directory.
6
6
  # Handles metadata extraction, frontmatter parsing, and category associations.
7
7
  class Documentation
8
- attr_reader :slug, :title, :description, :category, :file_path, :keywords
8
+ attr_reader :slug, :path_slug, :title, :description, :category, :file_path, :keywords
9
9
 
10
10
  def initialize(file_path)
11
11
  @file_path = file_path
12
12
  @slug = derive_slug
13
+ @path_slug = derive_path_slug
13
14
  extract_metadata
14
15
  @category = assign_category
15
16
  end
@@ -18,23 +19,69 @@ module Markdowndocs
18
19
  docs_path = Markdowndocs.config.resolved_docs_path
19
20
  return [] unless docs_path.exist?
20
21
 
21
- Dir.glob(docs_path.join("*.md")).map do |file|
22
- new(Pathname.new(file))
23
- end.sort_by(&:slug)
22
+ files = Dir.glob(docs_path.join("*.md"))
23
+
24
+ modes = Markdowndocs.config.modes
25
+ modes.each do |mode|
26
+ mode_dir = docs_path.join(mode)
27
+ files.concat(Dir.glob(mode_dir.join("*.md"))) if mode_dir.exist?
28
+ end
29
+
30
+ warn_about_non_mode_subdirectories(docs_path, modes)
31
+
32
+ files.map { |f| new(Pathname.new(f)) }.sort_by(&:path_slug)
33
+ end
34
+
35
+ # Emits a one-shot warning per process boot for each first-level
36
+ # subdirectory under docs_path that isn't a configured mode. Files
37
+ # inside such subdirectories are silently dropped by discovery —
38
+ # the warning makes that visible.
39
+ def self.warn_about_non_mode_subdirectories(docs_path, modes)
40
+ warned = Markdowndocs.config.non_mode_subdirs_warned
41
+
42
+ children = begin
43
+ docs_path.children
44
+ rescue Errno::ENOENT, Errno::EACCES => e
45
+ Rails.logger.warn("[Markdowndocs] Could not scan for non-mode subdirectories: #{e.message}")
46
+ return
47
+ end
48
+
49
+ children.each do |child|
50
+ next unless child.directory?
51
+ name = child.basename.to_s
52
+ next if modes.include?(name)
53
+ next if warned.include?(name)
54
+
55
+ warned << name
56
+ Rails.logger.warn(
57
+ "[Markdowndocs] Ignoring subdirectory #{child}/ — name does not match " \
58
+ "any configured mode (config.modes = #{modes.inspect}). Files inside " \
59
+ "this subdirectory will not be discovered. Move them into #{docs_path}/ " \
60
+ "or into a mode-named subdirectory."
61
+ )
62
+ end
24
63
  end
64
+ private_class_method :warn_about_non_mode_subdirectories
25
65
 
26
- # When `mode:` is given (e.g. "guide" / "technical"), returns nil if
27
- # the resolved doc's `audience:` frontmatter excludes that mode. Docs
28
- # without an explicit `audience:` key default to "visible in all modes"
29
- # — backward compatible with pre-0.6 docs.
66
+ # Resolves a doc by slug. When `mode:` is given, prefers the mode-scoped
67
+ # file (docs/<mode>/<slug>.md) and falls back to the root (docs/<slug>.md)
68
+ # if visible_to?(mode) passes. With `mode: nil`, only the root is checked.
30
69
  def self.find_by_slug(slug, mode: nil)
31
70
  return nil if slug.blank?
32
71
  return nil if slug.include?("..") || slug.include?("/")
33
72
 
34
- file_path = Markdowndocs.config.resolved_docs_path.join("#{slug}.md")
35
- return nil unless file_path.exist?
73
+ docs_path = Markdowndocs.config.resolved_docs_path
74
+ docs_root_real = docs_path.realpath
75
+
76
+ if mode.present? && Markdowndocs.config.modes.include?(mode.to_s)
77
+ scoped = docs_path.join(mode.to_s, "#{slug}.md")
78
+ return new(scoped) if scoped.exist? && inside_docs_path?(scoped, docs_root_real)
79
+ end
80
+
81
+ root = docs_path.join("#{slug}.md")
82
+ return nil unless root.exist? && inside_docs_path?(root, docs_root_real)
36
83
 
37
- doc = new(file_path)
84
+ doc = new(root)
38
85
  return nil unless doc.visible_to?(mode)
39
86
 
40
87
  doc
@@ -43,6 +90,18 @@ module Markdowndocs
43
90
  nil
44
91
  end
45
92
 
93
+ # Returns true when file_path resolves (after following symlinks) to a
94
+ # location inside docs_root_real. Defense against symlinks that point
95
+ # outside the docs tree.
96
+ def self.inside_docs_path?(file_path, docs_root_real)
97
+ resolved = file_path.realpath
98
+ resolved.to_s == docs_root_real.to_s ||
99
+ resolved.to_s.start_with?(docs_root_real.to_s + File::SEPARATOR)
100
+ rescue Errno::ENOENT, Errno::ELOOP
101
+ false
102
+ end
103
+ private_class_method :inside_docs_path?
104
+
46
105
  def self.by_category(category)
47
106
  all.select { |doc| doc.category == category }
48
107
  end
@@ -50,9 +109,17 @@ module Markdowndocs
50
109
  # When `mode:` is given, filters out docs whose `audience:` excludes
51
110
  # that mode AND drops categories that end up empty (so the index
52
111
  # sidebar doesn't render headers with no children).
112
+ #
113
+ # Resolves slugs by matching against `path_slug` on the full discovered
114
+ # set so that path-prefixed slugs like "technical/architecture" (which
115
+ # `find_by_slug` rejects as directory traversal) are found correctly.
53
116
  def self.grouped_by_category(mode: nil)
117
+ all_docs = all
54
118
  Markdowndocs.config.categories.each_with_object({}) do |(category, slugs), hash|
55
- docs = slugs.map { |slug| find_by_slug(slug, mode: mode) }.compact
119
+ docs = slugs.filter_map do |slug|
120
+ doc = all_docs.find { |d| d.path_slug == slug }
121
+ doc if doc&.visible_to?(mode)
122
+ end
56
123
  hash[category] = docs unless docs.empty?
57
124
  end
58
125
  end
@@ -65,7 +132,7 @@ module Markdowndocs
65
132
  end
66
133
 
67
134
  def cache_key
68
- "#{slug}-#{mtime.to_i}"
135
+ "#{path_slug.tr("/", "-")}-#{mtime.to_i}"
69
136
  end
70
137
 
71
138
  def mtime
@@ -94,20 +161,26 @@ module Markdowndocs
94
161
  available_modes.include?(mode.to_s)
95
162
  end
96
163
 
97
- # The audience(s) this doc is written for, declared via `audience:`
98
- # frontmatter. Accepts a single string or an array; both are coerced
99
- # to an Array<String>. When the frontmatter key is missing, defaults
100
- # to all configured modes a doc with no audience declaration is
101
- # visible in every mode (backward compat with pre-0.6 docs).
164
+ # The audience(s) this doc is written for. Resolution order:
165
+ # 1. `audience:` frontmatter (DEPRECATED in 0.7.0, removed in 1.0.0)
166
+ # 2. Parent directory name when it matches a configured mode
167
+ # 3. All configured modes (root file with no override visible everywhere)
102
168
  def audience
103
169
  @audience ||= begin
104
170
  parsed = parse_frontmatter
105
171
  raw = parsed[:frontmatter]["audience"]
172
+
173
+ if raw
174
+ emit_audience_deprecation_warning_once
175
+ end
176
+
106
177
  case raw
107
- when Array then raw.map(&:to_s)
108
- when String then [ raw ]
109
- when nil then Markdowndocs.config.modes.dup
110
- else Markdowndocs.config.modes.dup
178
+ when Array then raw.map(&:to_s)
179
+ when String then [raw]
180
+ when nil
181
+ scope = audience_from_path
182
+ scope ? [scope] : Markdowndocs.config.modes.dup
183
+ else Markdowndocs.config.modes.dup
111
184
  end
112
185
  end
113
186
  end
@@ -148,6 +221,49 @@ module Markdowndocs
148
221
  file_path.basename(".md").to_s
149
222
  end
150
223
 
224
+ def derive_path_slug
225
+ docs_root = Markdowndocs.config.resolved_docs_path
226
+ relative = file_path.relative_path_from(docs_root)
227
+ relative.sub_ext("").to_s
228
+ end
229
+
230
+ def audience_from_path
231
+ dir = file_path.dirname.basename.to_s
232
+ Markdowndocs.config.modes.include?(dir) ? dir : nil
233
+ end
234
+
235
+ def emit_audience_deprecation_warning_once
236
+ path_str = file_path.to_s
237
+ emitted = Markdowndocs.config.audience_deprecation_emitted
238
+ return if emitted.include?(path_str)
239
+
240
+ emitted << path_str
241
+
242
+ Markdowndocs.deprecator.warn(
243
+ "`audience:` frontmatter in #{path_str} is deprecated. " \
244
+ "#{suggest_migration_target} The `audience:` key will be removed in v1.0.0."
245
+ )
246
+ end
247
+
248
+ def suggest_migration_target
249
+ parsed = parse_frontmatter
250
+ raw = parsed[:frontmatter]["audience"]
251
+
252
+ case raw
253
+ when String
254
+ "Move the file to #{file_path.dirname.join(raw, file_path.basename)} instead and remove the `audience:` key."
255
+ when Array
256
+ if Array(raw).map(&:to_s).sort == Markdowndocs.config.modes.sort
257
+ "This doc is already declared multi-audience; remove the `audience:` key (root files are visible in every mode)."
258
+ else
259
+ modes = Array(raw).map(&:to_s).join(", ")
260
+ "This doc declares audience: [#{modes}]. Path-based routing supports only a single mode per file; either move the file to a single mode subdirectory or leave the file at root and remove `audience:` (root is shared)."
261
+ end
262
+ else
263
+ "Move the file into the mode-named subdirectory matching its audience, or leave it at root and remove the key."
264
+ end
265
+ end
266
+
151
267
  def extract_metadata
152
268
  parsed = parse_frontmatter
153
269
 
@@ -227,7 +343,7 @@ module Markdowndocs
227
343
 
228
344
  def assign_category
229
345
  Markdowndocs.config.categories.each do |category, slugs|
230
- return category if slugs.include?(slug)
346
+ return category if slugs.include?(path_slug)
231
347
  end
232
348
 
233
349
  "Other"
@@ -45,8 +45,9 @@ module Markdowndocs
45
45
  end
46
46
 
47
47
  def render_markdown(markdown)
48
- doc = Commonmarker.parse(markdown, options: Markdowndocs.config.markdown_options)
49
- html = doc.to_html(options: Markdowndocs.config.markdown_options)
48
+ options = markdown_render_options
49
+ doc = Commonmarker.parse(markdown, options: options)
50
+ html = doc.to_html(options: options)
50
51
  html = apply_syntax_highlighting(html)
51
52
  sanitize_html(html)
52
53
  rescue => e
@@ -56,7 +57,9 @@ module Markdowndocs
56
57
  end
57
58
 
58
59
  def apply_syntax_highlighting(html)
59
- doc = Nokogiri::HTML.fragment(html)
60
+ # HTML5 parsing preserves case-sensitive SVG/MathML foreign-content
61
+ # attributes (e.g. viewBox) that Nokogiri::HTML would lowercase.
62
+ doc = Nokogiri::HTML5.fragment(html)
60
63
 
61
64
  doc.css("pre[lang]").each do |pre|
62
65
  language = pre["lang"]
@@ -91,25 +94,53 @@ module Markdowndocs
91
94
  "<pre><code>#{ERB::Util.html_escape(code)}</code></pre>"
92
95
  end
93
96
 
97
+ # Force commonmarker to pass raw HTML through when SVG is allowed, so the
98
+ # markup reaches the sanitizer (the security boundary) instead of being
99
+ # escaped. Returns a copy — does not mutate the configured options.
100
+ def markdown_render_options
101
+ options = Markdowndocs.config.markdown_options
102
+ return options unless Markdowndocs.config.allow_svg
103
+
104
+ options.merge(render: (options[:render] || {}).merge(unsafe: true))
105
+ end
106
+
107
+ BASE_SANITIZE_TAGS = %w[
108
+ h1 h2 h3 h4 h5 h6 p br hr blockquote
109
+ ul ol li dl dt dd
110
+ table thead tbody tfoot tr th td
111
+ a img
112
+ strong em b i u del
113
+ code pre span div
114
+ ].freeze
115
+
116
+ BASE_SANITIZE_ATTRS = %w[href title src alt align class lang].freeze
117
+
118
+ # Curated structural SVG subset. Deliberately excludes script,
119
+ # foreignObject, and SMIL animate/set tags, plus all on* handlers — the
120
+ # SafeListSanitizer drops anything not listed, so scripts/handlers and
121
+ # javascript: URIs are stripped even with unsafe HTML enabled.
122
+ SVG_SANITIZE_TAGS = %w[
123
+ svg g path rect circle ellipse line polyline polygon
124
+ text tspan defs marker title desc
125
+ ].freeze
126
+
127
+ SVG_SANITIZE_ATTRS = %w[
128
+ viewBox d points
129
+ x y x1 y1 x2 y2 cx cy r rx ry width height
130
+ fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray
131
+ transform opacity text-anchor dominant-baseline
132
+ font-size font-family font-weight
133
+ marker-start marker-end markerWidth markerHeight refX refY orient
134
+ role aria-label id
135
+ ].freeze
136
+
94
137
  def sanitize_html(html)
95
- sanitizer = Rails::HTML5::SafeListSanitizer.new
138
+ allow_svg = Markdowndocs.config.allow_svg
96
139
 
97
- sanitizer.sanitize(
140
+ Rails::HTML5::SafeListSanitizer.new.sanitize(
98
141
  html,
99
- tags: %w[
100
- h1 h2 h3 h4 h5 h6 p br hr blockquote
101
- ul ol li dl dt dd
102
- table thead tbody tfoot tr th td
103
- a img
104
- strong em b i u del
105
- code pre span div
106
- ],
107
- attributes: %w[
108
- href title
109
- src alt
110
- align
111
- class lang
112
- ]
142
+ tags: allow_svg ? BASE_SANITIZE_TAGS + SVG_SANITIZE_TAGS : BASE_SANITIZE_TAGS,
143
+ attributes: allow_svg ? BASE_SANITIZE_ATTRS + SVG_SANITIZE_ATTRS : BASE_SANITIZE_ATTRS
113
144
  )
114
145
  end
115
146
  end
@@ -1,6 +1,11 @@
1
1
  <%# locals: (current_mode:, available_modes:) %>
2
+ <%# No `id=` on the root: show.html.erb renders _navigation twice (mobile +
3
+ desktop sidebars), which embeds this partial twice — a hardcoded id
4
+ would produce duplicate ids in the DOM (WCAG 4.1.1 violation, see
5
+ issue #20). Stimulus already scopes itself via data-controller, which
6
+ can appear N times without colliding. Tests / selectors should target
7
+ [data-controller="docs-mode"] rather than #docs-mode-switcher. %>
2
8
  <div
3
- id="docs-mode-switcher"
4
9
  class="
5
10
  bg-white
6
11
  dark:bg-slate-800
@@ -36,7 +41,8 @@
36
41
  turbo_action: "replace"
37
42
  }
38
43
  ) do |f| %>
39
- <%= f.hidden_field :mode, value: mode %>
44
+ <%= f.hidden_field :mode, value: mode, id: nil %>
45
+ <%= f.hidden_field :current_path, value: request.fullpath, id: nil %>
40
46
  <button
41
47
  type="submit"
42
48
  role="radio"
@@ -1,3 +1,5 @@
1
+ <% content_for :title, "#{@doc.title} — Documentation" %>
2
+
1
3
  <div class="min-h-screen bg-gray-50 dark:bg-slate-900">
2
4
  <div class="max-w-7xl mx-auto px-4 py-12
3
5
  sm:px-6
@@ -66,7 +68,10 @@
66
68
  lg:grid-cols-12">
67
69
  <!-- Main content -->
68
70
  <main class="lg:col-span-8">
69
- <article class="bg-white dark:bg-slate-800 rounded-lg shadow-sm p-8">
71
+ <article
72
+ tabindex="-1"
73
+ autofocus
74
+ class="bg-white dark:bg-slate-800 rounded-lg shadow-sm p-8">
70
75
  <div class="prose prose-indigo dark:prose-invert max-w-none">
71
76
  <%= raw @rendered_content %>
72
77
  </div>
data/config/routes.rb CHANGED
@@ -3,6 +3,17 @@
3
3
  Markdowndocs::Engine.routes.draw do
4
4
  root "docs#index"
5
5
  get "search_index", to: "docs#search_index", as: :search_index
6
+
7
+ # Mode-scoped doc route: matches /<mode>/<slug> where <mode> is one of
8
+ # the configured modes. Must come BEFORE the unconstrained :slug route
9
+ # so the more specific match wins.
10
+ mode_constraint = if Markdowndocs.config.modes.any?
11
+ Regexp.new("(?:#{Markdowndocs.config.modes.map { |m| Regexp.escape(m) }.join("|")})")
12
+ else
13
+ /impossible/
14
+ end
15
+ get ":mode/:slug", to: "docs#show", as: :scoped_doc, constraints: {mode: mode_constraint}
16
+
6
17
  get ":slug", to: "docs#show", as: :doc
7
18
  resource :preference, only: [:update]
8
19
  end