markdowndocs 0.6.1 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 834e28ea5348dcb63b8e7c5f952baa34d72166958da926627e16e8c3d1c300e8
4
- data.tar.gz: 7cae90fb66f75bf05e14ef61189ab5663e655459a55dc914c9187341af715046
3
+ metadata.gz: 764b5150885d6a306eae6d49fbabd8dedee96dffa9bc55a6f290da57e3b53123
4
+ data.tar.gz: 2a75805e06c5caa6fcdbf8e4860836bcf886fc5453b4d394761f8f45ae8ba114
5
5
  SHA512:
6
- metadata.gz: 43e73ace730900362729bc27b4d086cfac1ba6416d1270df6c52ed1ae40bceccd04cde503c0ae29cc1edd4bb890472ec5c9a8877cbf3bd2307d17d66413eb463
7
- data.tar.gz: 9d787524af79383f2a77c9c7c1a0bce5c18070a1ab9c46c7ae94fe32c2aa728d15f4f8f7741f14c751edc4ae3c84c9e20a3478d1d668eebc8beb3c12784592e5
6
+ metadata.gz: 8502ba23be6b2ccafa8abc6f0388fddfaf1eae79da276b2ec676087195f04ddc000c576b636c5f81473c10fe30f2786faa9fa7b901419a3f8689226d6440a86e
7
+ data.tar.gz: 0d71e8284d7a5ed9efcdd8841e0a7e760bb26906e4ac61fb27a1ec32e259357c920e6b86ff66bc16e7d3fdda632c2ed5c12b216ff2b71c1f025f3f1ab73aab99
data/CHANGELOG.md CHANGED
@@ -5,6 +5,102 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.0] - 2026-06-21
9
+
10
+ ### Added
11
+
12
+ - **Collapsible disclosure (`<details>` / `<summary>`).** Both tags — plus the
13
+ `open` attribute — are now in the sanitizer allow-list, so docs can use native,
14
+ no-JS click-to-expand sections. Rides the same curated raw-HTML passthrough as
15
+ inline SVG (requires `config.allow_svg = true`, which flips commonmarker to
16
+ unsafe so the markup reaches the sanitizer). Scripts and `on*` handlers inside
17
+ a disclosure are still stripped.
18
+
19
+ ## [0.8.0] - 2026-05-29
20
+
21
+ ### Added
22
+
23
+ - **Opt-in inline SVG (`config.allow_svg`).** When set to `true`, a curated,
24
+ safe subset of structural SVG tags and attributes is permitted in rendered
25
+ documents — useful for hand-authored diagrams. The `Rails::HTML5`
26
+ SafeListSanitizer remains the security boundary: `<script>`,
27
+ `<foreignObject>`, `on*` event handlers, and `javascript:` URIs are still
28
+ stripped. Defaults to `false`, so existing behavior is unchanged.
29
+
30
+ ### Fixed
31
+
32
+ - Heading-anchor injection, table-of-contents extraction, and syntax
33
+ highlighting now parse with `Nokogiri::HTML5` instead of `Nokogiri::HTML`,
34
+ preserving case-sensitive SVG/MathML foreign-content attributes (e.g.
35
+ `viewBox`, `markerWidth`, `refX`) that were previously lowercased on
36
+ re-serialization, which silently broke any inline SVG.
37
+
38
+ ## [0.7.0] - 2026-05-15
39
+
40
+ ### Added
41
+
42
+ - **Path-based audience routing.** A first-level subdirectory of
43
+ `app/docs/` whose name matches an entry in `config.modes` is now
44
+ treated as an audience scope. Files inside `app/docs/technical/` are
45
+ visible only when the current mode is `technical`; files at the root
46
+ remain shared (visible in every mode). The new convention is the
47
+ recommended way to scope whole documents and replaces `audience:`
48
+ frontmatter (see Deprecated below).
49
+ - **`/docs/:mode/:slug` route.** Mode-scoped documents are served at
50
+ stable, RESTful URLs (e.g., `/docs/technical/architecture`). The
51
+ `:mode` segment is constrained to entries in `config.modes`; unknown
52
+ modes return 404.
53
+ - **Path-prefixed slugs in `config.categories`.** Slug entries may now
54
+ include a mode prefix (e.g., `"technical/architecture"`) to attach a
55
+ mode-scoped doc to a category. Bare slugs continue to match root
56
+ files. Example:
57
+
58
+ config.categories = {
59
+ "Architecture" => %w[technical/architecture]
60
+ }
61
+
62
+ - **Smart navigation in mode switcher.** Toggling the mode now attempts
63
+ to navigate to a same-slug document in the target mode's location,
64
+ falling back to the shared root sibling, then staying put. Sharing
65
+ links still works because URLs are stable.
66
+ - **`Markdowndocs.deprecator`** ActiveSupport::Deprecation instance for
67
+ emitting gem-specific deprecation warnings. Hosts can configure
68
+ behavior (silence / raise / log) via standard
69
+ `ActiveSupport::Deprecation` APIs.
70
+
71
+ ### Changed
72
+
73
+ - `Documentation.all` walks both `app/docs/*.md` and
74
+ `app/docs/<mode>/*.md` for every configured mode.
75
+ - `Documentation` instances expose `#path_slug` (the file's path
76
+ relative to the docs root, sans `.md`).
77
+ - `Documentation.find_by_slug(slug, mode:)` prefers the mode-scoped
78
+ file first, then falls back to the root.
79
+ - `PreferencesController#update` now expects a `current_path` form
80
+ field (added in `_mode_switcher.html.erb`) and computes the smart-nav
81
+ target before redirecting. Hosts with custom forms targeting
82
+ `preference_path` should include `<input type="hidden"
83
+ name="current_path" value="<%= request.fullpath %>">` to opt into
84
+ smart navigation. Without it, the controller redirects to the docs
85
+ index (no behavior loss, just no smart-nav benefit).
86
+
87
+ ### Deprecated
88
+
89
+ - **`audience:` frontmatter.** Still functional, but emits a one-shot
90
+ warning per file path per process boot. Will be removed in v1.0.0.
91
+ Migration: move the file into a matching mode subdirectory (or, for
92
+ multi-audience docs, drop the key — root files are shared). The
93
+ warning message includes the suggested target path.
94
+
95
+ ### Migration notes
96
+
97
+ - See `README.md` ("Migrating from v0.6.x to v0.7.0") for full guidance.
98
+ - URL stability: every URL from v0.6.x continues to resolve unchanged.
99
+ - Subdirectories under `app/docs/` whose name doesn't match a
100
+ configured mode are now ignored (one-line warning at discovery). If
101
+ you've been using non-mode subdirectories for organization, either
102
+ flatten them or rename them to match a configured mode.
103
+
8
104
  ## [0.6.1] - 2026-05-13
9
105
 
10
106
  ### Fixed
@@ -194,6 +290,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
194
290
  - i18n support for all UI strings
195
291
  - Install generator (`rails generate markdowndocs:install`)
196
292
 
293
+ [0.7.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.7.0
294
+ [0.6.1]: https://github.com/dschmura/markdowndocs/releases/tag/v0.6.1
295
+ [0.6.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.6.0
197
296
  [0.5.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.5.0
198
297
  [0.4.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.4.0
199
298
  [0.3.1]: https://github.com/dschmura/markdowndocs/releases/tag/v0.3.1
data/README.md CHANGED
@@ -55,8 +55,8 @@ Markdowndocs.configure do |config|
55
55
  # Category → slug mapping
56
56
  config.categories = {
57
57
  "Getting Started" => %w[welcome quickstart],
58
- "Guides" => %w[authentication deployment],
59
- "Reference" => %w[api-reference configuration]
58
+ "Guides" => %w[authentication billing],
59
+ "Architecture" => %w[technical/architecture technical/billing]
60
60
  }
61
61
 
62
62
  # Available documentation modes (default: %w[guide technical])
@@ -71,6 +71,11 @@ Markdowndocs.configure do |config|
71
71
  # Cache expiry for rendered markdown (default: 1.hour)
72
72
  # config.cache_expiry = 1.hour
73
73
 
74
+ # Allow a curated, safe subset of inline SVG (for hand-authored diagrams).
75
+ # Scripts, event handlers, and javascript: URIs are still stripped by the
76
+ # sanitizer. Default: false. (default: false)
77
+ # config.allow_svg = true
78
+
74
79
  # Optional: Resolve current user's mode preference from database
75
80
  # config.user_mode_resolver = ->(controller) {
76
81
  # controller.send(:current_user)&.preferences&.docs_mode
@@ -83,6 +88,11 @@ Markdowndocs.configure do |config|
83
88
  end
84
89
  ```
85
90
 
91
+ > Bare slugs (e.g., `"welcome"`) match files at the docs root.
92
+ > Path-prefixed slugs (e.g., `"technical/architecture"`) match files
93
+ > inside the named mode subdirectory. The prefix segment must match
94
+ > an entry in `config.modes`.
95
+
86
96
  ### Configuration Options
87
97
 
88
98
  | Option | Default | Description |
@@ -108,13 +118,7 @@ Add optional YAML front matter to set metadata:
108
118
  ---
109
119
  title: "Quick Start Guide"
110
120
  description: "Get up and running in five minutes"
111
- audience:
112
- - guide
113
- - technical
114
- modes:
115
- - guide
116
- - technical
117
- default_mode: guide
121
+ keywords: [setup, install]
118
122
  ---
119
123
 
120
124
  # Quick Start Guide
@@ -122,25 +126,91 @@ default_mode: guide
122
126
  Your content here...
123
127
  ```
124
128
 
129
+ Recognized keys:
130
+
131
+ | Key | Type | Purpose |
132
+ | -------------- | ---------------- | -------------------------------------------------------------------------------- |
133
+ | `title` | String | Overrides the H1-derived title shown in nav and `<title>` |
134
+ | `description` | String | Card description on the index page; defaults to the first paragraph |
135
+ | `keywords` | Array of String | Tags surfaced to the search indexer |
136
+ | `modes` | Array of String | Per-doc override of which modes contain block-filtered content (see Mode Blocks) |
137
+ | `default_mode` | String | Per-doc default mode (overrides `config.default_mode`) |
138
+ | `audience` | String / Array | **Deprecated.** Use filesystem-path scoping instead — see below |
139
+
125
140
  If front matter is omitted, the title is extracted from the first H1 heading and the description from the first paragraph.
126
141
 
127
- ### Audience Filtering (whole-document)
142
+ ### Audience Filtering by Filesystem Path
143
+
144
+ The recommended way to scope a whole document to a single audience is to
145
+ place it inside a subdirectory whose name matches an entry in
146
+ `config.modes`. Files at the docs root are *shared* — visible in every
147
+ mode.
148
+
149
+ ```text
150
+ app/docs/
151
+ ├── getting_started.md → shared, visible in every mode
152
+ ├── billing.md → shared
153
+ └── technical/
154
+ ├── architecture.md → technical mode only
155
+ └── billing.md → technical mode only
156
+ ```
128
157
 
129
- For content that's split into separate files per audience (a user guide
130
- and a deep-dive technical reference, for example), use the `audience:`
131
- key in front matter to declare who a doc is for:
158
+ URLs follow the filesystem layout: `app/docs/billing.md` is served at
159
+ `/docs/billing`; `app/docs/technical/billing.md` is served at
160
+ `/docs/technical/billing`. Both URLs are stable and shareable.
161
+
162
+ Subdirectories whose name does not match a configured mode are ignored
163
+ by document discovery, with a one-line warning at boot.
164
+
165
+ #### Linking to Docs from Your Host App
166
+
167
+ The engine exposes two named route helpers under its mount point:
168
+
169
+ ```erb
170
+ <%# Shared (root) doc — /docs/billing %>
171
+ <%= link_to "Billing", markdowndocs.doc_path(slug: "billing") %>
172
+
173
+ <%# Mode-scoped doc — /docs/technical/architecture %>
174
+ <%= link_to "Architecture",
175
+ markdowndocs.scoped_doc_path(mode: "technical", slug: "architecture") %>
176
+ ```
177
+
178
+ The `:mode` segment is constrained at the route level; unknown modes
179
+ 404 rather than reach the controller.
180
+
181
+ #### Switching Modes
182
+
183
+ The mode switcher in the docs UI is *smart* about navigation. When the
184
+ viewer toggles modes, the engine attempts to keep them on the
185
+ equivalent document in the target mode:
186
+
187
+ - On `/docs/billing` (shared root), switching to `technical` redirects
188
+ to `/docs/technical/billing` if that scoped sibling exists; otherwise
189
+ it stays on the shared doc.
190
+ - On `/docs/technical/architecture`, switching to `guide` redirects to
191
+ `/docs/architecture` if a shared sibling exists; otherwise it stays
192
+ on the current page.
193
+ - Toggling from the index (`/docs`) just reloads the index in the new
194
+ mode.
195
+
196
+ URLs remain stable across the toggle — bookmarks and shared links to
197
+ mode-scoped docs keep working.
198
+
199
+ ### Audience Filtering by Frontmatter (deprecated)
200
+
201
+ The `audience:` frontmatter key from v0.6.0 still works in v0.7.x but is
202
+ deprecated. A warning is logged the first time each affected file is
203
+ read. Move the file into the matching mode subdirectory and remove the
204
+ `audience:` key — see [Migrating from v0.6.x to v0.7.0](#migrating-from-v06x-to-v070)
205
+ for step-by-step diffs.
132
206
 
133
207
  ```yaml
134
- audience: technical # single-audience: shown only when mode=technical
135
- audience: [guide, technical] # multi-audience: shown in either mode
136
- # omit `audience:` # backward-compatible: shown in every mode
208
+ audience: technical # deprecated move to app/docs/technical/
209
+ audience: [guide, technical] # deprecated keep at root, drop the key
210
+ # omit `audience:` # still works for shared docs at root
137
211
  ```
138
212
 
139
- When the current mode does not appear in a doc's audience, the doc is
140
- hidden from the index AND unreachable via slug (404). This complements
141
- the in-page `<!-- mode: -->` blocks below: use mode blocks when one doc
142
- mixes audience-specific snippets; use `audience:` when whole docs are
143
- audience-specific.
213
+ The `audience:` key is scheduled for removal in v1.0.0.
144
214
 
145
215
  ### Mode Blocks
146
216
 
@@ -186,6 +256,43 @@ function hello() {
186
256
 
187
257
  Supported languages include Ruby, JavaScript, Python, Bash, YAML, JSON, HTML, CSS, SQL, and [many more](https://github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers).
188
258
 
259
+ ### Inline SVG (Hand-Authored Diagrams)
260
+
261
+ Set `config.allow_svg = true` to opt into a curated, safe subset of inline
262
+ SVG for diagrams written directly in markdown. The sanitizer remains the
263
+ security boundary: `<script>`, `<foreignObject>`, `on*` event handlers, and
264
+ `javascript:` URIs are always stripped, regardless of this setting.
265
+
266
+ Allowed structural elements: `svg g path rect circle ellipse line polyline
267
+ polygon text tspan defs marker desc`.
268
+
269
+ #### Giving an SVG an accessible name
270
+
271
+ Inline SVGs need an accessible name so screen readers can announce them.
272
+ Use `role="img"` plus `aria-label`, or pair the SVG with a `<desc>` and
273
+ `aria-describedby`:
274
+
275
+ ```html
276
+ <svg role="img" aria-label="High-level system architecture"
277
+ viewBox="0 0 100 100">
278
+
279
+ </svg>
280
+
281
+ <svg role="img" aria-labelledby="t1" aria-describedby="d1"
282
+ viewBox="0 0 100 100">
283
+ <desc id="d1">Three-tier diagram: browser → Rails → Postgres.</desc>
284
+
285
+ </svg>
286
+ ```
287
+
288
+ Decorative SVGs (icons, dividers) should be marked `aria-hidden="true"`
289
+ so assistive technology skips them.
290
+
291
+ > The `<title>` SVG element is intentionally NOT in the safelist: the
292
+ > HTML5 parser treats it as a raw-text element in HTML context, so the
293
+ > name would silently escape into surrounding text. `aria-label` is the
294
+ > reliable path.
295
+
189
296
  ### Categories
190
297
 
191
298
  To organize docs on the index page, map category names to slugs in your configuration:
@@ -303,6 +410,57 @@ bundle exec rspec
303
410
 
304
411
  Pushing the tag triggers the GitHub Actions release workflow, which builds and publishes the gem to RubyGems automatically.
305
412
 
413
+ ## Migrating from v0.6.x to v0.7.0
414
+
415
+ **URL stability.** Every URL from v0.6.x continues to resolve. Hosts
416
+ that upgrade without moving files see zero URL changes. Path-based
417
+ routing only introduces *new* URLs (`/docs/<mode>/<slug>`) when you
418
+ explicitly relocate files into mode subdirectories.
419
+
420
+ ### If you don't use `audience:` today
421
+
422
+ No action required. Adopt the new convention at your leisure.
423
+
424
+ ### If you use `audience: <single-mode>`
425
+
426
+ For each affected doc:
427
+
428
+ ```diff
429
+ - app/docs/foo.md
430
+ - ---
431
+ - audience: technical
432
+ - ---
433
+ + app/docs/technical/foo.md
434
+ + (no `audience:` key)
435
+ ```
436
+
437
+ The deprecation warning surfaces the suggested target path.
438
+
439
+ ### If you use `audience: [guide, technical]`
440
+
441
+ The doc is multi-audience — drop the key, the root file is shared:
442
+
443
+ ```diff
444
+ app/docs/foo.md
445
+ - ---
446
+ - audience: [guide, technical]
447
+ - ---
448
+ + (no `audience:` key)
449
+ ```
450
+
451
+ ### `config.categories` for mode-scoped docs
452
+
453
+ Prefix slugs with the mode subdirectory:
454
+
455
+ ```diff
456
+ config.categories = {
457
+ - "Architecture" => %w[architecture data_model]
458
+ + "Architecture" => %w[technical/architecture data_model]
459
+ }
460
+ ```
461
+
462
+ Bare slugs continue to mean "the doc at the root with this name."
463
+
306
464
  ## Contributing
307
465
 
308
466
  Bug reports and pull requests are welcome on GitHub at [github.com/dschmura/markdowndocs](https://github.com/dschmura/markdowndocs).
@@ -26,7 +26,7 @@ module Markdowndocs
26
26
  json = Rails.cache.fetch(cache_key, expires_in: Markdowndocs.config.cache_expiry) do
27
27
  Documentation.all.map do |doc|
28
28
  {
29
- id: doc.slug,
29
+ id: doc.path_slug,
30
30
  title: doc.title,
31
31
  description: doc.description,
32
32
  content: doc.plain_text_content,
@@ -41,25 +41,29 @@ module Markdowndocs
41
41
  end
42
42
 
43
43
  def show
44
- # Audience filter: a doc with `audience: technical` is unreachable
45
- # via slug while the user is in guide mode they get the 404 page,
46
- # not the doc's content. This matters because the index already
47
- # hides those docs; making the show route honor the same filter
48
- # keeps URL guessing / shared-link scenarios consistent.
49
- @doc = Documentation.find_by_slug(params[:slug], mode: @docs_mode)
44
+ # `params[:mode]` here is the URL path segment (e.g. /docs/technical/foo "technical")
45
+ # if the request matched the scoped route. Otherwise, fall back to the resolved
46
+ # current preference (@docs_mode) so root-mounted docs honor audience-frontmatter
47
+ # filtering.
48
+ lookup_mode = params[:mode].presence || @docs_mode
49
+ @doc = Documentation.find_by_slug(params[:slug], mode: lookup_mode)
50
50
 
51
51
  if @doc.nil?
52
52
  render_not_found
53
53
  return
54
54
  end
55
55
 
56
+ # `mode:` here controls in-doc <!-- mode: X --> block stripping,
57
+ # which should reflect the user's PREFERENCE (@docs_mode), not the
58
+ # URL-path mode (lookup_mode). A /technical/foo URL does not force
59
+ # guide-only blocks invisible.
56
60
  rendered_html = MarkdownRenderer.render(
57
61
  @doc.content,
58
62
  cache_key: @doc.cache_key,
59
63
  mode: @docs_mode
60
64
  )
61
65
  @rendered_content = helpers.add_heading_anchors(rendered_html)
62
- @related_docs = Documentation.by_category(@doc.category).reject { |d| d.slug == @doc.slug }
66
+ @related_docs = Documentation.by_category(@doc.category).reject { |d| d.path_slug == @doc.path_slug }
63
67
  @available_modes = @doc.available_modes
64
68
  @toc_items = helpers.generate_table_of_contents(@rendered_content)
65
69
  end
@@ -88,7 +92,17 @@ module Markdowndocs
88
92
  end
89
93
 
90
94
  def determine_docs_mode
91
- mode = params[:mode] ||
95
+ # Only treat params[:mode] as a preference override on the root-mounted
96
+ # `/:slug` route. On the scoped `/:mode/:slug` route, params[:mode] is
97
+ # the URL path segment and is consumed by `show` for doc lookup, not as
98
+ # a preference override.
99
+ preference_param = if path_mode_in_request?
100
+ nil
101
+ else
102
+ params[:mode]
103
+ end
104
+
105
+ mode = preference_param ||
92
106
  resolve_user_mode ||
93
107
  cookies[:markdowndocs_mode] ||
94
108
  Markdowndocs.config.default_mode
@@ -97,6 +111,12 @@ module Markdowndocs
97
111
  valid_modes.include?(mode) ? mode : Markdowndocs.config.default_mode
98
112
  end
99
113
 
114
+ def path_mode_in_request?
115
+ # The scoped route names `mode` as a path param. On the unscoped route,
116
+ # `mode` (when present) comes from the query string.
117
+ request.path_parameters[:mode].present?
118
+ end
119
+
100
120
  def resolve_user_mode
101
121
  resolver = Markdowndocs.config.user_mode_resolver
102
122
  return nil unless resolver.respond_to?(:call)
@@ -10,7 +10,6 @@ module Markdowndocs
10
10
  return
11
11
  end
12
12
 
13
- # Save to database via host app's lambda (if configured)
14
13
  saver = Markdowndocs.config.user_mode_saver
15
14
  if saver.respond_to?(:call)
16
15
  begin
@@ -20,14 +19,88 @@ module Markdowndocs
20
19
  end
21
20
  end
22
21
 
23
- # Always set cookie as fallback
24
22
  cookies[:markdowndocs_mode] = {
25
23
  value: mode,
26
24
  expires: 1.year.from_now,
27
25
  httponly: true
28
26
  }
29
27
 
30
- redirect_back(fallback_location: markdowndocs.root_path, status: :see_other)
28
+ redirect_to(smart_nav_target(mode, params[:current_path]), status: :see_other)
29
+ end
30
+
31
+ private
32
+
33
+ # Computes the post-toggle destination using the unified lookup rule:
34
+ # 1. /docs/<target_mode>/<slug> if the scoped file exists and is not current
35
+ # 2. /docs/<slug> (root) if it exists and is not current
36
+ # 3. current path (stay put)
37
+ # Falls back to the docs index when current_path is missing or doesn't
38
+ # match a recognizable doc URL.
39
+ def smart_nav_target(target_mode, current_path)
40
+ index_path = markdowndocs.root_path.chomp("/")
41
+ return index_path if current_path.blank?
42
+
43
+ slug = extract_slug_from_path(current_path)
44
+ return index_path if slug.nil?
45
+
46
+ scoped_url = markdowndocs.scoped_doc_path(mode: target_mode, slug: slug)
47
+ root_url = markdowndocs.doc_path(slug: slug)
48
+
49
+ # Use Documentation.find_by_slug for existence so symlink-escape
50
+ # rejection (and any other reachability rules) match what the show
51
+ # action would actually serve. Bypassing this — e.g. with raw
52
+ # File.exist? — can redirect the user into a 404.
53
+ if scoped_sibling_reachable?(slug, target_mode) && current_path != scoped_url
54
+ scoped_url
55
+ elsif root_sibling_reachable?(slug) && current_path != root_url
56
+ root_url
57
+ else
58
+ current_path
59
+ end
60
+ end
61
+
62
+ def scoped_sibling_reachable?(slug, target_mode)
63
+ docs_path = Markdowndocs.config.resolved_docs_path
64
+ scoped_file = docs_path.join(target_mode, "#{slug}.md")
65
+ return false unless scoped_file.exist?
66
+ Documentation.inside_docs_path?(scoped_file, docs_path.realpath)
67
+ end
68
+
69
+ def root_sibling_reachable?(slug)
70
+ docs_path = Markdowndocs.config.resolved_docs_path
71
+ root_file = docs_path.join("#{slug}.md")
72
+ return false unless root_file.exist?
73
+ Documentation.inside_docs_path?(root_file, docs_path.realpath)
74
+ end
75
+
76
+ # Pulls the slug from a docs path. Returns nil if the path is the index
77
+ # or doesn't match the docs URL shape. Recognizes both /docs/<slug> and
78
+ # /docs/<mode>/<slug>.
79
+ def extract_slug_from_path(path)
80
+ # Strip query string and trailing slash.
81
+ clean = path.split("?").first.to_s.chomp("/")
82
+ base = markdowndocs.root_path.chomp("/")
83
+ return nil unless clean.start_with?(base)
84
+
85
+ remainder = clean[base.length..]
86
+ return nil if remainder.blank? || remainder == "/"
87
+
88
+ segments = remainder.sub(%r{\A/}, "").split("/")
89
+
90
+ case segments.length
91
+ when 1
92
+ slug_candidate(segments.first)
93
+ when 2
94
+ # Could be /<mode>/<slug>. Only treat second segment as the slug
95
+ # if the first is a configured mode.
96
+ Markdowndocs.config.modes.include?(segments.first) ? slug_candidate(segments.last) : nil
97
+ end
98
+ end
99
+
100
+ def slug_candidate(segment)
101
+ return nil if segment.blank?
102
+ return nil if segment.include?("..") || segment.include?("/")
103
+ segment
31
104
  end
32
105
  end
33
106
  end
@@ -5,7 +5,9 @@ module Markdowndocs
5
5
  def generate_table_of_contents(html)
6
6
  return [] if html.blank?
7
7
 
8
- doc = Nokogiri::HTML.fragment(html)
8
+ # HTML5 parsing preserves case-sensitive foreign-content (SVG/MathML)
9
+ # attributes like viewBox/markerWidth; Nokogiri::HTML lowercases them.
10
+ doc = Nokogiri::HTML5.fragment(html)
9
11
  toc_items = []
10
12
 
11
13
  doc.css("h2, h3").each do |heading|
@@ -35,7 +37,9 @@ module Markdowndocs
35
37
  def add_heading_anchors(html)
36
38
  return html if html.blank?
37
39
 
38
- doc = Nokogiri::HTML.fragment(html)
40
+ # HTML5 parsing preserves case-sensitive foreign-content (SVG/MathML)
41
+ # attributes like viewBox/markerWidth; Nokogiri::HTML lowercases them.
42
+ doc = Nokogiri::HTML5.fragment(html)
39
43
 
40
44
  doc.css("h2, h3").each do |heading|
41
45
  text = heading.text.strip
@@ -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,81 @@ 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
+ docs_root_real = docs_path.realpath
23
+
24
+ files = Dir.glob(docs_path.join("*.md"))
25
+
26
+ modes = Markdowndocs.config.modes
27
+ modes.each do |mode|
28
+ mode_dir = docs_path.join(mode)
29
+ next unless mode_dir.exist?
30
+ next unless inside_docs_path?(mode_dir, docs_root_real)
31
+ files.concat(Dir.glob(mode_dir.join("*.md")))
32
+ end
33
+
34
+ # Drop symlink-escapes from the merged file list.
35
+ files = files.select do |f|
36
+ inside_docs_path?(Pathname.new(f), docs_root_real)
37
+ end
38
+
39
+ warn_about_non_mode_subdirectories(docs_path, modes, docs_root_real)
40
+
41
+ files.map { |f| new(Pathname.new(f)) }.sort_by(&:path_slug)
24
42
  end
25
43
 
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.
44
+ # Emits a one-shot warning per process boot for each first-level
45
+ # subdirectory under docs_path that isn't a configured mode. Files
46
+ # inside such subdirectories are silently dropped by discovery
47
+ # the warning makes that visible.
48
+ def self.warn_about_non_mode_subdirectories(docs_path, modes, docs_root_real = nil)
49
+ warned = Markdowndocs.config.non_mode_subdirs_warned
50
+ docs_root_real ||= docs_path.realpath
51
+
52
+ children = begin
53
+ docs_path.children
54
+ rescue Errno::ENOENT, Errno::EACCES => e
55
+ Rails.logger.warn("[Markdowndocs] Could not scan for non-mode subdirectories: #{e.message}")
56
+ return
57
+ end
58
+
59
+ children.each do |child|
60
+ next unless child.directory?
61
+ # Don't follow symlinked subdirs that escape docs_path.
62
+ next unless inside_docs_path?(child, docs_root_real)
63
+ name = child.basename.to_s
64
+ next if modes.include?(name)
65
+ next if warned.include?(name)
66
+
67
+ warned << name
68
+ Rails.logger.warn(
69
+ "[Markdowndocs] Ignoring subdirectory #{child}/ — name does not match " \
70
+ "any configured mode (config.modes = #{modes.inspect}). Files inside " \
71
+ "this subdirectory will not be discovered. Move them into #{docs_path}/ " \
72
+ "or into a mode-named subdirectory."
73
+ )
74
+ end
75
+ end
76
+ private_class_method :warn_about_non_mode_subdirectories
77
+
78
+ # Resolves a doc by slug. When `mode:` is given, prefers the mode-scoped
79
+ # file (docs/<mode>/<slug>.md) and falls back to the root (docs/<slug>.md)
80
+ # if visible_to?(mode) passes. With `mode: nil`, only the root is checked.
30
81
  def self.find_by_slug(slug, mode: nil)
31
82
  return nil if slug.blank?
32
83
  return nil if slug.include?("..") || slug.include?("/")
33
84
 
34
- file_path = Markdowndocs.config.resolved_docs_path.join("#{slug}.md")
35
- return nil unless file_path.exist?
85
+ docs_path = Markdowndocs.config.resolved_docs_path
86
+ docs_root_real = docs_path.realpath
87
+
88
+ if mode.present? && Markdowndocs.config.modes.include?(mode.to_s)
89
+ scoped = docs_path.join(mode.to_s, "#{slug}.md")
90
+ return new(scoped) if scoped.exist? && inside_docs_path?(scoped, docs_root_real)
91
+ end
36
92
 
37
- doc = new(file_path)
93
+ root = docs_path.join("#{slug}.md")
94
+ return nil unless root.exist? && inside_docs_path?(root, docs_root_real)
95
+
96
+ doc = new(root)
38
97
  return nil unless doc.visible_to?(mode)
39
98
 
40
99
  doc
@@ -43,6 +102,19 @@ module Markdowndocs
43
102
  nil
44
103
  end
45
104
 
105
+ # Returns true when file_path resolves (after following symlinks) to a
106
+ # location inside docs_root_real. Defense against symlinks that point
107
+ # outside the docs tree. Callers outside this class (e.g. smart-nav in
108
+ # PreferencesController) use this to keep reachability checks aligned
109
+ # with what the show action would actually serve.
110
+ def self.inside_docs_path?(file_path, docs_root_real)
111
+ resolved = file_path.realpath
112
+ resolved.to_s == docs_root_real.to_s ||
113
+ resolved.to_s.start_with?(docs_root_real.to_s + File::SEPARATOR)
114
+ rescue Errno::ENOENT, Errno::ELOOP
115
+ false
116
+ end
117
+
46
118
  def self.by_category(category)
47
119
  all.select { |doc| doc.category == category }
48
120
  end
@@ -50,9 +122,17 @@ module Markdowndocs
50
122
  # When `mode:` is given, filters out docs whose `audience:` excludes
51
123
  # that mode AND drops categories that end up empty (so the index
52
124
  # sidebar doesn't render headers with no children).
125
+ #
126
+ # Resolves slugs by matching against `path_slug` on the full discovered
127
+ # set so that path-prefixed slugs like "technical/architecture" (which
128
+ # `find_by_slug` rejects as directory traversal) are found correctly.
53
129
  def self.grouped_by_category(mode: nil)
130
+ all_docs = all
54
131
  Markdowndocs.config.categories.each_with_object({}) do |(category, slugs), hash|
55
- docs = slugs.map { |slug| find_by_slug(slug, mode: mode) }.compact
132
+ docs = slugs.filter_map do |slug|
133
+ doc = all_docs.find { |d| d.path_slug == slug }
134
+ doc if doc&.visible_to?(mode)
135
+ end
56
136
  hash[category] = docs unless docs.empty?
57
137
  end
58
138
  end
@@ -65,7 +145,7 @@ module Markdowndocs
65
145
  end
66
146
 
67
147
  def cache_key
68
- "#{slug}-#{mtime.to_i}"
148
+ "#{path_slug.tr("/", "-")}-#{mtime.to_i}"
69
149
  end
70
150
 
71
151
  def mtime
@@ -94,19 +174,25 @@ module Markdowndocs
94
174
  available_modes.include?(mode.to_s)
95
175
  end
96
176
 
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).
177
+ # The audience(s) this doc is written for. Resolution order:
178
+ # 1. `audience:` frontmatter (DEPRECATED in 0.7.0, removed in 1.0.0)
179
+ # 2. Parent directory name when it matches a configured mode
180
+ # 3. All configured modes (root file with no override visible everywhere)
102
181
  def audience
103
182
  @audience ||= begin
104
183
  parsed = parse_frontmatter
105
184
  raw = parsed[:frontmatter]["audience"]
185
+
186
+ if raw
187
+ emit_audience_deprecation_warning_once
188
+ end
189
+
106
190
  case raw
107
191
  when Array then raw.map(&:to_s)
108
192
  when String then [raw]
109
- when nil then Markdowndocs.config.modes.dup
193
+ when nil
194
+ scope = audience_from_path
195
+ scope ? [scope] : Markdowndocs.config.modes.dup
110
196
  else Markdowndocs.config.modes.dup
111
197
  end
112
198
  end
@@ -148,6 +234,49 @@ module Markdowndocs
148
234
  file_path.basename(".md").to_s
149
235
  end
150
236
 
237
+ def derive_path_slug
238
+ docs_root = Markdowndocs.config.resolved_docs_path
239
+ relative = file_path.relative_path_from(docs_root)
240
+ relative.sub_ext("").to_s
241
+ end
242
+
243
+ def audience_from_path
244
+ dir = file_path.dirname.basename.to_s
245
+ Markdowndocs.config.modes.include?(dir) ? dir : nil
246
+ end
247
+
248
+ def emit_audience_deprecation_warning_once
249
+ path_str = file_path.to_s
250
+ emitted = Markdowndocs.config.audience_deprecation_emitted
251
+ return if emitted.include?(path_str)
252
+
253
+ emitted << path_str
254
+
255
+ Markdowndocs.deprecator.warn(
256
+ "`audience:` frontmatter in #{path_str} is deprecated. " \
257
+ "#{suggest_migration_target} The `audience:` key will be removed in v1.0.0."
258
+ )
259
+ end
260
+
261
+ def suggest_migration_target
262
+ parsed = parse_frontmatter
263
+ raw = parsed[:frontmatter]["audience"]
264
+
265
+ case raw
266
+ when String
267
+ "Move the file to #{file_path.dirname.join(raw, file_path.basename)} instead and remove the `audience:` key."
268
+ when Array
269
+ if Array(raw).map(&:to_s).sort == Markdowndocs.config.modes.sort
270
+ "This doc is already declared multi-audience; remove the `audience:` key (root files are visible in every mode)."
271
+ else
272
+ modes = Array(raw).map(&:to_s).join(", ")
273
+ "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)."
274
+ end
275
+ else
276
+ "Move the file into the mode-named subdirectory matching its audience, or leave it at root and remove the key."
277
+ end
278
+ end
279
+
151
280
  def extract_metadata
152
281
  parsed = parse_frontmatter
153
282
 
@@ -227,7 +356,7 @@ module Markdowndocs
227
356
 
228
357
  def assign_category
229
358
  Markdowndocs.config.categories.each do |category, slugs|
230
- return category if slugs.include?(slug)
359
+ return category if slugs.include?(path_slug)
231
360
  end
232
361
 
233
362
  "Other"
@@ -45,18 +45,31 @@ 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
54
+ # Bare rescue is intentional: third-party errors from commonmarker,
55
+ # Gumbo (Nokogiri::HTML5), Rouge, and Loofah are diverse and not
56
+ # worth enumerating. We never want a single malformed doc — e.g.
57
+ # a deeply nested inline SVG — to blank-render the page. Logs
58
+ # carry the diagnostic; the user sees their content as text.
53
59
  Rails.logger.error("Markdowndocs::MarkdownRenderer error: #{e.message}")
54
- Rails.logger.error(e.backtrace.join("\n"))
55
- ""
60
+ Rails.logger.error(e.backtrace.first(20).join("\n"))
61
+ render_fallback(markdown)
62
+ end
63
+
64
+ def render_fallback(markdown)
65
+ escaped = ERB::Util.html_escape(markdown.to_s)
66
+ %(<pre class="markdowndocs-render-error">#{escaped}</pre>)
56
67
  end
57
68
 
58
69
  def apply_syntax_highlighting(html)
59
- doc = Nokogiri::HTML.fragment(html)
70
+ # HTML5 parsing preserves case-sensitive SVG/MathML foreign-content
71
+ # attributes (e.g. viewBox) that Nokogiri::HTML would lowercase.
72
+ doc = Nokogiri::HTML5.fragment(html)
60
73
 
61
74
  doc.css("pre[lang]").each do |pre|
62
75
  language = pre["lang"]
@@ -91,25 +104,71 @@ module Markdowndocs
91
104
  "<pre><code>#{ERB::Util.html_escape(code)}</code></pre>"
92
105
  end
93
106
 
107
+ # Force commonmarker to pass raw HTML through when SVG is allowed, so the
108
+ # markup reaches the sanitizer (the security boundary) instead of being
109
+ # escaped. Returns a copy — does not mutate the configured options.
110
+ def markdown_render_options
111
+ options = Markdowndocs.config.markdown_options
112
+ return options unless Markdowndocs.config.allow_svg
113
+
114
+ options.merge(render: (options[:render] || {}).merge(unsafe: true))
115
+ end
116
+
117
+ BASE_SANITIZE_TAGS = %w[
118
+ h1 h2 h3 h4 h5 h6 p br hr blockquote
119
+ ul ol li dl dt dd
120
+ table thead tbody tfoot tr th td
121
+ a img
122
+ strong em b i u del
123
+ code pre span div
124
+ details summary
125
+ ].freeze
126
+
127
+ # ARIA labelling/role attributes are useful on any element. Adding them
128
+ # to BASE keeps non-SVG inline HTML accessible too (when allow_svg=true).
129
+ # No security risk — ARIA attributes carry no executable content.
130
+ BASE_SANITIZE_ATTRS = %w[
131
+ href title src alt align class lang
132
+ role aria-label aria-labelledby aria-describedby aria-hidden
133
+ open
134
+ ].freeze
135
+
136
+ # Curated structural SVG subset. Deliberately excludes script,
137
+ # foreignObject, and SMIL animate/set tags, plus all on* handlers — the
138
+ # SafeListSanitizer drops anything not listed, so scripts/handlers and
139
+ # javascript: URIs are stripped even with unsafe HTML enabled.
140
+ #
141
+ # Note: `<title>` is NOT included. Despite appearing safe, the HTML5
142
+ # parser treats `<title>` as a raw-text element when encountered in
143
+ # HTML context, so its content escapes into the surrounding text and
144
+ # the `<title>` element itself is dropped. Authors who want an
145
+ # accessible name for an inline SVG should use:
146
+ #
147
+ # <svg role="img" aria-label="Architecture diagram">…</svg>
148
+ #
149
+ # or pair the SVG with a `<desc>` element and aria-describedby.
150
+ SVG_SANITIZE_TAGS = %w[
151
+ svg g path rect circle ellipse line polyline polygon
152
+ text tspan defs marker desc
153
+ ].freeze
154
+
155
+ SVG_SANITIZE_ATTRS = %w[
156
+ viewBox d points
157
+ x y x1 y1 x2 y2 cx cy r rx ry width height
158
+ fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray
159
+ transform opacity text-anchor dominant-baseline
160
+ font-size font-family font-weight
161
+ marker-start marker-end markerWidth markerHeight refX refY orient
162
+ id xmlns focusable tabindex
163
+ ].freeze
164
+
94
165
  def sanitize_html(html)
95
- sanitizer = Rails::HTML5::SafeListSanitizer.new
166
+ allow_svg = Markdowndocs.config.allow_svg
96
167
 
97
- sanitizer.sanitize(
168
+ Rails::HTML5::SafeListSanitizer.new.sanitize(
98
169
  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
- ]
170
+ tags: allow_svg ? BASE_SANITIZE_TAGS + SVG_SANITIZE_TAGS : BASE_SANITIZE_TAGS,
171
+ attributes: allow_svg ? BASE_SANITIZE_ATTRS + SVG_SANITIZE_ATTRS : BASE_SANITIZE_ATTRS
113
172
  )
114
173
  end
115
174
  end
@@ -41,7 +41,8 @@
41
41
  turbo_action: "replace"
42
42
  }
43
43
  ) do |f| %>
44
- <%= 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 %>
45
46
  <button
46
47
  type="submit"
47
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,20 @@
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
+ #
11
+ # The constraint reads live config so dev-reload edits to config.modes
12
+ # take effect without a full server restart. Proc constraints apply only
13
+ # to request recognition; URL generation remains permissive.
14
+ mode_constraint = lambda do |request|
15
+ mode = request.path_parameters[:mode]
16
+ Array(Markdowndocs.config.modes).include?(mode)
17
+ end
18
+ get ":mode/:slug", to: "docs#show", as: :scoped_doc, constraints: mode_constraint
19
+
6
20
  get ":slug", to: "docs#show", as: :doc
7
21
  resource :preference, only: [:update]
8
22
  end
@@ -5,14 +5,27 @@ Markdowndocs.configure do |config|
5
5
  # config.docs_path = Rails.root.join("app", "docs")
6
6
 
7
7
  # Category → slug mapping
8
- # Maps category names to arrays of markdown file slugs (filenames without .md)
8
+ # Maps category names to arrays of markdown file slugs (filenames without .md).
9
+ # Bare slugs ("welcome") match files at app/docs/. Path-prefixed slugs
10
+ # ("technical/architecture") match files in mode subdirectories — see modes
11
+ # below.
9
12
  config.categories = {
10
13
  # "Getting Started" => %w[introduction quickstart],
11
14
  # "Guides" => %w[authentication deployment],
12
- # "Reference" => %w[api-reference configuration]
15
+ # "Reference" => %w[api-reference technical/architecture]
13
16
  }
14
17
 
15
18
  # Available documentation modes (default: %w[guide technical])
19
+ #
20
+ # Each entry in `modes` also doubles as a path-based audience scope: files
21
+ # under `app/docs/<mode>/` are visible ONLY in that mode and served at
22
+ # `/docs/<mode>/<slug>`. Files at the docs root are shared across all modes.
23
+ #
24
+ # app/docs/
25
+ # ├── welcome.md → shared, visible in every mode
26
+ # └── technical/
27
+ # └── architecture.md → technical mode only
28
+ #
16
29
  # config.modes = %w[guide technical]
17
30
 
18
31
  # Default mode (default: "guide")
@@ -28,6 +41,11 @@ Markdowndocs.configure do |config|
28
41
  # Adds a search bar that filters docs by title, description, and content
29
42
  # config.search_enabled = true
30
43
 
44
+ # Allow a curated, safe subset of inline SVG for hand-authored diagrams
45
+ # (default: false). Scripts, event handlers, and javascript: URIs are
46
+ # always stripped by the sanitizer regardless of this setting.
47
+ # config.allow_svg = true
48
+
31
49
  # Optional: Resolve current user's mode preference from database
32
50
  # Return nil to fall back to cookie/default
33
51
  # config.user_mode_resolver = ->(controller) {
@@ -2,15 +2,24 @@
2
2
 
3
3
  module Markdowndocs
4
4
  class Configuration
5
- attr_accessor :docs_path, :categories, :modes, :default_mode,
5
+ # Route segments owned by the engine itself. A mode name matching any
6
+ # of these would collide with built-in routes / controller actions.
7
+ RESERVED_MODE_NAMES = %w[search_index preference preferences].freeze
8
+
9
+ attr_accessor :docs_path, :categories, :default_mode,
6
10
  :markdown_options, :rouge_theme, :cache_expiry,
7
11
  :user_mode_resolver, :user_mode_saver, :search_enabled,
8
- :layout
12
+ :layout, :allow_svg
13
+ attr_reader :modes, :non_mode_subdirs_warned, :audience_deprecation_emitted
14
+
15
+ def modes=(value)
16
+ @modes = normalize_modes(value)
17
+ end
9
18
 
10
19
  def initialize
11
20
  @docs_path = nil # Resolved lazily so Rails.root is available
12
21
  @categories = {}
13
- @modes = %w[guide technical]
22
+ self.modes = %w[guide technical]
14
23
  @default_mode = "guide"
15
24
  @markdown_options = default_markdown_options
16
25
  @rouge_theme = "github"
@@ -19,6 +28,13 @@ module Markdowndocs
19
28
  @user_mode_saver = nil
20
29
  @search_enabled = false
21
30
  @layout = "markdowndocs/application"
31
+ # Opt-in: allow a curated, safe inline-SVG subset in rendered docs.
32
+ # When true, the renderer passes raw HTML through commonmarker (unsafe)
33
+ # and the sanitizer (the security boundary) whitelists structural SVG
34
+ # tags/attributes while still stripping scripts/handlers. Default off.
35
+ @allow_svg = false
36
+ @non_mode_subdirs_warned = Set.new
37
+ @audience_deprecation_emitted = Set.new
22
38
  end
23
39
 
24
40
  # Lazily resolve docs_path so Rails.root is available
@@ -28,6 +44,38 @@ module Markdowndocs
28
44
 
29
45
  private
30
46
 
47
+ def normalize_modes(value)
48
+ list = Array(value).map do |entry|
49
+ unless entry.is_a?(String)
50
+ raise ArgumentError,
51
+ "config.modes entries must be strings; got #{entry.inspect}"
52
+ end
53
+
54
+ name = entry.strip
55
+
56
+ if name.empty?
57
+ raise ArgumentError, "config.modes contains an invalid empty entry"
58
+ end
59
+
60
+ if name.match?(%r{[/?#&\s]})
61
+ raise ArgumentError,
62
+ "config.modes entry #{entry.inspect} is invalid — names cannot contain " \
63
+ "path separators, URL-significant characters, or whitespace"
64
+ end
65
+
66
+ if RESERVED_MODE_NAMES.include?(name)
67
+ raise ArgumentError,
68
+ "config.modes entry #{name.inspect} is reserved by the engine " \
69
+ "(conflicts with built-in route or controller). " \
70
+ "Reserved names: #{RESERVED_MODE_NAMES.join(", ")}"
71
+ end
72
+
73
+ name
74
+ end
75
+
76
+ list.uniq
77
+ end
78
+
31
79
  def default_markdown_options
32
80
  {
33
81
  parse: {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Markdowndocs
4
- VERSION = "0.6.1"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/markdowndocs.rb CHANGED
@@ -21,5 +21,12 @@ module Markdowndocs
21
21
  def reset_configuration!
22
22
  @configuration = Configuration.new
23
23
  end
24
+
25
+ # Deprecation channel for the gem. Hosts can attach custom behaviors
26
+ # (e.g., raise in test, silence in production) via:
27
+ # Markdowndocs.deprecator.behavior = :log
28
+ def deprecator
29
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0.0", "Markdowndocs")
30
+ end
24
31
  end
25
32
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: markdowndocs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dave Chmura
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-06-23 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rails
@@ -103,7 +104,6 @@ files:
103
104
  - lib/markdowndocs/configuration.rb
104
105
  - lib/markdowndocs/engine.rb
105
106
  - lib/markdowndocs/version.rb
106
- - log/test.log
107
107
  - sig/markdowndocs.rbs
108
108
  homepage: https://github.com/dschmura/markdowndocs
109
109
  licenses:
@@ -112,6 +112,7 @@ metadata:
112
112
  homepage_uri: https://github.com/dschmura/markdowndocs
113
113
  source_code_uri: https://github.com/dschmura/markdowndocs
114
114
  changelog_uri: https://github.com/dschmura/markdowndocs/blob/main/CHANGELOG.md
115
+ post_install_message:
115
116
  rdoc_options: []
116
117
  require_paths:
117
118
  - lib
@@ -126,7 +127,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
126
127
  - !ruby/object:Gem::Version
127
128
  version: '0'
128
129
  requirements: []
129
- rubygems_version: 3.6.9
130
+ rubygems_version: 3.4.19
131
+ signing_key:
130
132
  specification_version: 4
131
133
  summary: A drop-in markdown documentation site for Rails apps
132
134
  test_files: []
data/log/test.log DELETED
@@ -1,4 +0,0 @@
1
- Started GET "/docs/..%2F..%2Fetc%2Fpasswd" for 127.0.0.1 at 2026-02-19 21:15:40 -0500
2
-
3
- ActionController::RoutingError (No route matches [GET] "/docs/..%2F..%2Fetc%2Fpasswd"):
4
-