markdowndocs 0.8.0 → 0.10.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: 75c53c564b04a6a6a957dc2b038121c542e924d97908f5001c100cbca060d3a6
4
- data.tar.gz: f791c421dd96c2c81b9bd49019216b72bb796aef43965157e3a608309d25e47d
3
+ metadata.gz: 6ca89d7b3d1decf28b07cfaccfd1e2e0e0924f8ef7d56d1374e3cb600cbd4933
4
+ data.tar.gz: 036b99af3190b230d1ceb327171300758b76d09392a4d28b32a9a0129b9cdaf3
5
5
  SHA512:
6
- metadata.gz: 4af4914a66d3a2bcbdedcfb6e375573897dc1127199c6eabf6b0784f817741fcdb90ba732c4c8f1601bc56eb913781dc89dd59507c18c09ffea490ed211695e3
7
- data.tar.gz: 3d733bca1efcdbb749a35491506f0d0f13ac4e7ed1f2f04ea6ee698cd94854cec7791e374ad02211a020a2d1012334639c49c5d87e1adb8b2b531a4f57d39be1
6
+ metadata.gz: 80f8fb1ad69d4e83407943a1655a7da909d3704ce950ccb3e01a01a515aa3b7a96804e3ae93ceea7ed2cace9497267d750010a2c6fd05b56db27abc89f597432
7
+ data.tar.gz: 7b7a5ba65d0e5e549811734b03aed0bcc5d52b0c13b768f5e06599162b367998f7d4f7294fe7af2a37d82924ce1826e7e65fbfa39b616bf97828f6b68f83fe2a
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.4.5
data/CHANGELOG.md CHANGED
@@ -5,6 +5,48 @@ 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.10.0] - 2026-06-24
9
+
10
+ ### Added
11
+
12
+ - **Persistent audience switcher.** The viewing-mode switcher now renders in a
13
+ toolbar at the top of both the docs index and every doc page (new
14
+ `markdowndocs/docs/_docs_toolbar` partial), instead of only the show-page
15
+ sidebar — the audience choice is always visible and rendered once per page.
16
+ `DocsController#index` and `#show` both expose `@available_modes` from
17
+ `config.modes`.
18
+ - **Screen-reader mode announcement.** Switching audience sets `flash[:notice]`
19
+ (`markdowndocs.mode_announcement`), surfaced in a `role="status"
20
+ aria-live="polite"` region in the engine layout so the Turbo-replace mode
21
+ change isn't silent for assistive tech. Hosts may render the flash their own way.
22
+
23
+ ### Changed
24
+
25
+ - **No-counterpart audience switch goes to the target index.** Switching to a
26
+ mode where the current doc has no counterpart (and no shared-root fallback)
27
+ now redirects to that audience's index — with a distinct flash
28
+ (`markdowndocs.no_counterpart_announcement`) — instead of stranding the reader
29
+ on a doc outside the audience they chose. Topic-preserving (scoped counterpart)
30
+ and shared-root docs are unchanged.
31
+
32
+ ### Fixed
33
+
34
+ - **Mode switcher ARIA: toggle-button group, not a radiogroup.** Each mode is a
35
+ submit button using `aria-pressed` (was `role="radio"` / `aria-checked`, which
36
+ implied unimplemented arrow-key navigation). Focus returns to the chosen button
37
+ after the Turbo replace.
38
+
39
+ ## [0.9.0] - 2026-06-21
40
+
41
+ ### Added
42
+
43
+ - **Collapsible disclosure (`<details>` / `<summary>`).** Both tags — plus the
44
+ `open` attribute — are now in the sanitizer allow-list, so docs can use native,
45
+ no-JS click-to-expand sections. Rides the same curated raw-HTML passthrough as
46
+ inline SVG (requires `config.allow_svg = true`, which flips commonmarker to
47
+ unsafe so the markup reaches the sanitizer). Scripts and `on*` handlers inside
48
+ a disclosure are still stripped.
49
+
8
50
  ## [0.8.0] - 2026-05-29
9
51
 
10
52
  ### Added
data/README.md CHANGED
@@ -118,13 +118,7 @@ Add optional YAML front matter to set metadata:
118
118
  ---
119
119
  title: "Quick Start Guide"
120
120
  description: "Get up and running in five minutes"
121
- audience:
122
- - guide
123
- - technical
124
- modes:
125
- - guide
126
- - technical
127
- default_mode: guide
121
+ keywords: [setup, install]
128
122
  ---
129
123
 
130
124
  # Quick Start Guide
@@ -132,6 +126,17 @@ default_mode: guide
132
126
  Your content here...
133
127
  ```
134
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
+
135
140
  If front matter is omitted, the title is extracted from the first H1 heading and the description from the first paragraph.
136
141
 
137
142
  ### Audience Filtering by Filesystem Path
@@ -157,12 +162,47 @@ URLs follow the filesystem layout: `app/docs/billing.md` is served at
157
162
  Subdirectories whose name does not match a configured mode are ignored
158
163
  by document discovery, with a one-line warning at boot.
159
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
+
160
199
  ### Audience Filtering by Frontmatter (deprecated)
161
200
 
162
201
  The `audience:` frontmatter key from v0.6.0 still works in v0.7.x but is
163
202
  deprecated. A warning is logged the first time each affected file is
164
203
  read. Move the file into the matching mode subdirectory and remove the
165
- `audience:` key. See the migration guide below.
204
+ `audience:` key see [Migrating from v0.6.x to v0.7.0](#migrating-from-v06x-to-v070)
205
+ for step-by-step diffs.
166
206
 
167
207
  ```yaml
168
208
  audience: technical # deprecated — move to app/docs/technical/
@@ -216,6 +256,43 @@ function hello() {
216
256
 
217
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).
218
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
+
219
296
  ### Categories
220
297
 
221
298
  To organize docs on the index page, map category names to slugs in your configuration:
@@ -16,11 +16,45 @@ export default class extends Controller {
16
16
  }
17
17
 
18
18
  static STORAGE_KEY = "markdowndocs_mode"
19
+ static FOCUS_KEY = "markdowndocs_focus_mode"
19
20
 
20
21
  connect() {
21
22
  if (!this.isAuthenticated()) {
22
23
  this.restoreGuestMode()
23
24
  }
25
+ this.restoreFocusAfterToggle()
26
+ }
27
+
28
+ rememberFocus(event) {
29
+ // Save the mode the user just pressed so we can restore focus to the
30
+ // matching button after Turbo replaces the page (a11y: keep focus on
31
+ // the control the user actuated, not on the article wrapper).
32
+ try {
33
+ const mode = event.currentTarget.dataset.mode
34
+ if (mode) {
35
+ sessionStorage.setItem(this.constructor.FOCUS_KEY, mode)
36
+ }
37
+ } catch (e) {
38
+ // sessionStorage unavailable — accept the focus loss gracefully.
39
+ }
40
+ }
41
+
42
+ restoreFocusAfterToggle() {
43
+ let pendingMode
44
+ try {
45
+ pendingMode = sessionStorage.getItem(this.constructor.FOCUS_KEY)
46
+ if (pendingMode) sessionStorage.removeItem(this.constructor.FOCUS_KEY)
47
+ } catch (e) {
48
+ return
49
+ }
50
+ if (!pendingMode) return
51
+
52
+ const button = this.element.querySelector(`button[data-mode="${pendingMode}"]`)
53
+ if (button) {
54
+ // Defer past the Turbo render + autofocus on the article wrapper so
55
+ // we steal focus from it; otherwise autofocus would win the race.
56
+ window.requestAnimationFrame(() => button.focus())
57
+ }
24
58
  }
25
59
 
26
60
  isAuthenticated() {
@@ -14,6 +14,7 @@ module Markdowndocs
14
14
  # surviving docs are dropped (see Documentation.grouped_by_category).
15
15
  @docs_by_category = Documentation.grouped_by_category(mode: @docs_mode)
16
16
  @search_enabled = Markdowndocs.config.search_enabled
17
+ @available_modes = Markdowndocs.config.modes
17
18
  end
18
19
 
19
20
  def search_index
@@ -64,7 +65,7 @@ module Markdowndocs
64
65
  )
65
66
  @rendered_content = helpers.add_heading_anchors(rendered_html)
66
67
  @related_docs = Documentation.by_category(@doc.category).reject { |d| d.path_slug == @doc.path_slug }
67
- @available_modes = @doc.available_modes
68
+ @available_modes = Markdowndocs.config.modes
68
69
  @toc_items = helpers.generate_table_of_contents(@rendered_content)
69
70
  end
70
71
 
@@ -25,7 +25,25 @@ module Markdowndocs
25
25
  httponly: true
26
26
  }
27
27
 
28
- redirect_to(smart_nav_target(mode, params[:current_path]), status: :see_other)
28
+ current_path = params[:current_path]
29
+ target = smart_nav_target(mode, current_path)
30
+ mode_name = I18n.t("markdowndocs.modes.#{mode}", default: mode.titleize)
31
+
32
+ flash[:notice] = if redirected_to_index_from_doc?(target, current_path)
33
+ I18n.t(
34
+ "markdowndocs.no_counterpart_announcement",
35
+ mode: mode_name,
36
+ default: "No %{mode} version of this page — showing the %{mode} documentation."
37
+ )
38
+ else
39
+ I18n.t(
40
+ "markdowndocs.mode_announcement",
41
+ mode: mode_name,
42
+ default: "Now viewing %{mode}."
43
+ )
44
+ end
45
+
46
+ redirect_to(target, status: :see_other)
29
47
  end
30
48
 
31
49
  private
@@ -43,22 +61,47 @@ module Markdowndocs
43
61
  slug = extract_slug_from_path(current_path)
44
62
  return index_path if slug.nil?
45
63
 
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
64
  scoped_url = markdowndocs.scoped_doc_path(mode: target_mode, slug: slug)
51
65
  root_url = markdowndocs.doc_path(slug: slug)
52
66
 
53
- if scoped_file.exist? && current_path != scoped_url
67
+ # Use Documentation.find_by_slug for existence so symlink-escape
68
+ # rejection (and any other reachability rules) match what the show
69
+ # action would actually serve. Bypassing this — e.g. with raw
70
+ # File.exist? — can redirect the user into a 404.
71
+ if scoped_sibling_reachable?(slug, target_mode)
54
72
  scoped_url
55
- elsif root_file.exist? && current_path != root_url
73
+ elsif root_sibling_reachable?(slug)
56
74
  root_url
57
75
  else
58
- current_path
76
+ # No counterpart in the target mode and no shared root fallback: send the
77
+ # reader to that audience's index rather than stranding them on a doc
78
+ # outside the audience they just chose.
79
+ index_path
59
80
  end
60
81
  end
61
82
 
83
+ # True when the switch had to fall back to the docs index because the current
84
+ # doc has no counterpart in the target mode (selects the flash copy below).
85
+ def redirected_to_index_from_doc?(target, current_path)
86
+ current_path.present? &&
87
+ target == markdowndocs.root_path.chomp("/") &&
88
+ extract_slug_from_path(current_path).present?
89
+ end
90
+
91
+ def scoped_sibling_reachable?(slug, target_mode)
92
+ docs_path = Markdowndocs.config.resolved_docs_path
93
+ scoped_file = docs_path.join(target_mode, "#{slug}.md")
94
+ return false unless scoped_file.exist?
95
+ Documentation.inside_docs_path?(scoped_file, docs_path.realpath)
96
+ end
97
+
98
+ def root_sibling_reachable?(slug)
99
+ docs_path = Markdowndocs.config.resolved_docs_path
100
+ root_file = docs_path.join("#{slug}.md")
101
+ return false unless root_file.exist?
102
+ Documentation.inside_docs_path?(root_file, docs_path.realpath)
103
+ end
104
+
62
105
  # Pulls the slug from a docs path. Returns nil if the path is the index
63
106
  # or doesn't match the docs URL shape. Recognizes both /docs/<slug> and
64
107
  # /docs/<mode>/<slug>.
@@ -19,15 +19,24 @@ module Markdowndocs
19
19
  docs_path = Markdowndocs.config.resolved_docs_path
20
20
  return [] unless docs_path.exist?
21
21
 
22
+ docs_root_real = docs_path.realpath
23
+
22
24
  files = Dir.glob(docs_path.join("*.md"))
23
25
 
24
26
  modes = Markdowndocs.config.modes
25
27
  modes.each do |mode|
26
28
  mode_dir = docs_path.join(mode)
27
- files.concat(Dir.glob(mode_dir.join("*.md"))) if mode_dir.exist?
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)
28
37
  end
29
38
 
30
- warn_about_non_mode_subdirectories(docs_path, modes)
39
+ warn_about_non_mode_subdirectories(docs_path, modes, docs_root_real)
31
40
 
32
41
  files.map { |f| new(Pathname.new(f)) }.sort_by(&:path_slug)
33
42
  end
@@ -36,8 +45,9 @@ module Markdowndocs
36
45
  # subdirectory under docs_path that isn't a configured mode. Files
37
46
  # inside such subdirectories are silently dropped by discovery —
38
47
  # the warning makes that visible.
39
- def self.warn_about_non_mode_subdirectories(docs_path, modes)
48
+ def self.warn_about_non_mode_subdirectories(docs_path, modes, docs_root_real = nil)
40
49
  warned = Markdowndocs.config.non_mode_subdirs_warned
50
+ docs_root_real ||= docs_path.realpath
41
51
 
42
52
  children = begin
43
53
  docs_path.children
@@ -48,6 +58,8 @@ module Markdowndocs
48
58
 
49
59
  children.each do |child|
50
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)
51
63
  name = child.basename.to_s
52
64
  next if modes.include?(name)
53
65
  next if warned.include?(name)
@@ -92,7 +104,9 @@ module Markdowndocs
92
104
 
93
105
  # Returns true when file_path resolves (after following symlinks) to a
94
106
  # location inside docs_root_real. Defense against symlinks that point
95
- # outside the docs tree.
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.
96
110
  def self.inside_docs_path?(file_path, docs_root_real)
97
111
  resolved = file_path.realpath
98
112
  resolved.to_s == docs_root_real.to_s ||
@@ -100,7 +114,6 @@ module Markdowndocs
100
114
  rescue Errno::ENOENT, Errno::ELOOP
101
115
  false
102
116
  end
103
- private_class_method :inside_docs_path?
104
117
 
105
118
  def self.by_category(category)
106
119
  all.select { |doc| doc.category == category }
@@ -51,9 +51,19 @@ module Markdowndocs
51
51
  html = apply_syntax_highlighting(html)
52
52
  sanitize_html(html)
53
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.
54
59
  Rails.logger.error("Markdowndocs::MarkdownRenderer error: #{e.message}")
55
- Rails.logger.error(e.backtrace.join("\n"))
56
- ""
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>)
57
67
  end
58
68
 
59
69
  def apply_syntax_highlighting(html)
@@ -111,17 +121,35 @@ module Markdowndocs
111
121
  a img
112
122
  strong em b i u del
113
123
  code pre span div
124
+ details summary
114
125
  ].freeze
115
126
 
116
- BASE_SANITIZE_ATTRS = %w[href title src alt align class lang].freeze
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
117
135
 
118
136
  # Curated structural SVG subset. Deliberately excludes script,
119
137
  # foreignObject, and SMIL animate/set tags, plus all on* handlers — the
120
138
  # SafeListSanitizer drops anything not listed, so scripts/handlers and
121
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.
122
150
  SVG_SANITIZE_TAGS = %w[
123
151
  svg g path rect circle ellipse line polyline polygon
124
- text tspan defs marker title desc
152
+ text tspan defs marker desc
125
153
  ].freeze
126
154
 
127
155
  SVG_SANITIZE_ATTRS = %w[
@@ -131,7 +159,7 @@ module Markdowndocs
131
159
  transform opacity text-anchor dominant-baseline
132
160
  font-size font-family font-weight
133
161
  marker-start marker-end markerWidth markerHeight refX refY orient
134
- role aria-label id
162
+ id xmlns focusable tabindex
135
163
  ].freeze
136
164
 
137
165
  def sanitize_html(html)
@@ -11,6 +11,9 @@
11
11
  </head>
12
12
 
13
13
  <body class="min-h-screen flex flex-col bg-gray-50">
14
+ <% if flash[:notice].present? %>
15
+ <div role="status" aria-live="polite" class="sr-only"><%= flash[:notice] %></div>
16
+ <% end %>
14
17
  <%= yield :docs_header %>
15
18
 
16
19
  <main id="main-content" role="main" class="flex-1">
@@ -0,0 +1,10 @@
1
+ <%# locals: (current_mode:, available_modes:) %>
2
+ <%# Persistent docs toolbar: the audience switcher, shown at the top of both
3
+ the index and every doc page. Renders nothing when only one mode exists. %>
4
+ <% if available_modes.length > 1 %>
5
+ <div class="flex justify-end mb-6">
6
+ <%= render "markdowndocs/docs/mode_switcher",
7
+ current_mode: current_mode,
8
+ available_modes: available_modes %>
9
+ </div>
10
+ <% end %>
@@ -1,80 +1,29 @@
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
+ <%# Horizontal audience toggle for the docs toolbar (rendered once per page).
3
+ Toggle-button group, NOT a radiogroup: each option is its own form +
4
+ submit button, so aria-pressed describes state. No element id tests
5
+ target [data-controller="docs-mode"]. %>
8
6
  <div
9
- class="
10
- bg-white
11
- dark:bg-slate-800
12
- rounded-lg
13
- border
14
- border-slate-200
15
- dark:border-slate-700
16
- p-4
17
- "
7
+ class="flex items-center gap-2"
18
8
  data-controller="docs-mode"
19
9
  data-docs-mode-current-value="<%= current_mode %>"
20
10
  >
21
- <h3 class="
22
- text-sm
23
- font-medium
24
- text-slate-700
25
- dark:text-slate-300
26
- mb-3
27
- ">
11
+ <span class="text-sm font-medium text-slate-700 dark:text-slate-300">
28
12
  <%= t("markdowndocs.viewing_mode") %>
29
- </h3>
30
-
31
- <div
32
- class="space-y-2"
33
- role="radiogroup"
34
- aria-label="<%= t('markdowndocs.select_viewing_mode') %>"
35
- >
13
+ </span>
14
+ <div class="inline-flex gap-1" role="group" aria-label="<%= t('markdowndocs.select_viewing_mode') %>">
36
15
  <% available_modes.each do |mode| %>
37
- <%= form_with(
38
- url: markdowndocs.preference_path,
39
- method: :patch,
40
- data: {
41
- turbo_action: "replace"
42
- }
43
- ) do |f| %>
16
+ <%= form_with(url: markdowndocs.preference_path, method: :patch, data: {turbo_action: "replace"}) do |f| %>
44
17
  <%= f.hidden_field :mode, value: mode, id: nil %>
45
18
  <%= f.hidden_field :current_path, value: request.fullpath, id: nil %>
46
19
  <button
47
20
  type="submit"
48
- role="radio"
49
- aria-checked="<%= current_mode == mode %>"
50
- class="
51
- w-full
52
- text-left
53
- p-3
54
- rounded-lg
55
- border-2
56
- transition-all
57
- duration-150
58
- <%= (current_mode == mode) ? 'border-cyan-500 bg-cyan-50 dark:bg-cyan-900/40 dark:border-cyan-400' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-700' %>
59
- "
21
+ aria-pressed="<%= current_mode == mode %>"
22
+ data-mode="<%= mode %>"
23
+ data-action="click->docs-mode#rememberFocus"
24
+ class="px-3 py-1.5 text-sm font-medium rounded-md border transition-colors <%= (current_mode == mode) ? 'border-cyan-500 bg-cyan-50 text-cyan-900 dark:bg-cyan-900/40 dark:border-cyan-400 dark:text-cyan-100' : 'border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700' %>"
60
25
  >
61
- <span class="
62
- block
63
- font-medium
64
- <%= (current_mode == mode) ? 'text-cyan-900 dark:text-cyan-100' : 'text-slate-900 dark:text-slate-100' %>
65
- ">
66
- <%= t("markdowndocs.modes.#{mode}", default: mode.titleize) %>
67
- </span>
68
- <% description = t("markdowndocs.modes.#{mode}_description", default: "") %>
69
- <% if description.present? %>
70
- <p class="
71
- text-xs
72
- mt-1
73
- <%= (current_mode == mode) ? 'text-cyan-900 dark:text-cyan-100' : 'text-slate-700 dark:text-slate-300' %>
74
- ">
75
- <%= description %>
76
- </p>
77
- <% end %>
26
+ <%= t("markdowndocs.modes.#{mode}", default: mode.titleize) %>
78
27
  </button>
79
28
  <% end %>
80
29
  <% end %>
@@ -1,11 +1,5 @@
1
- <%# locals: (rendered_content:, related_docs:, current_mode:, available_modes:, toc_items:) %>
1
+ <%# locals: (rendered_content:, related_docs:, toc_items:) %>
2
2
  <aside class="sidebar space-y-6">
3
- <% if available_modes.length > 1 %>
4
- <%= render "markdowndocs/docs/mode_switcher",
5
- current_mode: current_mode,
6
- available_modes: available_modes %>
7
- <% end %>
8
-
9
3
  <% if toc_items.length >= 3 %>
10
4
  <div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm p-6">
11
5
  <nav aria-label="Table of Contents">
@@ -6,6 +6,9 @@
6
6
  data-controller="docs-search"
7
7
  data-docs-search-index-url-value="<%= markdowndocs.search_index_path %>"
8
8
  <% end %>>
9
+ <%= render "markdowndocs/docs/docs_toolbar",
10
+ current_mode: @docs_mode,
11
+ available_modes: @available_modes %>
9
12
  <!-- Hero Section -->
10
13
  <div class="text-center mb-12">
11
14
  <h1 class="text-4xl font-bold text-gray-900 dark:text-slate-100 mb-4">Documentation</h1>
@@ -18,6 +18,10 @@
18
18
  <!-- Breadcrumb -->
19
19
  <%= render "markdowndocs/docs/breadcrumb", category: @doc.category, title: @doc.title %>
20
20
 
21
+ <%= render "markdowndocs/docs/docs_toolbar",
22
+ current_mode: @docs_mode,
23
+ available_modes: @available_modes %>
24
+
21
25
  <!-- Mobile navigation (hamburger dropdown, hidden on desktop) -->
22
26
  <div class="lg:hidden mb-6">
23
27
  <button
@@ -57,8 +61,6 @@
57
61
  <%= render "markdowndocs/docs/navigation",
58
62
  rendered_content: @rendered_content,
59
63
  related_docs: @related_docs,
60
- current_mode: @docs_mode,
61
- available_modes: @available_modes,
62
64
  toc_items: @toc_items %>
63
65
  </div>
64
66
  </div>
@@ -84,8 +86,6 @@
84
86
  <%= render "markdowndocs/docs/navigation",
85
87
  rendered_content: @rendered_content,
86
88
  related_docs: @related_docs,
87
- current_mode: @docs_mode,
88
- available_modes: @available_modes,
89
89
  toc_items: @toc_items %>
90
90
  </div>
91
91
  </div>
@@ -4,6 +4,8 @@ en:
4
4
  navigation_sidebar: "Navigation & Related Docs"
5
5
  viewing_mode: "Viewing Mode"
6
6
  select_viewing_mode: "Select documentation viewing mode"
7
+ mode_announcement: "Now viewing %{mode}."
8
+ no_counterpart_announcement: "No %{mode} version of this page — showing the %{mode} documentation."
7
9
  modes:
8
10
  guide: "User Guide"
9
11
  guide_description: "User-friendly explanations and step-by-step instructions"