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 +4 -4
- data/CHANGELOG.md +99 -0
- data/README.md +179 -21
- data/app/controllers/markdowndocs/docs_controller.rb +29 -9
- data/app/controllers/markdowndocs/preferences_controller.rb +76 -3
- data/app/helpers/markdowndocs/docs_helper.rb +6 -2
- data/app/models/markdowndocs/documentation.rb +149 -20
- data/app/services/markdowndocs/markdown_renderer.rb +80 -21
- data/app/views/markdowndocs/docs/_mode_switcher.html.erb +2 -1
- data/app/views/markdowndocs/docs/show.html.erb +6 -1
- data/config/routes.rb +14 -0
- data/lib/generators/markdowndocs/install/templates/initializer.rb +20 -2
- data/lib/markdowndocs/configuration.rb +51 -3
- data/lib/markdowndocs/version.rb +1 -1
- data/lib/markdowndocs.rb +7 -0
- metadata +6 -4
- 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: 764b5150885d6a306eae6d49fbabd8dedee96dffa9bc55a6f290da57e3b53123
|
|
4
|
+
data.tar.gz: 2a75805e06c5caa6fcdbf8e4860836bcf886fc5453b4d394761f8f45ae8ba114
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
59
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 #
|
|
135
|
-
audience: [guide, technical] #
|
|
136
|
-
# omit `audience:` #
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
@doc = Documentation.find_by_slug(params[:slug], 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"#{
|
|
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
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
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
|
|
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?(
|
|
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
|
-
|
|
49
|
-
|
|
48
|
+
options = markdown_render_options
|
|
49
|
+
doc = Commonmarker.parse(markdown, options: options)
|
|
50
|
+
html = doc.to_html(options: options)
|
|
50
51
|
html = apply_syntax_highlighting(html)
|
|
51
52
|
sanitize_html(html)
|
|
52
53
|
rescue => e
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
allow_svg = Markdowndocs.config.allow_svg
|
|
96
167
|
|
|
97
|
-
|
|
168
|
+
Rails::HTML5::SafeListSanitizer.new.sanitize(
|
|
98
169
|
html,
|
|
99
|
-
tags:
|
|
100
|
-
|
|
101
|
-
ul ol li dl dt dd
|
|
102
|
-
table thead tbody tfoot tr th td
|
|
103
|
-
a img
|
|
104
|
-
strong em b i u del
|
|
105
|
-
code pre span div
|
|
106
|
-
],
|
|
107
|
-
attributes: %w[
|
|
108
|
-
href title
|
|
109
|
-
src alt
|
|
110
|
-
align
|
|
111
|
-
class lang
|
|
112
|
-
]
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
data/lib/markdowndocs/version.rb
CHANGED
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.
|
|
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:
|
|
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.
|
|
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