markdowndocs 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,311 @@
1
+ # Path-Based Audience Routing — Design Spec
2
+
3
+ - **Date:** 2026-05-15
4
+ - **Status:** Approved for planning
5
+ - **Target version:** v0.7.0
6
+ - **Driver:** Editor experience — make documentation content easily updatable by different groups of editors using only filesystem conventions, without requiring frontmatter literacy.
7
+
8
+ ## Context
9
+
10
+ The markdowndocs gem currently supports audience-specific content through three mechanisms layered on a single concept of "current mode" (one of the strings in `Markdowndocs.config.modes`, default `%w[guide technical]`):
11
+
12
+ | Layer | Where | Reference |
13
+ |---|---|---|
14
+ | Config universe | `config.modes` | [configuration.rb:13](../../../lib/markdowndocs/configuration.rb#L13) |
15
+ | Whole-doc filter | `audience:` YAML frontmatter (v0.6.0, 2026-05-13) | [docs_controller.rb:15](../../../app/controllers/markdowndocs/docs_controller.rb#L15) |
16
+ | In-doc fragment filter | `<!-- mode: X -->...<!-- /mode -->` HTML comments | [markdown_renderer.rb:29-45](../../../app/services/markdowndocs/markdown_renderer.rb#L29-L45) |
17
+
18
+ The current per-request mode is resolved in [docs_controller.rb:90-98](../../../app/controllers/markdowndocs/docs_controller.rb#L90-L98) from `params[:mode]`, then `Markdowndocs.config.user_mode_resolver`, then a cookie, then `config.default_mode`.
19
+
20
+ There is no authorization layer — any visitor can append `?mode=technical` and view audience-restricted content. The `user_mode_resolver` lambda only resolves a user's *preferred* mode, not a *permitted* set.
21
+
22
+ ## Problem
23
+
24
+ Two related issues motivate this design:
25
+
26
+ 1. **Authoring ergonomics.** `audience:` frontmatter requires editors to learn YAML conventions. Non-technical contributors making a content edit cannot easily tell, just by browsing the repo, which docs belong to which audience. Documents from different audiences are commingled in one directory.
27
+ 2. **Editor isolation.** Different teams should be able to own different slices of the documentation (e.g., engineering owns technical docs, customer success owns guide docs). Today there is no filesystem signal that supports this — CODEOWNERS, branch protection, IDE folder views, and OS permissions all key off paths, but `audience:` lives inside the files.
28
+
29
+ This spec **does not** address authorization (gating viewer access by role/group). That concern was explicitly scoped out of the gem during brainstorming; host apps will continue to gate access externally (e.g., by wrapping `mount Markdowndocs::Engine` in an `authenticate` block).
30
+
31
+ ## Design Decisions
32
+
33
+ ### D1. Subdirectory-named modes drive whole-doc audience scoping
34
+
35
+ Files under `app/docs/` are scoped to audiences by their location:
36
+
37
+ ```
38
+ app/docs/
39
+ ├── getting_started.md → shared (visible in every mode)
40
+ ├── billing.md → shared
41
+ └── technical/
42
+ ├── architecture.md → technical mode only
43
+ └── billing.md → technical mode only
44
+ ```
45
+
46
+ **Rule:** A first-level subdirectory of the docs root whose name exactly matches an entry in `Markdowndocs.config.modes` is an *audience scope*. Files inside that subdirectory are visible only when the current mode equals the subdirectory name.
47
+
48
+ **Files at the docs root are *shared* — visible in every mode.**
49
+
50
+ **Non-mode subdirectories.** A first-level subdirectory whose name does *not* match any entry in `config.modes` is ignored by the document discovery walker. The gem logs a warning once per boot for each such subdirectory:
51
+
52
+ ```
53
+ [Markdowndocs] Ignoring subdirectory app/docs/api/ — name does not match any
54
+ configured mode (config.modes = ["guide", "technical"]). Files inside this
55
+ subdirectory will not be discovered. Move them into app/docs/ or into a
56
+ mode-named subdirectory.
57
+ ```
58
+
59
+ Nested subdirectories within a mode scope (`app/docs/technical/sub/foo.md`) are also out of scope for this design; see "Out of Scope" below.
60
+
61
+ **Empty mode subdirectories.** `config.modes` may declare modes that have no corresponding subdirectory yet (e.g., a host is planning to add `sre` content). The mode remains valid for preference, the switcher offers it, and its index shows only shared docs. This is not an error.
62
+
63
+ Alternatives considered:
64
+ - *Filename-suffix variant* (`getting_started_technical.md`): rejected. CODEOWNERS patterns compose poorly with suffixes, mode renames touch every file, and the convention scales worse to N modes.
65
+ - *Default-mode-only at root* (root = only the default mode, every audience is a silo): rejected. Less natural for shared "About us" or "Getting started" docs that all audiences should read.
66
+
67
+ ### D2. Distinct URLs (RESTful)
68
+
69
+ Each markdown file maps to exactly one URL. URLs do not change content based on mode.
70
+
71
+ | File | URL |
72
+ |---|---|
73
+ | `app/docs/getting_started.md` | `/docs/getting_started` |
74
+ | `app/docs/technical/architecture.md` | `/docs/technical/architecture` |
75
+ | `app/docs/technical/getting_started.md` | `/docs/technical/getting_started` |
76
+
77
+ This means it is valid for both `app/docs/getting_started.md` and `app/docs/technical/getting_started.md` to exist simultaneously — they are two distinct documents at two distinct URLs, sharing the slug `getting_started` within different mode locations.
78
+
79
+ **Rationale:** Shared links must point to a specific document. A reader copying `/docs/getting_started` from their address bar and pasting it into Slack expects every recipient to see the same content, regardless of which mode the recipient prefers.
80
+
81
+ ### D3. Mode is a discovery preference, not an access gate
82
+
83
+ The gem does not authorize URL access by mode. A user in guide mode whose browser hits `/docs/technical/architecture` receives the document. The mode setting only affects:
84
+
85
+ - Which docs appear in the index
86
+ - Which docs appear in the sidebar / related-docs list
87
+ - Which docs are returned by search (when `config.search_enabled` is true)
88
+ - Mode switcher behavior (see D6)
89
+
90
+ Host apps that wish to restrict access to a subtree wrap the engine mount in their existing auth system. Examples:
91
+
92
+ ```ruby
93
+ # config/routes.rb in the host app
94
+ authenticate :user, ->(u) { u.staff? } do
95
+ mount Markdowndocs::Engine, at: "/docs"
96
+ end
97
+ ```
98
+
99
+ …or place a parent controller in front of the engine, or use route constraints. None of this is the gem's responsibility.
100
+
101
+ ### D4. Index page composition: merged into configured categories
102
+
103
+ Per mode, the index shows the union of shared docs and that mode's docs, slotted into the categories declared in `Markdowndocs.config.categories`. There is no separate "Shared" section, no audience badge, and no auto-generated mode-named category.
104
+
105
+ Categories whose visible-doc set is empty under the current mode are dropped from the index (matches the v0.6.0 behavior for `audience:` frontmatter).
106
+
107
+ ### D5. `config.categories` slug format
108
+
109
+ `config.categories` continues to accept a hash of `String category_name => Array<String> slugs`. The slug entries gain a path-prefix convention:
110
+
111
+ ```ruby
112
+ config.categories = {
113
+ "Getting Started" => %w[welcome quickstart],
114
+ "Architecture" => %w[technical/architecture technical/data_model],
115
+ "Billing" => %w[billing technical/billing]
116
+ }
117
+ ```
118
+
119
+ | Slug entry | Matches file | Visible in |
120
+ |---|---|---|
121
+ | `welcome` | `app/docs/welcome.md` | Every mode |
122
+ | `technical/architecture` | `app/docs/technical/architecture.md` | Technical mode only |
123
+ | `billing` | `app/docs/billing.md` | Every mode |
124
+ | `technical/billing` | `app/docs/technical/billing.md` | Technical mode only |
125
+
126
+ A bare slug (no slash) matches a file at the docs root. A path-prefixed slug (one slash) matches a file in a mode subdirectory. The first segment of a path-prefixed slug must equal an entry in `config.modes`.
127
+
128
+ ### D6. Mode switcher behavior: smart navigation
129
+
130
+ When the user toggles the mode switcher, the engine attempts to navigate to a same-slug document in the target mode's location. The slug used for comparison is the basename of the current doc's source path (file name without `.md`).
131
+
132
+ **Unified lookup rule.** Given target mode `M`, current slug `S`, and current URL `U`:
133
+
134
+ 1. If `app/docs/M/S.md` exists AND its URL is not equal to `U` → redirect to its URL.
135
+ 2. Else if `app/docs/S.md` exists AND its URL is not equal to `U` → redirect to its URL.
136
+ 3. Else → stay on `U`.
137
+
138
+ In all three branches, persist the new preference (cookie + `user_mode_saver` if configured). The "not equal to `U`" guard prevents pointless self-redirects.
139
+
140
+ Applied to each case:
141
+
142
+ | Currently viewing | Toggle to | Lookup | Outcome |
143
+ |---|---|---|---|
144
+ | `/docs/billing` | technical | `app/docs/technical/billing.md`? else `app/docs/billing.md` (= U, skip) | Redirect to `/docs/technical/billing` if it exists; else stay |
145
+ | `/docs/technical/billing` | guide | `app/docs/guide/billing.md`? else `app/docs/billing.md` | Redirect to the guide variant if it exists; else redirect to shared if it exists; else stay |
146
+ | `/docs/technical/architecture` (no shared sibling) | guide | `app/docs/guide/architecture.md`? else `app/docs/architecture.md` (neither exists) | Stay on `/docs/technical/architecture` |
147
+ | `/docs` (index, no slug) | technical | n/a — no slug to look up | Stay on `/docs`. Index re-renders with technical-mode content |
148
+
149
+ The lookup is direction-agnostic: the same rule handles shared→mode, mode→shared (implicit), and mode→other-mode transitions.
150
+
151
+ ### D7. `audience:` frontmatter is deprecated
152
+
153
+ Authors using `audience:` in front matter receive a deprecation warning at request time (logged once per file path per process boot) suggesting the file-move migration. The key continues to function in v0.7.x exactly as it does in v0.6.x — no behavior change for hosts that have already adopted it.
154
+
155
+ **Warning text:**
156
+
157
+ ```text
158
+ [Markdowndocs] DEPRECATION: `audience:` frontmatter in app/docs/foo.md is
159
+ deprecated. Move the file to app/docs/technical/foo.md instead and remove
160
+ the `audience:` key. The `audience:` key will be removed in v1.0.0.
161
+ ```
162
+
163
+ For multi-audience frontmatter (`audience: [guide, technical]`), the suggested target is the file root (no move) and dropping the key. The warning emitter substitutes the right suggestion based on the resolved audience array.
164
+
165
+ **Removal target:** v1.0.0. (This gem is pre-1.0; the 0.7.x → 1.0.0 transition is the deprecation window.)
166
+
167
+ ### D8. `<!-- mode: -->` HTML-comment blocks are unchanged
168
+
169
+ The block-level filter in [markdown_renderer.rb:29-45](../../../app/services/markdowndocs/markdown_renderer.rb#L29-L45) is orthogonal to whole-doc placement and continues to serve in-doc fragment filtering. No deprecation, no rename, no behavior change.
170
+
171
+ ## URL Routing & Slug Validation
172
+
173
+ The current route mounts `DocsController#show` at `/docs/:slug` with the validator `SAFE_SLUG_PATTERN = /\A[a-zA-Z0-9_-]+\z/` in [docs_controller.rb:9](../../../app/controllers/markdowndocs/docs_controller.rb#L9).
174
+
175
+ Path-based routing adds a new shape: `/docs/<mode>/:slug` where `<mode>` is one of `config.modes`. Recommended routing approach:
176
+
177
+ - Add a new constrained route: `GET /docs/:mode/:slug` where `:mode` is constrained to `config.modes`.
178
+ - Both routes call `DocsController#show`; the controller resolves the file path from `(params[:mode], params[:slug])` using the same `SAFE_SLUG_PATTERN`.
179
+ - Directory traversal protection: the slug regex already prevents `..` and `/` in slugs. The mode segment is constrained by `config.modes`, so it cannot smuggle traversal.
180
+
181
+ `SAFE_SLUG_PATTERN` does not need to change — slugs remain segment-flat. Nested-subdirectory support (e.g., `docs/technical/sub/foo.md`) is **out of scope** for this design.
182
+
183
+ ## Documentation Model Changes
184
+
185
+ `Markdowndocs::Documentation` (in [app/models/markdowndocs/documentation.rb](../../../app/models/markdowndocs/documentation.rb), to be edited) gains the following:
186
+
187
+ - **File discovery** walks `app/docs/*.md` (root) and `app/docs/<mode>/*.md` (for each mode in `config.modes`). Each Documentation instance is tagged with its source path; the audience is derived from the first path segment under `app/docs/`.
188
+ - **`Documentation#audience`** returns `Array<String>` (matches v0.6.x contract — never nil):
189
+ - `Markdowndocs.config.modes.dup` for shared docs at root with no `audience:` frontmatter — visible everywhere. Observationally identical to v0.6.x.
190
+ - `["technical"]` (single-element array) for a file at `app/docs/technical/foo.md` with no frontmatter — visible only in technical mode.
191
+ - The value from `audience:` frontmatter when present — frontmatter wins for backward compat, AND emits the deprecation warning. (Mixing path-scoping with `audience:` frontmatter is an unusual combination but not an error.)
192
+ - **`Documentation#visible_to?(mode)`** unchanged in signature, updated semantics: `nil` audience → always visible; otherwise `audience.include?(mode)`.
193
+ - **`Documentation.find_by_slug(slug, mode: nil)`** resolves to:
194
+ - `app/docs/<mode>/<slug>.md` if `mode` is non-nil AND the file exists in that subdirectory; OR
195
+ - `app/docs/<slug>.md` if it exists at root AND `visible_to?(mode)` returns true; OR
196
+ - `nil`.
197
+ - **`Documentation.grouped_by_category(mode: nil)`** filters the visible set: shared docs always pass; mode-scoped docs pass only when `mode` matches their audience. Empty categories are dropped (unchanged from v0.6.0).
198
+
199
+ No public API is removed. All existing call sites continue to work.
200
+
201
+ ## Migration Guide (host-app-facing)
202
+
203
+ This will be inlined in the v0.7.0 release notes and CHANGELOG entry.
204
+
205
+ **URL stability.** Every existing URL continues to resolve. A host upgrading from v0.6.x to v0.7.0 without moving any files sees zero URL changes. Path-based routing introduces *new* URLs (`/docs/<mode>/<slug>`) for files that the host explicitly relocates into mode subdirectories. Search-engine indexed URLs, shared links, and bookmarks all keep working.
206
+
207
+ ### If you don't use `audience:` today
208
+
209
+ No action required. Your existing root-level docs continue to render in every mode. Adopt the new convention at your leisure: create `app/docs/technical/` (or whatever mode directory you want) and move technical-only docs into it.
210
+
211
+ ### If you use `audience: <single-mode>` today
212
+
213
+ For each such doc:
214
+
215
+ ```
216
+ # before
217
+ app/docs/foo.md
218
+ ---
219
+ audience: technical
220
+ ---
221
+
222
+ # after
223
+ app/docs/technical/foo.md
224
+ (no `audience:` key)
225
+ ```
226
+
227
+ The deprecation warning will surface in your logs and tell you which file to move.
228
+
229
+ ### If you use `audience: [guide, technical]` today
230
+
231
+ The doc is already explicitly multi-audience. Move it to root and drop the key:
232
+
233
+ ```
234
+ # before
235
+ app/docs/foo.md
236
+ ---
237
+ audience: [guide, technical]
238
+ ---
239
+
240
+ # after
241
+ app/docs/foo.md
242
+ (no `audience:` key — root = shared = visible in every mode)
243
+ ```
244
+
245
+ ### `config.categories` update
246
+
247
+ If you have technical-only docs that appear in categories on the index, prefix their slugs:
248
+
249
+ ```ruby
250
+ # before
251
+ config.categories = {
252
+ "Architecture" => %w[architecture data_model]
253
+ }
254
+
255
+ # after (technical-only architecture + shared data_model)
256
+ config.categories = {
257
+ "Architecture" => %w[technical/architecture data_model]
258
+ }
259
+ ```
260
+
261
+ Bare slugs continue to mean "the doc at the root with this name." No change needed for shared-only or guide-only categories.
262
+
263
+ ## Test Surface
264
+
265
+ Each item below is a behavior that must be covered before v0.7.0 ships. Phrased as a test name suggestion.
266
+
267
+ ### File discovery and visibility
268
+ - `Documentation.all` includes root files in every mode
269
+ - `Documentation.all` includes mode-subdirectory files only in that mode
270
+ - `Documentation.grouped_by_category(mode: "guide")` excludes `app/docs/technical/*` entries
271
+ - `Documentation.grouped_by_category(mode: "technical")` includes both root and `app/docs/technical/*` entries
272
+ - A category with no visible docs in the current mode is dropped
273
+ - A subdirectory whose name is NOT in `config.modes` is ignored (does not appear in any mode's listing) — and a warning is logged once per boot
274
+
275
+ ### URL routing
276
+ - `GET /docs/billing` renders `app/docs/billing.md`
277
+ - `GET /docs/technical/billing` renders `app/docs/technical/billing.md` (independent doc, NOT a content-swap of the shared one)
278
+ - `GET /docs/technical/billing` returns the doc even when the request's mode is `guide` (D3: no access gating)
279
+ - `GET /docs/notamode/foo` returns 404 (the `:mode` segment must match a configured mode)
280
+ - `GET /docs/technical/../etc/passwd` returns 404 (slug pattern rejects)
281
+
282
+ ### Mode switcher
283
+ - Toggling from guide → technical on `/docs/billing` (with `app/docs/technical/billing.md` present) redirects to `/docs/technical/billing`
284
+ - Toggling from guide → technical on `/docs/billing` (no technical sibling) stays on `/docs/billing` and updates preference
285
+ - Toggling from technical → guide on `/docs/technical/architecture` (no shared sibling) stays on `/docs/technical/architecture`
286
+ - Toggling on `/docs` (index) stays on `/docs` and the next render reflects the new mode
287
+ - Preference is persisted via cookie and (when configured) `user_mode_saver`
288
+
289
+ ### `audience:` deprecation
290
+ - A doc with `audience: technical` continues to render correctly in v0.7.0
291
+ - A deprecation warning is logged (once per file per boot) when such a doc is loaded
292
+ - The warning text includes the file path and the suggested target path
293
+
294
+ ### Existing block-comment filtering
295
+ - `<!-- mode: technical -->...<!-- /mode -->` content still strips in non-matching modes (unchanged from v0.6.x)
296
+ - `<!-- mode: all -->...<!-- /mode -->` content remains in every mode
297
+
298
+ ## Out of Scope
299
+
300
+ These were considered and explicitly excluded from this design. Each may become its own follow-on spec.
301
+
302
+ - **Authorization / role-based access control.** Host apps gate access via standard Rails patterns ([feedback memory](../../../.claude/projects/-Users-dschmura-Documents-code-markdowndocs/memory/feedback_no_auth_in_gem.md)).
303
+ - **Inline browser editing.** Future feature.
304
+ - **"Edit on GitHub" links.** Small follow-on.
305
+ - **Directories as automatic categories.** `config.categories` remains the source of truth for the index. Subdirectories that match a mode are audience scopes only, not categories.
306
+ - **Nested mode-subdirectory paths.** `app/docs/technical/sub/foo.md` is not supported in this design. Files inside a mode subdirectory are flat.
307
+ - **Per-doc role/group frontmatter.** `audience:` was the closest existing analogue; it's deprecated. No new frontmatter keys for authorization.
308
+
309
+ ## Open Questions
310
+
311
+ None. All decisions captured above were resolved during the 2026-05-15 brainstorming session.
@@ -5,7 +5,8 @@ module Markdowndocs
5
5
  attr_accessor :docs_path, :categories, :modes, :default_mode,
6
6
  :markdown_options, :rouge_theme, :cache_expiry,
7
7
  :user_mode_resolver, :user_mode_saver, :search_enabled,
8
- :layout
8
+ :layout, :allow_svg
9
+ attr_reader :non_mode_subdirs_warned, :audience_deprecation_emitted
9
10
 
10
11
  def initialize
11
12
  @docs_path = nil # Resolved lazily so Rails.root is available
@@ -19,6 +20,13 @@ module Markdowndocs
19
20
  @user_mode_saver = nil
20
21
  @search_enabled = false
21
22
  @layout = "markdowndocs/application"
23
+ # Opt-in: allow a curated, safe inline-SVG subset in rendered docs.
24
+ # When true, the renderer passes raw HTML through commonmarker (unsafe)
25
+ # and the sanitizer (the security boundary) whitelists structural SVG
26
+ # tags/attributes while still stripping scripts/handlers. Default off.
27
+ @allow_svg = false
28
+ @non_mode_subdirs_warned = Set.new
29
+ @audience_deprecation_emitted = Set.new
22
30
  end
23
31
 
24
32
  # Lazily resolve docs_path so Rails.root is available
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Markdowndocs
4
- VERSION = "0.6.1"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/markdowndocs.rb CHANGED
@@ -21,5 +21,12 @@ module Markdowndocs
21
21
  def reset_configuration!
22
22
  @configuration = Configuration.new
23
23
  end
24
+
25
+ # Deprecation channel for the gem. Hosts can attach custom behaviors
26
+ # (e.g., raise in test, silence in production) via:
27
+ # Markdowndocs.deprecator.behavior = :log
28
+ def deprecator
29
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0.0", "Markdowndocs")
30
+ end
24
31
  end
25
32
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: markdowndocs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dave Chmura
@@ -97,6 +97,8 @@ files:
97
97
  - config/importmap.rb
98
98
  - config/locales/en.yml
99
99
  - config/routes.rb
100
+ - docs/superpowers/plans/2026-05-15-path-based-audience-routing.md
101
+ - docs/superpowers/specs/2026-05-15-path-based-audience-routing-design.md
100
102
  - lib/generators/markdowndocs/install/install_generator.rb
101
103
  - lib/generators/markdowndocs/install/templates/initializer.rb
102
104
  - lib/markdowndocs.rb