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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +115 -0
- data/README.md +95 -14
- data/app/controllers/markdowndocs/docs_controller.rb +29 -9
- data/app/controllers/markdowndocs/preferences_controller.rb +62 -3
- data/app/helpers/markdowndocs/docs_helper.rb +6 -2
- data/app/models/markdowndocs/documentation.rb +139 -23
- data/app/services/markdowndocs/markdown_renderer.rb +50 -19
- data/app/views/markdowndocs/docs/_mode_switcher.html.erb +8 -2
- data/app/views/markdowndocs/docs/show.html.erb +6 -1
- data/config/routes.rb +11 -0
- data/docs/superpowers/plans/2026-05-15-path-based-audience-routing.md +1619 -0
- data/docs/superpowers/specs/2026-05-15-path-based-audience-routing-design.md +311 -0
- data/lib/markdowndocs/configuration.rb +9 -1
- data/lib/markdowndocs/version.rb +1 -1
- data/lib/markdowndocs.rb +7 -0
- metadata +3 -1
|
@@ -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"))
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
27
|
-
#
|
|
28
|
-
#
|
|
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
|
-
|
|
35
|
-
|
|
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(
|
|
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.
|
|
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
|
-
"#{
|
|
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
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
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
|
|
108
|
-
when String then [
|
|
109
|
-
when nil
|
|
110
|
-
|
|
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?(
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
+
allow_svg = Markdowndocs.config.allow_svg
|
|
96
139
|
|
|
97
|
-
|
|
140
|
+
Rails::HTML5::SafeListSanitizer.new.sanitize(
|
|
98
141
|
html,
|
|
99
|
-
tags:
|
|
100
|
-
|
|
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
|
|
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
|