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 +4 -4
- data/.tool-versions +1 -0
- data/CHANGELOG.md +42 -0
- data/README.md +85 -8
- data/app/assets/javascripts/markdowndocs/controllers/docs_mode_controller.js +34 -0
- data/app/controllers/markdowndocs/docs_controller.rb +2 -1
- data/app/controllers/markdowndocs/preferences_controller.rb +51 -8
- data/app/models/markdowndocs/documentation.rb +18 -5
- data/app/services/markdowndocs/markdown_renderer.rb +33 -5
- data/app/views/layouts/markdowndocs/application.html.erb +3 -0
- data/app/views/markdowndocs/docs/_docs_toolbar.html.erb +10 -0
- data/app/views/markdowndocs/docs/_mode_switcher.html.erb +14 -65
- data/app/views/markdowndocs/docs/_navigation.html.erb +1 -7
- data/app/views/markdowndocs/docs/index.html.erb +3 -0
- data/app/views/markdowndocs/docs/show.html.erb +4 -4
- data/config/locales/en.yml +2 -0
- data/config/routes.rb +8 -5
- data/lib/generators/markdowndocs/install/templates/initializer.rb +20 -2
- data/lib/markdowndocs/configuration.rb +43 -3
- data/lib/markdowndocs/version.rb +1 -1
- data/mise.toml +2 -0
- metadata +9 -6
- data/docs/superpowers/plans/2026-05-15-path-based-audience-routing.md +0 -1619
- data/docs/superpowers/specs/2026-05-15-path-based-audience-routing-design.md +0 -311
- data/log/test.log +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ca89d7b3d1decf28b07cfaccfd1e2e0e0924f8ef7d56d1374e3cb600cbd4933
|
|
4
|
+
data.tar.gz: 036b99af3190b230d1ceb327171300758b76d09392a4d28b32a9a0129b9cdaf3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
73
|
+
elsif root_sibling_reachable?(slug)
|
|
56
74
|
root_url
|
|
57
75
|
else
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<%#
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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:,
|
|
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>
|
data/config/locales/en.yml
CHANGED
|
@@ -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"
|