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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57e0fd3a2811b71d43a7e32029abd3a252af462158017fe7dff81abe94b827eb
4
- data.tar.gz: bc74b02043fb27fa165d89cbbad04f8c89209fd45ef584ac6e1d017ffd89548b
3
+ metadata.gz: 75c53c564b04a6a6a957dc2b038121c542e924d97908f5001c100cbca060d3a6
4
+ data.tar.gz: f791c421dd96c2c81b9bd49019216b72bb796aef43965157e3a608309d25e47d
5
5
  SHA512:
6
- metadata.gz: ce7f0b7b5ad9e21ff9b58d7bfe5a55319b127b68b9f614d33c69c2d825f3cb33bf8e58be7915ab619bcdb54b3830b7d699ed2ea1efa995204b7759f1967a45bc
7
- data.tar.gz: 2fbb656ed33f668b517f2d8a105fd3c1be00a2b0a3f6d212f7fc80b603118121e5eea04e569ea7d75d0146a82537c299a65cebfc425e9b36dd49a5205aa1e60f
6
+ metadata.gz: 4af4914a66d3a2bcbdedcfb6e375573897dc1127199c6eabf6b0784f817741fcdb90ba732c4c8f1601bc56eb913781dc89dd59507c18c09ffea490ed211695e3
7
+ data.tar.gz: 3d733bca1efcdbb749a35491506f0d0f13ac4e7ed1f2f04ea6ee698cd94854cec7791e374ad02211a020a2d1012334639c49c5d87e1adb8b2b531a4f57d39be1
data/CHANGELOG.md CHANGED
@@ -5,6 +5,118 @@ 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.8.0] - 2026-05-29
9
+
10
+ ### Added
11
+
12
+ - **Opt-in inline SVG (`config.allow_svg`).** When set to `true`, a curated,
13
+ safe subset of structural SVG tags and attributes is permitted in rendered
14
+ documents — useful for hand-authored diagrams. The `Rails::HTML5`
15
+ SafeListSanitizer remains the security boundary: `<script>`,
16
+ `<foreignObject>`, `on*` event handlers, and `javascript:` URIs are still
17
+ stripped. Defaults to `false`, so existing behavior is unchanged.
18
+
19
+ ### Fixed
20
+
21
+ - Heading-anchor injection, table-of-contents extraction, and syntax
22
+ highlighting now parse with `Nokogiri::HTML5` instead of `Nokogiri::HTML`,
23
+ preserving case-sensitive SVG/MathML foreign-content attributes (e.g.
24
+ `viewBox`, `markerWidth`, `refX`) that were previously lowercased on
25
+ re-serialization, which silently broke any inline SVG.
26
+
27
+ ## [0.7.0] - 2026-05-15
28
+
29
+ ### Added
30
+
31
+ - **Path-based audience routing.** A first-level subdirectory of
32
+ `app/docs/` whose name matches an entry in `config.modes` is now
33
+ treated as an audience scope. Files inside `app/docs/technical/` are
34
+ visible only when the current mode is `technical`; files at the root
35
+ remain shared (visible in every mode). The new convention is the
36
+ recommended way to scope whole documents and replaces `audience:`
37
+ frontmatter (see Deprecated below).
38
+ - **`/docs/:mode/:slug` route.** Mode-scoped documents are served at
39
+ stable, RESTful URLs (e.g., `/docs/technical/architecture`). The
40
+ `:mode` segment is constrained to entries in `config.modes`; unknown
41
+ modes return 404.
42
+ - **Path-prefixed slugs in `config.categories`.** Slug entries may now
43
+ include a mode prefix (e.g., `"technical/architecture"`) to attach a
44
+ mode-scoped doc to a category. Bare slugs continue to match root
45
+ files. Example:
46
+
47
+ config.categories = {
48
+ "Architecture" => %w[technical/architecture]
49
+ }
50
+
51
+ - **Smart navigation in mode switcher.** Toggling the mode now attempts
52
+ to navigate to a same-slug document in the target mode's location,
53
+ falling back to the shared root sibling, then staying put. Sharing
54
+ links still works because URLs are stable.
55
+ - **`Markdowndocs.deprecator`** ActiveSupport::Deprecation instance for
56
+ emitting gem-specific deprecation warnings. Hosts can configure
57
+ behavior (silence / raise / log) via standard
58
+ `ActiveSupport::Deprecation` APIs.
59
+
60
+ ### Changed
61
+
62
+ - `Documentation.all` walks both `app/docs/*.md` and
63
+ `app/docs/<mode>/*.md` for every configured mode.
64
+ - `Documentation` instances expose `#path_slug` (the file's path
65
+ relative to the docs root, sans `.md`).
66
+ - `Documentation.find_by_slug(slug, mode:)` prefers the mode-scoped
67
+ file first, then falls back to the root.
68
+ - `PreferencesController#update` now expects a `current_path` form
69
+ field (added in `_mode_switcher.html.erb`) and computes the smart-nav
70
+ target before redirecting. Hosts with custom forms targeting
71
+ `preference_path` should include `<input type="hidden"
72
+ name="current_path" value="<%= request.fullpath %>">` to opt into
73
+ smart navigation. Without it, the controller redirects to the docs
74
+ index (no behavior loss, just no smart-nav benefit).
75
+
76
+ ### Deprecated
77
+
78
+ - **`audience:` frontmatter.** Still functional, but emits a one-shot
79
+ warning per file path per process boot. Will be removed in v1.0.0.
80
+ Migration: move the file into a matching mode subdirectory (or, for
81
+ multi-audience docs, drop the key — root files are shared). The
82
+ warning message includes the suggested target path.
83
+
84
+ ### Migration notes
85
+
86
+ - See `README.md` ("Migrating from v0.6.x to v0.7.0") for full guidance.
87
+ - URL stability: every URL from v0.6.x continues to resolve unchanged.
88
+ - Subdirectories under `app/docs/` whose name doesn't match a
89
+ configured mode are now ignored (one-line warning at discovery). If
90
+ you've been using non-mode subdirectories for organization, either
91
+ flatten them or rename them to match a configured mode.
92
+
93
+ ## [0.6.1] - 2026-05-13
94
+
95
+ ### Fixed
96
+
97
+ - **Duplicate `id="docs-mode-switcher"` in the DOM** (issue #20). The
98
+ show layout renders `_navigation` (and therefore the mode switcher)
99
+ twice — once for the mobile sidebar, once for the desktop sidebar.
100
+ The hardcoded id on `_mode_switcher.html.erb` produced two elements
101
+ with the same id on every doc show page, a WCAG 4.1.1 violation.
102
+
103
+ Dropped the `id=` from the partial entirely. Stimulus already scopes
104
+ the controller via `data-controller="docs-mode"`, which can appear
105
+ N times in a document without colliding.
106
+
107
+ Host apps / tests / custom CSS selecting via `#docs-mode-switcher`
108
+ should switch to `[data-controller="docs-mode"]`. Note: any host app
109
+ relying on that id was already in a broken state (duplicate ids in
110
+ the DOM); this fix surfaces the issue rather than creating it.
111
+
112
+ ### Migration notes
113
+
114
+ - If you have a CSS rule like `#docs-mode-switcher { ... }`, change it
115
+ to `[data-controller="docs-mode"] { ... }`.
116
+ - If you have a Capybara test using `within "#docs-mode-switcher"`,
117
+ change it to `within first("[data-controller='docs-mode']")` (or
118
+ similar — the partial may render twice depending on your layout).
119
+
8
120
  ## [0.6.0] - 2026-05-13
9
121
 
10
122
  ### Added
@@ -167,6 +279,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
167
279
  - i18n support for all UI strings
168
280
  - Install generator (`rails generate markdowndocs:install`)
169
281
 
282
+ [0.7.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.7.0
283
+ [0.6.1]: https://github.com/dschmura/markdowndocs/releases/tag/v0.6.1
284
+ [0.6.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.6.0
170
285
  [0.5.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.5.0
171
286
  [0.4.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.4.0
172
287
  [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 |
@@ -124,23 +134,43 @@ Your content here...
124
134
 
125
135
  If front matter is omitted, the title is extracted from the first H1 heading and the description from the first paragraph.
126
136
 
127
- ### Audience Filtering (whole-document)
137
+ ### Audience Filtering by Filesystem Path
138
+
139
+ The recommended way to scope a whole document to a single audience is to
140
+ place it inside a subdirectory whose name matches an entry in
141
+ `config.modes`. Files at the docs root are *shared* — visible in every
142
+ mode.
143
+
144
+ ```text
145
+ app/docs/
146
+ ├── getting_started.md → shared, visible in every mode
147
+ ├── billing.md → shared
148
+ └── technical/
149
+ ├── architecture.md → technical mode only
150
+ └── billing.md → technical mode only
151
+ ```
152
+
153
+ URLs follow the filesystem layout: `app/docs/billing.md` is served at
154
+ `/docs/billing`; `app/docs/technical/billing.md` is served at
155
+ `/docs/technical/billing`. Both URLs are stable and shareable.
156
+
157
+ Subdirectories whose name does not match a configured mode are ignored
158
+ by document discovery, with a one-line warning at boot.
128
159
 
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:
160
+ ### Audience Filtering by Frontmatter (deprecated)
161
+
162
+ The `audience:` frontmatter key from v0.6.0 still works in v0.7.x but is
163
+ deprecated. A warning is logged the first time each affected file is
164
+ read. Move the file into the matching mode subdirectory and remove the
165
+ `audience:` key. See the migration guide below.
132
166
 
133
167
  ```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
168
+ audience: technical # deprecated move to app/docs/technical/
169
+ audience: [guide, technical] # deprecated keep at root, drop the key
170
+ # omit `audience:` # still works for shared docs at root
137
171
  ```
138
172
 
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.
173
+ The `audience:` key is scheduled for removal in v1.0.0.
144
174
 
145
175
  ### Mode Blocks
146
176
 
@@ -303,6 +333,57 @@ bundle exec rspec
303
333
 
304
334
  Pushing the tag triggers the GitHub Actions release workflow, which builds and publishes the gem to RubyGems automatically.
305
335
 
336
+ ## Migrating from v0.6.x to v0.7.0
337
+
338
+ **URL stability.** Every URL from v0.6.x continues to resolve. Hosts
339
+ that upgrade without moving files see zero URL changes. Path-based
340
+ routing only introduces *new* URLs (`/docs/<mode>/<slug>`) when you
341
+ explicitly relocate files into mode subdirectories.
342
+
343
+ ### If you don't use `audience:` today
344
+
345
+ No action required. Adopt the new convention at your leisure.
346
+
347
+ ### If you use `audience: <single-mode>`
348
+
349
+ For each affected doc:
350
+
351
+ ```diff
352
+ - app/docs/foo.md
353
+ - ---
354
+ - audience: technical
355
+ - ---
356
+ + app/docs/technical/foo.md
357
+ + (no `audience:` key)
358
+ ```
359
+
360
+ The deprecation warning surfaces the suggested target path.
361
+
362
+ ### If you use `audience: [guide, technical]`
363
+
364
+ The doc is multi-audience — drop the key, the root file is shared:
365
+
366
+ ```diff
367
+ app/docs/foo.md
368
+ - ---
369
+ - audience: [guide, technical]
370
+ - ---
371
+ + (no `audience:` key)
372
+ ```
373
+
374
+ ### `config.categories` for mode-scoped docs
375
+
376
+ Prefix slugs with the mode subdirectory:
377
+
378
+ ```diff
379
+ config.categories = {
380
+ - "Architecture" => %w[architecture data_model]
381
+ + "Architecture" => %w[technical/architecture data_model]
382
+ }
383
+ ```
384
+
385
+ Bare slugs continue to mean "the doc at the root with this name."
386
+
306
387
  ## Contributing
307
388
 
308
389
  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,74 @@ 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
+ docs_path = Markdowndocs.config.resolved_docs_path
47
+ scoped_file = docs_path.join(target_mode, "#{slug}.md")
48
+ root_file = docs_path.join("#{slug}.md")
49
+
50
+ scoped_url = markdowndocs.scoped_doc_path(mode: target_mode, slug: slug)
51
+ root_url = markdowndocs.doc_path(slug: slug)
52
+
53
+ if scoped_file.exist? && current_path != scoped_url
54
+ scoped_url
55
+ elsif root_file.exist? && current_path != root_url
56
+ root_url
57
+ else
58
+ current_path
59
+ end
60
+ end
61
+
62
+ # Pulls the slug from a docs path. Returns nil if the path is the index
63
+ # or doesn't match the docs URL shape. Recognizes both /docs/<slug> and
64
+ # /docs/<mode>/<slug>.
65
+ def extract_slug_from_path(path)
66
+ # Strip query string and trailing slash.
67
+ clean = path.split("?").first.to_s.chomp("/")
68
+ base = markdowndocs.root_path.chomp("/")
69
+ return nil unless clean.start_with?(base)
70
+
71
+ remainder = clean[base.length..]
72
+ return nil if remainder.blank? || remainder == "/"
73
+
74
+ segments = remainder.sub(%r{\A/}, "").split("/")
75
+
76
+ case segments.length
77
+ when 1
78
+ slug_candidate(segments.first)
79
+ when 2
80
+ # Could be /<mode>/<slug>. Only treat second segment as the slug
81
+ # if the first is a configured mode.
82
+ Markdowndocs.config.modes.include?(segments.first) ? slug_candidate(segments.last) : nil
83
+ end
84
+ end
85
+
86
+ def slug_candidate(segment)
87
+ return nil if segment.blank?
88
+ return nil if segment.include?("..") || segment.include?("/")
89
+ segment
31
90
  end
32
91
  end
33
92
  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