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,1619 @@
1
+ # Path-Based Audience Routing Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add path-based audience routing to the markdowndocs gem (v0.7.0) — subdirectory-named modes scope whole docs, root docs are shared, RESTful URLs are stable, mode switcher does smart navigation, and `audience:` frontmatter is deprecated.
6
+
7
+ **Architecture:** A first-level subdirectory under `app/docs/` whose name matches an entry in `Markdowndocs.config.modes` becomes an audience scope. Files inside it are visible only when the current mode matches the subdirectory name. Files at root are shared (visible in every mode). Two URL shapes are served by the same `DocsController#show`: `/docs/:slug` (root) and `/docs/:mode/:slug` (scoped), the latter via a regex-constrained route. The `Documentation` PORO gains a `path_slug` attribute (`"technical/architecture"` for a scoped file, `"billing"` for a root file) used by both category matching and audience derivation. `PreferencesController#update` becomes smart-navigation aware: it computes the target URL using the unified lookup rule (target-mode-subdir file → root file → stay) and redirects there.
8
+
9
+ **Tech Stack:** Ruby 3.2+, Rails ≥ 7.1, RSpec, Capybara-free system specs, ActiveSupport::Deprecation, Pathname.
10
+
11
+ **Spec:** [docs/superpowers/specs/2026-05-15-path-based-audience-routing-design.md](../specs/2026-05-15-path-based-audience-routing-design.md)
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ ### Files to create
18
+
19
+ | Path | Responsibility |
20
+ |---|---|
21
+ | `spec/dummy/app/docs/billing.md` | Shared fixture — visible in every mode |
22
+ | `spec/dummy/app/docs/technical/architecture.md` | Technical-only fixture, no shared sibling |
23
+ | `spec/dummy/app/docs/technical/billing.md` | Technical sibling to `docs/billing.md` (tests override path / smart nav) |
24
+
25
+ ### Files to modify
26
+
27
+ | Path | Change |
28
+ |---|---|
29
+ | `app/models/markdowndocs/documentation.rb` | Add `path_slug` attribute; walk mode subdirs in `.all`; derive audience from path; resolve mode-scoped paths in `.find_by_slug`; emit deprecation warning when `audience:` frontmatter is used |
30
+ | `app/controllers/markdowndocs/docs_controller.rb` | Read `params[:mode]` in `#show`; pass to `Documentation.find_by_slug` |
31
+ | `app/controllers/markdowndocs/preferences_controller.rb` | Smart-nav target URL computation; replace `redirect_back` with the computed target |
32
+ | `app/views/markdowndocs/docs/_mode_switcher.html.erb` | Pass `current_path` to the preferences form |
33
+ | `config/routes.rb` (engine) | Add constrained `:mode/:slug` route before `:slug` |
34
+ | `lib/markdowndocs.rb` | Add `Markdowndocs.deprecator` helper (ActiveSupport::Deprecation instance) |
35
+ | `lib/markdowndocs/configuration.rb` | Add `audience_deprecation_emitted` Set for once-per-file warning tracking |
36
+ | `spec/spec_helper.rb` | Add new fixtures to the test category configuration |
37
+ | `spec/models/markdowndocs/documentation_spec.rb` | Tests for new behavior |
38
+ | `spec/requests/markdowndocs/docs_spec.rb` | Request specs for `/docs/:mode/:slug` |
39
+ | `spec/requests/markdowndocs/preferences_spec.rb` | New file — request specs for smart navigation |
40
+ | `README.md` | Document the new convention, new categories slug format, migration steps |
41
+ | `CHANGELOG.md` | Add v0.7.0 entry |
42
+ | `lib/markdowndocs/version.rb` | Bump to `0.7.0` |
43
+
44
+ ---
45
+
46
+ ## Task 1: Add path-based test fixtures
47
+
48
+ **Files:**
49
+
50
+ - Create: `spec/dummy/app/docs/billing.md`
51
+ - Create: `spec/dummy/app/docs/technical/architecture.md`
52
+ - Create: `spec/dummy/app/docs/technical/billing.md`
53
+ - Modify: `spec/spec_helper.rb` (extend `categories` config to reference the new fixtures)
54
+
55
+ - [ ] **Step 1: Create the shared `billing.md` fixture**
56
+
57
+ Create `spec/dummy/app/docs/billing.md`:
58
+
59
+ ```markdown
60
+ ---
61
+ title: Billing
62
+ description: How billing works for all customers
63
+ ---
64
+
65
+ # Billing
66
+
67
+ This is the shared billing doc, visible to every audience. It explains
68
+ the customer-facing aspects of billing.
69
+ ```
70
+
71
+ - [ ] **Step 2: Create the technical subdirectory and architecture fixture**
72
+
73
+ ```bash
74
+ mkdir -p spec/dummy/app/docs/technical
75
+ ```
76
+
77
+ Create `spec/dummy/app/docs/technical/architecture.md`:
78
+
79
+ ```markdown
80
+ ---
81
+ title: System Architecture
82
+ description: How the billing service is wired internally
83
+ ---
84
+
85
+ # System Architecture
86
+
87
+ Technical-only doc with no shared sibling. Used to verify smart-nav
88
+ fallback behavior when toggling to guide mode (should stay put).
89
+ ```
90
+
91
+ - [ ] **Step 3: Create the technical billing sibling**
92
+
93
+ Create `spec/dummy/app/docs/technical/billing.md`:
94
+
95
+ ```markdown
96
+ ---
97
+ title: Billing Internals
98
+ description: Stripe webhook plumbing and idempotency keys
99
+ ---
100
+
101
+ # Billing Internals
102
+
103
+ Technical sibling to the shared billing doc. Used to verify the smart-nav
104
+ override path (toggle from guide → technical jumps here when sitting on
105
+ the shared /docs/billing).
106
+ ```
107
+
108
+ - [ ] **Step 4: Extend the test category configuration**
109
+
110
+ Modify `spec/spec_helper.rb`. Find both `Markdowndocs.configure` blocks (one in `before(:suite)`, one in `after`) and update the `c.categories` hash in both:
111
+
112
+ ```ruby
113
+ c.categories = {
114
+ "Getting Started" => %w[welcome quickstart],
115
+ "Guides" => %w[authentication billing],
116
+ "Administrator Reference" => %w[admin-reference],
117
+ "Architecture" => %w[technical/architecture technical/billing]
118
+ }
119
+ ```
120
+
121
+ This exercises both bare slugs (`billing`) and path-prefixed slugs (`technical/architecture`, `technical/billing`).
122
+
123
+ - [ ] **Step 5: Run existing suite to confirm no regressions from fixtures alone**
124
+
125
+ Run: `bundle exec rspec`
126
+ Expected: All existing tests still pass. The new fixtures are present on disk but no test references them yet. There may be a NEW failure where `grouped_by_category` builds a "Architecture" category whose slugs `technical/architecture` resolve to nothing under today's code — that's expected and will turn green in Task 3. If the existing tests pass and only the new path-prefix slugs produce empty/missing docs (which is fine because `grouped_by_category` already drops empty categories), the commit can proceed.
127
+
128
+ - [ ] **Step 6: Commit**
129
+
130
+ ```bash
131
+ git add spec/dummy/app/docs/billing.md \
132
+ spec/dummy/app/docs/technical/architecture.md \
133
+ spec/dummy/app/docs/technical/billing.md \
134
+ spec/spec_helper.rb
135
+ git commit -m "test: add fixtures for path-based audience routing"
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Task 2: Documentation walks mode subdirectories and derives `path_slug`
141
+
142
+ **Files:**
143
+
144
+ - Modify: `app/models/markdowndocs/documentation.rb`
145
+ - Test: `spec/models/markdowndocs/documentation_spec.rb`
146
+
147
+ The `Documentation` PORO learns two new things in this task:
148
+
149
+ 1. `Documentation.all` walks both the root and each mode-named subdirectory under `app/docs/`.
150
+ 2. Each instance gains a `path_slug` attribute (the file's path relative to docs root, sans `.md`). This is used in subsequent tasks for category matching and audience derivation.
151
+
152
+ - [ ] **Step 1: Write failing tests for path-based discovery**
153
+
154
+ Add to `spec/models/markdowndocs/documentation_spec.rb` (inside the existing `describe ".all"` block):
155
+
156
+ ```ruby
157
+ context "with files in mode-named subdirectories" do
158
+ it "discovers files in subdirectories whose name matches a configured mode" do
159
+ slugs = described_class.all.map(&:slug)
160
+ expect(slugs).to include("architecture", "billing")
161
+ # `billing` appears twice: docs/billing.md AND docs/technical/billing.md.
162
+ # We use path_slug in later tests to distinguish them.
163
+ end
164
+
165
+ it "exposes the path relative to the docs root via #path_slug" do
166
+ path_slugs = described_class.all.map(&:path_slug)
167
+ expect(path_slugs).to include("billing")
168
+ expect(path_slugs).to include("technical/architecture")
169
+ expect(path_slugs).to include("technical/billing")
170
+ end
171
+
172
+ it "ignores subdirectories whose name is not in config.modes" do
173
+ Dir.mktmpdir do |tmp|
174
+ docs = Pathname.new(tmp)
175
+ docs.join("root.md").write("# Root\n")
176
+ docs.join("api").mkpath
177
+ docs.join("api", "ignored.md").write("# Ignored\n")
178
+ docs.join("technical").mkpath
179
+ docs.join("technical", "kept.md").write("# Kept\n")
180
+
181
+ Markdowndocs.config.docs_path = docs
182
+ slugs = described_class.all.map(&:slug)
183
+
184
+ expect(slugs).to include("root", "kept")
185
+ expect(slugs).not_to include("ignored")
186
+ end
187
+ end
188
+
189
+ it "logs a warning the first time a non-mode subdirectory is encountered" do
190
+ Dir.mktmpdir do |tmp|
191
+ docs = Pathname.new(tmp)
192
+ docs.join("api").mkpath
193
+ docs.join("api", "ignored.md").write("# Ignored\n")
194
+
195
+ Markdowndocs.config.docs_path = docs
196
+
197
+ log_messages = []
198
+ original_logger = Rails.logger
199
+ Rails.logger = Logger.new(StringIO.new).tap do |l|
200
+ l.formatter = ->(_sev, _t, _p, msg) { log_messages << msg.to_s; "" }
201
+ end
202
+
203
+ begin
204
+ 2.times { described_class.all }
205
+ ensure
206
+ Rails.logger = original_logger
207
+ end
208
+
209
+ api_warnings = log_messages.select { |m| m.include?("Ignoring subdirectory") && m.include?("api") }
210
+ expect(api_warnings.size).to eq(1), "expected exactly one warning, got #{api_warnings.size}: #{api_warnings.inspect}"
211
+ end
212
+ end
213
+ end
214
+ ```
215
+
216
+ - [ ] **Step 2: Run failing tests**
217
+
218
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "with files in mode-named subdirectories"`
219
+ Expected: FAIL. Errors should be of the form `expected to include "architecture"` (because `Dir.glob(docs_path.join("*.md"))` doesn't descend), `NoMethodError: undefined method path_slug` (the attribute doesn't exist yet), and the non-mode-subdirectory warning test fails because no warning is emitted today.
220
+
221
+ - [ ] **Step 3: Implement `path_slug` and subdirectory discovery**
222
+
223
+ Modify `app/models/markdowndocs/documentation.rb`. Replace the `attr_reader` line:
224
+
225
+ ```ruby
226
+ attr_reader :slug, :path_slug, :title, :description, :category, :file_path, :keywords
227
+ ```
228
+
229
+ Replace `initialize`:
230
+
231
+ ```ruby
232
+ def initialize(file_path)
233
+ @file_path = file_path
234
+ @slug = derive_slug
235
+ @path_slug = derive_path_slug
236
+ extract_metadata
237
+ @category = assign_category
238
+ end
239
+ ```
240
+
241
+ Add the `derive_path_slug` method in the private section, right after `derive_slug`:
242
+
243
+ ```ruby
244
+ def derive_path_slug
245
+ docs_root = Markdowndocs.config.resolved_docs_path
246
+ relative = file_path.relative_path_from(docs_root)
247
+ relative.sub_ext("").to_s
248
+ end
249
+ ```
250
+
251
+ Replace `self.all`:
252
+
253
+ ```ruby
254
+ def self.all
255
+ docs_path = Markdowndocs.config.resolved_docs_path
256
+ return [] unless docs_path.exist?
257
+
258
+ files = Dir.glob(docs_path.join("*.md"))
259
+
260
+ modes = Markdowndocs.config.modes
261
+ modes.each do |mode|
262
+ mode_dir = docs_path.join(mode)
263
+ files.concat(Dir.glob(mode_dir.join("*.md"))) if mode_dir.exist?
264
+ end
265
+
266
+ warn_about_non_mode_subdirectories(docs_path, modes)
267
+
268
+ files.map { |f| new(Pathname.new(f)) }.sort_by(&:path_slug)
269
+ end
270
+
271
+ # Emits a one-shot warning per process boot for each first-level
272
+ # subdirectory under docs_path that isn't a configured mode. Files
273
+ # inside such subdirectories are silently dropped by discovery —
274
+ # the warning makes that visible.
275
+ def self.warn_about_non_mode_subdirectories(docs_path, modes)
276
+ Markdowndocs.config.non_mode_subdirs_warned ||= Set.new
277
+
278
+ docs_path.children.each do |child|
279
+ next unless child.directory?
280
+ name = child.basename.to_s
281
+ next if modes.include?(name)
282
+ next if Markdowndocs.config.non_mode_subdirs_warned.include?(name)
283
+
284
+ Markdowndocs.config.non_mode_subdirs_warned << name
285
+ Rails.logger.warn(
286
+ "[Markdowndocs] Ignoring subdirectory #{child}/ — name does not match " \
287
+ "any configured mode (config.modes = #{modes.inspect}). Files inside " \
288
+ "this subdirectory will not be discovered. Move them into #{docs_path}/ " \
289
+ "or into a mode-named subdirectory."
290
+ )
291
+ end
292
+ rescue => e
293
+ # Don't let a discovery-time warning failure break .all.
294
+ Rails.logger.warn("[Markdowndocs] Could not scan for non-mode subdirectories: #{e.message}")
295
+ end
296
+ ```
297
+
298
+ Note: sorting by `path_slug` groups files by audience scope. The pre-existing `.sort_by(&:slug)` would have placed `docs/billing.md` and `docs/technical/billing.md` adjacent (both `billing`), which is confusing. `path_slug` ordering puts root files first alphabetically, then each mode's subdirectory.
299
+
300
+ The `non_mode_subdirs_warned` Set lives on `Markdowndocs.config` so test teardown (via `Markdowndocs.reset_configuration!`) resets it between tests — same pattern used by the audience-deprecation tracking in Task 9. Add it to the Configuration in this task to keep the implementation self-contained:
301
+
302
+ Modify `lib/markdowndocs/configuration.rb`. Add `require "set"` at the top of the file (if not already present), then add an attribute and initializer assignment:
303
+
304
+ ```ruby
305
+ attr_accessor :docs_path, :categories, :modes, :default_mode,
306
+ :markdown_options, :rouge_theme, :cache_expiry,
307
+ :user_mode_resolver, :user_mode_saver, :search_enabled,
308
+ :layout, :non_mode_subdirs_warned
309
+ ```
310
+
311
+ Inside `initialize`:
312
+
313
+ ```ruby
314
+ @non_mode_subdirs_warned = Set.new
315
+ ```
316
+
317
+ - [ ] **Step 4: Run tests, verify pass**
318
+
319
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "with files in mode-named subdirectories"`
320
+ Expected: PASS.
321
+
322
+ Then run the full Documentation spec to catch regressions:
323
+
324
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb`
325
+ Expected: All previously passing tests still pass.
326
+
327
+ - [ ] **Step 5: Commit**
328
+
329
+ ```bash
330
+ git add app/models/markdowndocs/documentation.rb \
331
+ spec/models/markdowndocs/documentation_spec.rb
332
+ git commit -m "feat: Documentation walks mode subdirectories and exposes path_slug"
333
+ ```
334
+
335
+ ---
336
+
337
+ ## Task 3: Documentation derives audience from path
338
+
339
+ **Files:**
340
+
341
+ - Modify: `app/models/markdowndocs/documentation.rb`
342
+ - Test: `spec/models/markdowndocs/documentation_spec.rb`
343
+
344
+ When `audience:` frontmatter is absent, audience is derived from the parent directory:
345
+
346
+ - Root files (`docs/foo.md`) → `audience = config.modes.dup` (visible everywhere, identical to v0.6.x backward-compat behavior).
347
+ - Mode-subdirectory files (`docs/technical/foo.md`) → `audience = ["technical"]` (visible in technical mode only).
348
+
349
+ `audience:` frontmatter still wins when present.
350
+
351
+ - [ ] **Step 1: Write failing tests for audience derivation**
352
+
353
+ Add to `spec/models/markdowndocs/documentation_spec.rb`, near the existing `audience` tests (or create a new `describe "#audience"` block if none exists):
354
+
355
+ ```ruby
356
+ describe "#audience (path-based)" do
357
+ it "returns all configured modes for a root file with no frontmatter audience" do
358
+ doc = described_class.find_by_slug("billing")
359
+ expect(doc.audience).to match_array(Markdowndocs.config.modes)
360
+ end
361
+
362
+ it "returns a single-element array of the subdirectory name for a mode-scoped file" do
363
+ doc = described_class.all.find { |d| d.path_slug == "technical/architecture" }
364
+ expect(doc).not_to be_nil
365
+ expect(doc.audience).to eq(["technical"])
366
+ end
367
+
368
+ it "lets `audience:` frontmatter override the path-derived value (backward compat)" do
369
+ # admin-reference.md is at the root with `audience: technical` in frontmatter.
370
+ doc = described_class.all.find { |d| d.slug == "admin-reference" }
371
+ expect(doc.audience).to eq(["technical"])
372
+ end
373
+ end
374
+
375
+ describe "#visible_to?" do
376
+ it "returns true for a root file in any configured mode" do
377
+ doc = described_class.find_by_slug("billing")
378
+ expect(doc.visible_to?("guide")).to be true
379
+ expect(doc.visible_to?("technical")).to be true
380
+ end
381
+
382
+ it "returns false for a mode-scoped file in a non-matching mode" do
383
+ doc = described_class.all.find { |d| d.path_slug == "technical/architecture" }
384
+ expect(doc.visible_to?("guide")).to be false
385
+ expect(doc.visible_to?("technical")).to be true
386
+ end
387
+ end
388
+ ```
389
+
390
+ - [ ] **Step 2: Run failing tests**
391
+
392
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "path-based"`
393
+ Expected: FAIL. The mode-scoped file currently returns `config.modes.dup` (visible-everywhere default) instead of `["technical"]`.
394
+
395
+ - [ ] **Step 3: Implement path-based audience derivation**
396
+
397
+ Modify `app/models/markdowndocs/documentation.rb`. Replace the `audience` method:
398
+
399
+ ```ruby
400
+ # The audience(s) this doc is written for. Resolution order:
401
+ # 1. `audience:` frontmatter (DEPRECATED in 0.7.0, removed in 1.0.0)
402
+ # 2. Parent directory name when it matches a configured mode
403
+ # 3. All configured modes (root file with no override — visible everywhere)
404
+ def audience
405
+ @audience ||= begin
406
+ parsed = parse_frontmatter
407
+ raw = parsed[:frontmatter]["audience"]
408
+ case raw
409
+ when Array then raw.map(&:to_s)
410
+ when String then [raw]
411
+ when nil
412
+ scope = audience_from_path
413
+ scope ? [scope] : Markdowndocs.config.modes.dup
414
+ else Markdowndocs.config.modes.dup
415
+ end
416
+ end
417
+ end
418
+ ```
419
+
420
+ Add a private helper near `derive_path_slug`:
421
+
422
+ ```ruby
423
+ def audience_from_path
424
+ dir = file_path.dirname.basename.to_s
425
+ Markdowndocs.config.modes.include?(dir) ? dir : nil
426
+ end
427
+ ```
428
+
429
+ - [ ] **Step 4: Run tests, verify pass**
430
+
431
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "path-based"`
432
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "#visible_to?"`
433
+ Expected: PASS for both.
434
+
435
+ Then run the full Documentation spec:
436
+
437
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb`
438
+ Expected: All tests pass (including the pre-existing audience-frontmatter tests, which still work because frontmatter overrides path).
439
+
440
+ - [ ] **Step 5: Commit**
441
+
442
+ ```bash
443
+ git add app/models/markdowndocs/documentation.rb \
444
+ spec/models/markdowndocs/documentation_spec.rb
445
+ git commit -m "feat: derive Documentation#audience from parent directory when no frontmatter"
446
+ ```
447
+
448
+ ---
449
+
450
+ ## Task 4: Documentation.find_by_slug resolves mode-scoped paths
451
+
452
+ **Files:**
453
+
454
+ - Modify: `app/models/markdowndocs/documentation.rb`
455
+ - Test: `spec/models/markdowndocs/documentation_spec.rb`
456
+
457
+ When `mode:` is non-nil, `find_by_slug` first looks for `docs/<mode>/<slug>.md` (the scoped file), then falls back to `docs/<slug>.md` (a shared file at root). With `mode: nil`, only the root is checked (matches v0.6.x semantics — used by search indexer / admin tools).
458
+
459
+ - [ ] **Step 1: Write failing tests**
460
+
461
+ Add to `spec/models/markdowndocs/documentation_spec.rb` inside the existing `describe ".find_by_slug"` block:
462
+
463
+ ```ruby
464
+ context "with a mode subdirectory" do
465
+ it "resolves to the mode-scoped file when mode is given and the file exists" do
466
+ doc = described_class.find_by_slug("architecture", mode: "technical")
467
+ expect(doc).not_to be_nil
468
+ expect(doc.path_slug).to eq("technical/architecture")
469
+ end
470
+
471
+ it "falls back to the root file when no mode-scoped file exists" do
472
+ # `welcome.md` is at root and has no `docs/technical/welcome.md`.
473
+ doc = described_class.find_by_slug("welcome", mode: "technical")
474
+ expect(doc).not_to be_nil
475
+ expect(doc.path_slug).to eq("welcome")
476
+ end
477
+
478
+ it "returns nil when neither a mode-scoped nor a root file exists" do
479
+ expect(described_class.find_by_slug("nonexistent", mode: "technical")).to be_nil
480
+ end
481
+
482
+ it "with mode: nil, only checks the root and ignores subdirectory files" do
483
+ # `architecture` only exists at docs/technical/architecture.md, not root.
484
+ expect(described_class.find_by_slug("architecture", mode: nil)).to be_nil
485
+ end
486
+
487
+ it "respects visible_to? on root fallback (audience:-deprecated root files)" do
488
+ # admin-reference is at root with `audience: technical` frontmatter.
489
+ expect(described_class.find_by_slug("admin-reference", mode: "guide")).to be_nil
490
+ expect(described_class.find_by_slug("admin-reference", mode: "technical")).not_to be_nil
491
+ end
492
+ end
493
+ ```
494
+
495
+ - [ ] **Step 2: Run failing tests**
496
+
497
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "with a mode subdirectory"`
498
+ Expected: FAIL (the first test fails because `find_by_slug` builds `docs_path.join("architecture.md")` and that file doesn't exist at root).
499
+
500
+ - [ ] **Step 3: Implement mode-scoped resolution**
501
+
502
+ Modify `app/models/markdowndocs/documentation.rb`. Replace `self.find_by_slug`:
503
+
504
+ ```ruby
505
+ # Resolves a doc by slug. When `mode:` is given, prefers the mode-scoped
506
+ # file (docs/<mode>/<slug>.md) and falls back to the root (docs/<slug>.md)
507
+ # if visible_to?(mode) passes. With `mode: nil`, only the root is checked.
508
+ def self.find_by_slug(slug, mode: nil)
509
+ return nil if slug.blank?
510
+ return nil if slug.include?("..") || slug.include?("/")
511
+
512
+ docs_path = Markdowndocs.config.resolved_docs_path
513
+
514
+ if mode.present? && Markdowndocs.config.modes.include?(mode.to_s)
515
+ scoped = docs_path.join(mode.to_s, "#{slug}.md")
516
+ return new(scoped) if scoped.exist?
517
+ end
518
+
519
+ root = docs_path.join("#{slug}.md")
520
+ return nil unless root.exist?
521
+
522
+ doc = new(root)
523
+ return nil unless doc.visible_to?(mode)
524
+
525
+ doc
526
+ rescue => e
527
+ Rails.logger.error("Error finding documentation by slug '#{slug}': #{e.message}")
528
+ nil
529
+ end
530
+ ```
531
+
532
+ - [ ] **Step 4: Run tests, verify pass**
533
+
534
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "with a mode subdirectory"`
535
+ Expected: PASS for all five examples.
536
+
537
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb`
538
+ Expected: All tests pass.
539
+
540
+ - [ ] **Step 5: Commit**
541
+
542
+ ```bash
543
+ git add app/models/markdowndocs/documentation.rb \
544
+ spec/models/markdowndocs/documentation_spec.rb
545
+ git commit -m "feat: find_by_slug resolves mode-scoped paths with root fallback"
546
+ ```
547
+
548
+ ---
549
+
550
+ ## Task 5: assign_category matches path-prefixed slugs
551
+
552
+ **Files:**
553
+
554
+ - Modify: `app/models/markdowndocs/documentation.rb`
555
+ - Test: `spec/models/markdowndocs/documentation_spec.rb`
556
+
557
+ `config.categories` slugs now support a path prefix (`"technical/architecture"`). `assign_category` matches on `path_slug` instead of just `slug`, so bare entries (`"billing"`) match root files and prefixed entries (`"technical/architecture"`) match the scoped file.
558
+
559
+ - [ ] **Step 1: Write failing tests**
560
+
561
+ Add to `spec/models/markdowndocs/documentation_spec.rb` (new top-level describe block):
562
+
563
+ ```ruby
564
+ describe "#category (path-prefixed slugs in config.categories)" do
565
+ it "assigns the configured category to a root file via bare slug" do
566
+ doc = described_class.find_by_slug("billing")
567
+ expect(doc.category).to eq("Guides")
568
+ end
569
+
570
+ it "assigns the configured category to a mode-scoped file via path-prefixed slug" do
571
+ doc = described_class.all.find { |d| d.path_slug == "technical/architecture" }
572
+ expect(doc.category).to eq("Architecture")
573
+ end
574
+
575
+ it "assigns the configured category to a mode-scoped file with a same-named root sibling" do
576
+ # Both docs/billing.md (Guides) and docs/technical/billing.md (Architecture) exist.
577
+ root_billing = described_class.all.find { |d| d.path_slug == "billing" }
578
+ scoped_billing = described_class.all.find { |d| d.path_slug == "technical/billing" }
579
+
580
+ expect(root_billing.category).to eq("Guides")
581
+ expect(scoped_billing.category).to eq("Architecture")
582
+ end
583
+ end
584
+ ```
585
+
586
+ - [ ] **Step 2: Run failing tests**
587
+
588
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "path-prefixed slugs"`
589
+ Expected: FAIL. The scoped billing assertion fails because `assign_category` matches `slug` (`"billing"`) against `config.categories["Architecture"]` which contains `"technical/billing"` — no match — so it returns `"Other"` instead of `"Architecture"`.
590
+
591
+ - [ ] **Step 3: Update assign_category to match on path_slug**
592
+
593
+ Modify `app/models/markdowndocs/documentation.rb`. Replace `assign_category`:
594
+
595
+ ```ruby
596
+ def assign_category
597
+ Markdowndocs.config.categories.each do |category, slugs|
598
+ return category if slugs.include?(path_slug)
599
+ end
600
+
601
+ "Other"
602
+ end
603
+ ```
604
+
605
+ This is a one-word change (`slug` → `path_slug`), but it's load-bearing.
606
+
607
+ - [ ] **Step 4: Run tests, verify pass**
608
+
609
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "path-prefixed slugs"`
610
+ Expected: PASS.
611
+
612
+ Run the full Documentation spec:
613
+
614
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb`
615
+ Expected: All tests pass. Pre-existing categories tests still work because bare slugs (e.g., `"welcome"`) match a root file's `path_slug` (also `"welcome"`).
616
+
617
+ - [ ] **Step 5: Commit**
618
+
619
+ ```bash
620
+ git add app/models/markdowndocs/documentation.rb \
621
+ spec/models/markdowndocs/documentation_spec.rb
622
+ git commit -m "feat: config.categories supports path-prefixed slugs for mode-scoped docs"
623
+ ```
624
+
625
+ ---
626
+
627
+ ## Task 6: Add `/:mode/:slug` route with regex constraint
628
+
629
+ **Files:**
630
+
631
+ - Modify: `config/routes.rb`
632
+ - Modify: `app/controllers/markdowndocs/docs_controller.rb`
633
+ - Test: `spec/requests/markdowndocs/docs_spec.rb`
634
+
635
+ A new route serves `/docs/:mode/:slug` where `:mode` is constrained to entries in `Markdowndocs.config.modes`. Both routes call `DocsController#show`, which reads `params[:mode]` and passes it through to `Documentation.find_by_slug`.
636
+
637
+ - [ ] **Step 1: Write failing request specs**
638
+
639
+ Add to `spec/requests/markdowndocs/docs_spec.rb` (inside the `RSpec.describe "Markdowndocs::Docs"` block):
640
+
641
+ ```ruby
642
+ describe "GET /docs/:mode/:slug" do
643
+ it "renders a mode-scoped document" do
644
+ get "/docs/technical/architecture"
645
+ expect(response).to have_http_status(:ok)
646
+ expect(response.body).to include("System Architecture")
647
+ end
648
+
649
+ it "renders a mode-scoped doc independently of the current preference (URL determines content)" do
650
+ get "/docs/technical/architecture", params: {mode: "guide"}
651
+ expect(response).to have_http_status(:ok)
652
+ expect(response.body).to include("System Architecture")
653
+ end
654
+
655
+ it "returns 404 for an unknown mode segment" do
656
+ get "/docs/notamode/architecture"
657
+ expect(response).to have_http_status(:not_found)
658
+ end
659
+
660
+ it "returns 404 for a mode-scoped slug that doesn't exist" do
661
+ get "/docs/technical/nonexistent"
662
+ expect(response).to have_http_status(:not_found)
663
+ end
664
+
665
+ it "returns 404 for directory traversal in slug" do
666
+ get "/docs/technical/..%2F..%2Fetc%2Fpasswd"
667
+ expect(response).to have_http_status(:not_found)
668
+ end
669
+ end
670
+ ```
671
+
672
+ - [ ] **Step 2: Run failing tests**
673
+
674
+ Run: `bundle exec rspec spec/requests/markdowndocs/docs_spec.rb -e "GET /docs/:mode/:slug"`
675
+ Expected: FAIL — all examples return 404 because no route matches `/docs/technical/architecture` (the existing `:slug` route doesn't accept two segments).
676
+
677
+ - [ ] **Step 3: Add the constrained route**
678
+
679
+ Modify `config/routes.rb`. Replace the file contents:
680
+
681
+ ```ruby
682
+ # frozen_string_literal: true
683
+
684
+ Markdowndocs::Engine.routes.draw do
685
+ root "docs#index"
686
+ get "search_index", to: "docs#search_index", as: :search_index
687
+
688
+ # Mode-scoped doc route: matches /<mode>/<slug> where <mode> is one of
689
+ # the configured modes. Must come BEFORE the unconstrained :slug route
690
+ # so the more specific match wins.
691
+ mode_constraint = if Markdowndocs.config.modes.any?
692
+ Regexp.new("\\A(?:#{Markdowndocs.config.modes.map { |m| Regexp.escape(m) }.join("|")})\\z")
693
+ else
694
+ /\Aimpossible\z/
695
+ end
696
+ get ":mode/:slug", to: "docs#show", as: :scoped_doc, constraints: {mode: mode_constraint}
697
+
698
+ get ":slug", to: "docs#show", as: :doc
699
+ resource :preference, only: [:update]
700
+ end
701
+ ```
702
+
703
+ The `if Markdowndocs.config.modes.any?` guard prevents an empty alternation regex (which would match the empty string and produce unexpected routing). Hosts that empty their modes list disable scoped routing.
704
+
705
+ **Note on boot ordering:** The regex constraint is evaluated when the engine routes are drawn (at app boot). Hosts that set `config.modes` in `config/initializers/markdowndocs.rb` do so before route drawing — this is the typical and expected flow. Hosts that mutate `config.modes` after boot won't see their changes reflected in routes.
706
+
707
+ - [ ] **Step 4: Update the controller to pass `params[:mode]` through**
708
+
709
+ Modify `app/controllers/markdowndocs/docs_controller.rb`. The `show` method already references `@docs_mode` (the resolved current mode). With the new route, `params[:mode]` is now potentially the *path mode* (the subdirectory portion of the URL), distinct from the *current preference*. We need to look up the doc using the *path mode* when present, and fall back to `@docs_mode` (the preference) when the URL is unscoped.
710
+
711
+ Replace the `show` method:
712
+
713
+ ```ruby
714
+ def show
715
+ # `params[:mode]` here is the URL path segment (e.g. /docs/technical/foo → "technical")
716
+ # if the request matched the scoped route. Otherwise, fall back to the resolved
717
+ # current preference (@docs_mode) so root-mounted docs honor audience-frontmatter
718
+ # filtering.
719
+ lookup_mode = params[:mode].presence || @docs_mode
720
+ @doc = Documentation.find_by_slug(params[:slug], mode: lookup_mode)
721
+
722
+ if @doc.nil?
723
+ render_not_found
724
+ return
725
+ end
726
+
727
+ rendered_html = MarkdownRenderer.render(
728
+ @doc.content,
729
+ cache_key: @doc.cache_key,
730
+ mode: @docs_mode
731
+ )
732
+ @rendered_content = helpers.add_heading_anchors(rendered_html)
733
+ @related_docs = Documentation.by_category(@doc.category).reject { |d| d.slug == @doc.slug }
734
+ @available_modes = @doc.available_modes
735
+ @toc_items = helpers.generate_table_of_contents(@rendered_content)
736
+ end
737
+ ```
738
+
739
+ **Important:** `set_docs_mode` (which builds `@docs_mode`) currently treats `params[:mode]` as a mode preference override. That breaks the moment `params[:mode]` carries a URL path segment. Update `determine_docs_mode` so it only treats `params[:mode]` as a preference when the request matched the unscoped route (i.e., when no `:slug` URL captured the mode).
740
+
741
+ Replace `determine_docs_mode`:
742
+
743
+ ```ruby
744
+ def determine_docs_mode
745
+ # Only treat params[:mode] as a preference override on the root-mounted
746
+ # `/:slug` route. On the scoped `/:mode/:slug` route, params[:mode] is
747
+ # the URL path segment and is consumed by `show` for doc lookup, not as
748
+ # a preference override.
749
+ preference_param = if path_mode_in_request?
750
+ nil
751
+ else
752
+ params[:mode]
753
+ end
754
+
755
+ mode = preference_param ||
756
+ resolve_user_mode ||
757
+ cookies[:markdowndocs_mode] ||
758
+ Markdowndocs.config.default_mode
759
+
760
+ valid_modes = Markdowndocs.config.modes
761
+ valid_modes.include?(mode) ? mode : Markdowndocs.config.default_mode
762
+ end
763
+
764
+ def path_mode_in_request?
765
+ # The scoped route names `mode` as a path param. On the unscoped route,
766
+ # `mode` (when present) comes from the query string.
767
+ request.path_parameters[:mode].present?
768
+ end
769
+ ```
770
+
771
+ - [ ] **Step 5: Run tests, verify pass**
772
+
773
+ Run: `bundle exec rspec spec/requests/markdowndocs/docs_spec.rb -e "GET /docs/:mode/:slug"`
774
+ Expected: PASS for all five examples.
775
+
776
+ Run the full request spec:
777
+
778
+ Run: `bundle exec rspec spec/requests/markdowndocs/docs_spec.rb`
779
+ Expected: All tests pass, including the pre-existing "supports mode parameter" test (it passes `mode: "guide"` as a query param, which still works via the unscoped route).
780
+
781
+ - [ ] **Step 6: Commit**
782
+
783
+ ```bash
784
+ git add config/routes.rb \
785
+ app/controllers/markdowndocs/docs_controller.rb \
786
+ spec/requests/markdowndocs/docs_spec.rb
787
+ git commit -m "feat: add /docs/:mode/:slug route for path-based audience scoping"
788
+ ```
789
+
790
+ ---
791
+
792
+ ## Task 7: PreferencesController computes smart-navigation target
793
+
794
+ **Files:**
795
+
796
+ - Create: `spec/requests/markdowndocs/preferences_spec.rb`
797
+ - Modify: `app/controllers/markdowndocs/preferences_controller.rb`
798
+
799
+ When the user submits a mode change, the controller computes the target URL using the unified lookup rule (target-mode-subdir file → root file → stay), and redirects there instead of `redirect_back`. The form must pass `current_path` as a hidden param (added in Task 8).
800
+
801
+ - [ ] **Step 1: Write failing request specs**
802
+
803
+ Create `spec/requests/markdowndocs/preferences_spec.rb`:
804
+
805
+ ```ruby
806
+ # frozen_string_literal: true
807
+
808
+ require "spec_helper"
809
+
810
+ RSpec.describe "Markdowndocs::Preferences", type: :request do
811
+ describe "PATCH /docs/preference" do
812
+ context "smart navigation" do
813
+ it "redirects to /docs/<target>/<slug> when the scoped sibling exists" do
814
+ # Sitting on shared /docs/billing, toggling to technical, technical/billing.md exists.
815
+ patch "/docs/preference",
816
+ params: {mode: "technical", current_path: "/docs/billing"}
817
+ expect(response).to redirect_to("/docs/technical/billing")
818
+ end
819
+
820
+ it "redirects to /docs/<slug> (root) when toggling away from a mode-scoped doc whose shared sibling exists" do
821
+ # Sitting on /docs/technical/billing, toggling to guide, docs/billing.md exists.
822
+ patch "/docs/preference",
823
+ params: {mode: "guide", current_path: "/docs/technical/billing"}
824
+ expect(response).to redirect_to("/docs/billing")
825
+ end
826
+
827
+ it "stays on the current path when no sibling exists in the target mode and the current URL has no shared fallback" do
828
+ # technical/architecture has no shared sibling.
829
+ patch "/docs/preference",
830
+ params: {mode: "guide", current_path: "/docs/technical/architecture"}
831
+ expect(response).to redirect_to("/docs/technical/architecture")
832
+ end
833
+
834
+ it "stays on the current path when toggling and no scoped sibling exists for a shared doc" do
835
+ # docs/welcome.md exists, but docs/technical/welcome.md does NOT.
836
+ patch "/docs/preference",
837
+ params: {mode: "technical", current_path: "/docs/welcome"}
838
+ expect(response).to redirect_to("/docs/welcome")
839
+ end
840
+
841
+ it "stays on /docs (index) when current_path is the index" do
842
+ patch "/docs/preference",
843
+ params: {mode: "technical", current_path: "/docs"}
844
+ expect(response).to redirect_to("/docs")
845
+ end
846
+
847
+ it "rejects an unknown mode with 422" do
848
+ patch "/docs/preference",
849
+ params: {mode: "notamode", current_path: "/docs/billing"}
850
+ expect(response).to have_http_status(:unprocessable_entity)
851
+ end
852
+
853
+ it "persists the mode preference as a cookie" do
854
+ patch "/docs/preference",
855
+ params: {mode: "technical", current_path: "/docs/billing"}
856
+ expect(cookies["markdowndocs_mode"]).to eq("technical")
857
+ end
858
+
859
+ it "falls back to /docs when current_path is missing" do
860
+ # Backward-compat: forms from older deployments that don't pass current_path.
861
+ patch "/docs/preference", params: {mode: "technical"}
862
+ expect(response).to redirect_to("/docs")
863
+ end
864
+ end
865
+ end
866
+ end
867
+ ```
868
+
869
+ - [ ] **Step 2: Run failing tests**
870
+
871
+ Run: `bundle exec rspec spec/requests/markdowndocs/preferences_spec.rb`
872
+ Expected: FAIL — current `redirect_back` does not honor `current_path`, and there's no smart-navigation logic.
873
+
874
+ - [ ] **Step 3: Implement smart-navigation in PreferencesController**
875
+
876
+ Replace the contents of `app/controllers/markdowndocs/preferences_controller.rb`:
877
+
878
+ ```ruby
879
+ # frozen_string_literal: true
880
+
881
+ module Markdowndocs
882
+ class PreferencesController < ApplicationController
883
+ def update
884
+ mode = params[:mode].to_s
885
+
886
+ unless Markdowndocs.config.modes.include?(mode)
887
+ head :unprocessable_entity
888
+ return
889
+ end
890
+
891
+ saver = Markdowndocs.config.user_mode_saver
892
+ if saver.respond_to?(:call)
893
+ begin
894
+ saver.call(self, mode)
895
+ rescue => e
896
+ Rails.logger.warn("Markdowndocs: user_mode_saver failed: #{e.message}")
897
+ end
898
+ end
899
+
900
+ cookies[:markdowndocs_mode] = {
901
+ value: mode,
902
+ expires: 1.year.from_now,
903
+ httponly: true
904
+ }
905
+
906
+ redirect_to(smart_nav_target(mode, params[:current_path]), status: :see_other)
907
+ end
908
+
909
+ private
910
+
911
+ # Computes the post-toggle destination using the unified lookup rule:
912
+ # 1. /docs/<target_mode>/<slug> if the scoped file exists and is not current
913
+ # 2. /docs/<slug> (root) if it exists and is not current
914
+ # 3. current path (stay put)
915
+ # Falls back to the docs index when current_path is missing or doesn't
916
+ # match a recognizable doc URL.
917
+ def smart_nav_target(target_mode, current_path)
918
+ index_path = markdowndocs.root_path
919
+ return index_path if current_path.blank?
920
+
921
+ slug = extract_slug_from_path(current_path)
922
+ return current_path if slug.nil?
923
+
924
+ docs_path = Markdowndocs.config.resolved_docs_path
925
+ scoped_file = docs_path.join(target_mode, "#{slug}.md")
926
+ root_file = docs_path.join("#{slug}.md")
927
+
928
+ scoped_url = markdowndocs.scoped_doc_path(mode: target_mode, slug: slug)
929
+ root_url = markdowndocs.doc_path(slug: slug)
930
+
931
+ if scoped_file.exist? && current_path != scoped_url
932
+ scoped_url
933
+ elsif root_file.exist? && current_path != root_url
934
+ root_url
935
+ else
936
+ current_path
937
+ end
938
+ end
939
+
940
+ # Pulls the slug from a docs path. Returns nil if the path is the index
941
+ # or doesn't match the docs URL shape. Recognizes both /docs/<slug> and
942
+ # /docs/<mode>/<slug>.
943
+ def extract_slug_from_path(path)
944
+ # Strip query string and trailing slash.
945
+ clean = path.split("?").first.to_s.chomp("/")
946
+ base = markdowndocs.root_path.chomp("/")
947
+ return nil unless clean.start_with?(base)
948
+
949
+ remainder = clean[base.length..]
950
+ return nil if remainder.blank? || remainder == "/"
951
+
952
+ segments = remainder.sub(%r{\A/}, "").split("/")
953
+
954
+ case segments.length
955
+ when 1
956
+ slug_candidate(segments.first)
957
+ when 2
958
+ # Could be /<mode>/<slug>. Only treat second segment as the slug
959
+ # if the first is a configured mode.
960
+ Markdowndocs.config.modes.include?(segments.first) ? slug_candidate(segments.last) : nil
961
+ else
962
+ nil
963
+ end
964
+ end
965
+
966
+ def slug_candidate(segment)
967
+ return nil if segment.blank?
968
+ return nil if segment.include?("..") || segment.include?("/")
969
+ segment
970
+ end
971
+ end
972
+ end
973
+ ```
974
+
975
+ The `markdowndocs.root_path`, `markdowndocs.doc_path(slug:)`, and `markdowndocs.scoped_doc_path(mode:, slug:)` helpers are generated automatically from the engine routes added in Task 6 (`as: :doc` and `as: :scoped_doc`). Verify by running `bundle exec rails routes -g markdowndocs` if needed.
976
+
977
+ - [ ] **Step 4: Run tests, verify pass**
978
+
979
+ Run: `bundle exec rspec spec/requests/markdowndocs/preferences_spec.rb`
980
+ Expected: PASS for all eight examples.
981
+
982
+ Run the full request suite to catch regressions:
983
+
984
+ Run: `bundle exec rspec spec/requests`
985
+ Expected: All pass.
986
+
987
+ - [ ] **Step 5: Commit**
988
+
989
+ ```bash
990
+ git add app/controllers/markdowndocs/preferences_controller.rb \
991
+ spec/requests/markdowndocs/preferences_spec.rb
992
+ git commit -m "feat: PreferencesController does smart-navigation on mode toggle"
993
+ ```
994
+
995
+ ---
996
+
997
+ ## Task 8: Mode switcher partial passes current_path
998
+
999
+ **Files:**
1000
+
1001
+ - Modify: `app/views/markdowndocs/docs/_mode_switcher.html.erb`
1002
+ - Test: `spec/requests/markdowndocs/docs_spec.rb`
1003
+
1004
+ The mode switcher form must include `current_path` as a hidden field so the preferences controller can compute the smart-nav target. The value is `request.fullpath` (server-rendered, no JS required).
1005
+
1006
+ - [ ] **Step 1: Locate the form-builder block in the partial**
1007
+
1008
+ The partial at `app/views/markdowndocs/docs/_mode_switcher.html.erb` contains a per-mode `form_with` block. At time of writing (after the issue #20 fix in v0.6.1), the relevant chunk is at lines 37-43:
1009
+
1010
+ ```erb
1011
+ <%= form_with(
1012
+ url: markdowndocs.preference_path,
1013
+ method: :patch,
1014
+ data: {
1015
+ turbo_action: "replace"
1016
+ }
1017
+ ) do |f| %>
1018
+ <%= f.hidden_field :mode, value: mode %>
1019
+ ```
1020
+
1021
+ The new `current_path` hidden field goes immediately AFTER the `f.hidden_field :mode, ...` line — i.e., before the `<button type="submit">` that follows. If the line numbers have shifted since this plan was written, find the same anchor (`f.hidden_field :mode`) and insert immediately after.
1022
+
1023
+ - [ ] **Step 2: Write a failing test verifying the field is present**
1024
+
1025
+ Add to `spec/requests/markdowndocs/docs_spec.rb` (in the `describe "GET /docs/:slug"` block):
1026
+
1027
+ ```ruby
1028
+ it "renders the mode switcher with current_path as a hidden field" do
1029
+ get "/docs/welcome"
1030
+ expect(response.body).to include('name="current_path"')
1031
+ expect(response.body).to include('value="/docs/welcome"')
1032
+ end
1033
+
1034
+ it "renders the mode switcher with current_path on a mode-scoped doc" do
1035
+ get "/docs/technical/architecture"
1036
+ expect(response.body).to include('name="current_path"')
1037
+ expect(response.body).to include('value="/docs/technical/architecture"')
1038
+ end
1039
+ ```
1040
+
1041
+ - [ ] **Step 3: Run failing test**
1042
+
1043
+ Run: `bundle exec rspec spec/requests/markdowndocs/docs_spec.rb -e "current_path"`
1044
+ Expected: FAIL — `current_path` field not yet present.
1045
+
1046
+ - [ ] **Step 4: Add the hidden field to the partial**
1047
+
1048
+ Open `app/views/markdowndocs/docs/_mode_switcher.html.erb` and find the `<%= form_with %>` (or `form_tag`) block that posts to the preferences endpoint. Inside it, add:
1049
+
1050
+ ```erb
1051
+ <%= hidden_field_tag :current_path, request.fullpath %>
1052
+ ```
1053
+
1054
+ Place it near the existing hidden mode/value fields. If the partial uses raw `<form>` HTML rather than Rails form helpers, add a corresponding `<input type="hidden" name="current_path" value="<%= request.fullpath %>">` line.
1055
+
1056
+ - [ ] **Step 5: Run tests, verify pass**
1057
+
1058
+ Run: `bundle exec rspec spec/requests/markdowndocs/docs_spec.rb -e "current_path"`
1059
+ Expected: PASS.
1060
+
1061
+ Then run the full request spec:
1062
+
1063
+ Run: `bundle exec rspec spec/requests/markdowndocs/docs_spec.rb`
1064
+ Expected: All pass. The pre-existing duplicate-id test still passes (we did not add an id attribute).
1065
+
1066
+ - [ ] **Step 6: Commit**
1067
+
1068
+ ```bash
1069
+ git add app/views/markdowndocs/docs/_mode_switcher.html.erb \
1070
+ spec/requests/markdowndocs/docs_spec.rb
1071
+ git commit -m "feat: mode switcher emits current_path for smart navigation"
1072
+ ```
1073
+
1074
+ ---
1075
+
1076
+ ## Task 9: Deprecate `audience:` frontmatter with a one-shot warning
1077
+
1078
+ **Files:**
1079
+
1080
+ - Modify: `lib/markdowndocs.rb` (add `deprecator` helper)
1081
+ - Modify: `lib/markdowndocs/configuration.rb` (add `audience_deprecation_emitted` Set)
1082
+ - Modify: `app/models/markdowndocs/documentation.rb` (emit warning when frontmatter `audience:` is present)
1083
+ - Test: `spec/models/markdowndocs/documentation_spec.rb`
1084
+
1085
+ The warning fires once per file path per process boot. Tracking lives on `Markdowndocs.config` so `reset_configuration!` (called in test teardown) clears it between tests.
1086
+
1087
+ - [ ] **Step 1: Write failing tests**
1088
+
1089
+ Add to `spec/models/markdowndocs/documentation_spec.rb` (new top-level block):
1090
+
1091
+ ```ruby
1092
+ describe "audience: frontmatter deprecation" do
1093
+ before { Markdowndocs.reset_configuration! }
1094
+
1095
+ it "emits a deprecation warning the first time a doc with audience: frontmatter is read" do
1096
+ warning_text = nil
1097
+ Markdowndocs.deprecator.behavior = ->(message, *) { warning_text = message }
1098
+
1099
+ doc = described_class.find_by_slug("admin-reference", mode: "technical")
1100
+ doc.audience # force evaluation
1101
+
1102
+ expect(warning_text).to be_present
1103
+ expect(warning_text).to include("admin-reference.md")
1104
+ expect(warning_text).to include("audience:")
1105
+ end
1106
+
1107
+ it "emits the warning at most once per file path" do
1108
+ call_count = 0
1109
+ Markdowndocs.deprecator.behavior = ->(_message, *) { call_count += 1 }
1110
+
1111
+ 3.times do
1112
+ doc = described_class.find_by_slug("admin-reference", mode: "technical")
1113
+ doc.audience
1114
+ end
1115
+
1116
+ expect(call_count).to eq(1)
1117
+ end
1118
+
1119
+ it "does NOT emit a warning for path-derived audience (no frontmatter)" do
1120
+ call_count = 0
1121
+ Markdowndocs.deprecator.behavior = ->(_message, *) { call_count += 1 }
1122
+
1123
+ doc = described_class.find_by_slug("architecture", mode: "technical")
1124
+ doc.audience
1125
+
1126
+ expect(call_count).to eq(0)
1127
+ end
1128
+ end
1129
+ ```
1130
+
1131
+ - [ ] **Step 2: Run failing tests**
1132
+
1133
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "audience: frontmatter deprecation"`
1134
+ Expected: FAIL — `Markdowndocs.deprecator` undefined.
1135
+
1136
+ - [ ] **Step 3: Add the deprecator and tracking Set**
1137
+
1138
+ Modify `lib/markdowndocs.rb`. Replace contents:
1139
+
1140
+ ```ruby
1141
+ # frozen_string_literal: true
1142
+
1143
+ require_relative "markdowndocs/version"
1144
+ require_relative "markdowndocs/configuration"
1145
+ require_relative "markdowndocs/engine"
1146
+
1147
+ module Markdowndocs
1148
+ class Error < StandardError; end
1149
+
1150
+ class << self
1151
+ def configuration
1152
+ @configuration ||= Configuration.new
1153
+ end
1154
+
1155
+ alias_method :config, :configuration
1156
+
1157
+ def configure
1158
+ yield(configuration)
1159
+ end
1160
+
1161
+ def reset_configuration!
1162
+ @configuration = Configuration.new
1163
+ end
1164
+
1165
+ # Deprecation channel for the gem. Hosts can attach custom behaviors
1166
+ # (e.g., raise in test, silence in production) via:
1167
+ # Markdowndocs.deprecator.behavior = :log
1168
+ def deprecator
1169
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0.0", "Markdowndocs")
1170
+ end
1171
+ end
1172
+ end
1173
+ ```
1174
+
1175
+ Modify `lib/markdowndocs/configuration.rb`. Add `audience_deprecation_emitted` alongside the `non_mode_subdirs_warned` attribute added in Task 2. The full attribute set should look like:
1176
+
1177
+ ```ruby
1178
+ # frozen_string_literal: true
1179
+
1180
+ require "set"
1181
+
1182
+ module Markdowndocs
1183
+ class Configuration
1184
+ attr_accessor :docs_path, :categories, :modes, :default_mode,
1185
+ :markdown_options, :rouge_theme, :cache_expiry,
1186
+ :user_mode_resolver, :user_mode_saver, :search_enabled,
1187
+ :layout, :non_mode_subdirs_warned
1188
+ attr_reader :audience_deprecation_emitted
1189
+
1190
+ def initialize
1191
+ @docs_path = nil
1192
+ @categories = {}
1193
+ @modes = %w[guide technical]
1194
+ @default_mode = "guide"
1195
+ @markdown_options = default_markdown_options
1196
+ @rouge_theme = "github"
1197
+ @cache_expiry = 1.hour
1198
+ @user_mode_resolver = nil
1199
+ @user_mode_saver = nil
1200
+ @search_enabled = false
1201
+ @layout = "markdowndocs/application"
1202
+ @non_mode_subdirs_warned = Set.new
1203
+ @audience_deprecation_emitted = Set.new
1204
+ end
1205
+
1206
+ def resolved_docs_path
1207
+ @docs_path || Rails.root.join("app", "docs")
1208
+ end
1209
+
1210
+ private
1211
+
1212
+ def default_markdown_options
1213
+ {
1214
+ parse: {
1215
+ smart: true,
1216
+ default_info_string: nil
1217
+ },
1218
+ render: {
1219
+ unsafe: false,
1220
+ github_pre_lang: true,
1221
+ full_info_string: true,
1222
+ hardbreaks: false,
1223
+ sourcepos: false,
1224
+ escaped_char_spans: true
1225
+ },
1226
+ extension: {
1227
+ strikethrough: true,
1228
+ tagfilter: true,
1229
+ table: true,
1230
+ autolink: true,
1231
+ tasklist: true,
1232
+ footnotes: true,
1233
+ description_lists: true,
1234
+ front_matter_delimiter: "---",
1235
+ shortcodes: false,
1236
+ header_ids: ""
1237
+ }
1238
+ }
1239
+ end
1240
+ end
1241
+ end
1242
+ ```
1243
+
1244
+ - [ ] **Step 4: Emit the warning from Documentation#audience**
1245
+
1246
+ Modify `app/models/markdowndocs/documentation.rb`. Replace the `audience` method:
1247
+
1248
+ ```ruby
1249
+ def audience
1250
+ @audience ||= begin
1251
+ parsed = parse_frontmatter
1252
+ raw = parsed[:frontmatter]["audience"]
1253
+
1254
+ if raw
1255
+ emit_audience_deprecation_warning_once
1256
+ end
1257
+
1258
+ case raw
1259
+ when Array then raw.map(&:to_s)
1260
+ when String then [raw]
1261
+ when nil
1262
+ scope = audience_from_path
1263
+ scope ? [scope] : Markdowndocs.config.modes.dup
1264
+ else Markdowndocs.config.modes.dup
1265
+ end
1266
+ end
1267
+ end
1268
+ ```
1269
+
1270
+ Add the helper in the private section:
1271
+
1272
+ ```ruby
1273
+ def emit_audience_deprecation_warning_once
1274
+ path_str = file_path.to_s
1275
+ emitted = Markdowndocs.config.audience_deprecation_emitted
1276
+ return if emitted.include?(path_str)
1277
+
1278
+ emitted << path_str
1279
+
1280
+ suggested_target = suggest_migration_target
1281
+ Markdowndocs.deprecator.warn(
1282
+ "`audience:` frontmatter in #{path_str} is deprecated. " \
1283
+ "#{suggested_target} The `audience:` key will be removed in v1.0.0."
1284
+ )
1285
+ end
1286
+
1287
+ def suggest_migration_target
1288
+ parsed = parse_frontmatter
1289
+ raw = parsed[:frontmatter]["audience"]
1290
+
1291
+ case raw
1292
+ when String
1293
+ "Move the file to #{file_path.dirname.join(raw, file_path.basename)} instead and remove the `audience:` key."
1294
+ when Array
1295
+ if Array(raw).map(&:to_s).sort == Markdowndocs.config.modes.sort
1296
+ "This doc is already declared multi-audience; remove the `audience:` key (root files are visible in every mode)."
1297
+ else
1298
+ modes = Array(raw).map(&:to_s).join(", ")
1299
+ "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)."
1300
+ end
1301
+ else
1302
+ "Move the file into the mode-named subdirectory matching its audience, or leave it at root and remove the key."
1303
+ end
1304
+ end
1305
+ ```
1306
+
1307
+ - [ ] **Step 5: Run tests, verify pass**
1308
+
1309
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb -e "audience: frontmatter deprecation"`
1310
+ Expected: PASS for all three examples.
1311
+
1312
+ Run the full Documentation spec:
1313
+
1314
+ Run: `bundle exec rspec spec/models/markdowndocs/documentation_spec.rb`
1315
+ Expected: All pass. The pre-existing audience-frontmatter tests still pass (deprecation does not change behavior).
1316
+
1317
+ Run the full suite to confirm no regressions:
1318
+
1319
+ Run: `bundle exec rspec`
1320
+ Expected: All pass.
1321
+
1322
+ - [ ] **Step 6: Commit**
1323
+
1324
+ ```bash
1325
+ git add lib/markdowndocs.rb \
1326
+ lib/markdowndocs/configuration.rb \
1327
+ app/models/markdowndocs/documentation.rb \
1328
+ spec/models/markdowndocs/documentation_spec.rb
1329
+ git commit -m "feat: deprecate audience: frontmatter in favor of path-based routing"
1330
+ ```
1331
+
1332
+ ---
1333
+
1334
+ ## Task 10: Update README with the new convention and migration guide
1335
+
1336
+ **Files:**
1337
+
1338
+ - Modify: `README.md`
1339
+
1340
+ - [ ] **Step 1: Update the "Writing Documentation" section**
1341
+
1342
+ Open `README.md` and find the existing "Audience Filtering (whole-document)" subsection. Replace its contents with the new path-based convention. Mark the old frontmatter mechanism as deprecated.
1343
+
1344
+ Locate the "### Audience Filtering (whole-document)" heading and replace through (but not including) the "### Mode Blocks" heading with:
1345
+
1346
+ ```markdown
1347
+ ### Audience Filtering by Filesystem Path
1348
+
1349
+ The recommended way to scope a whole document to a single audience is to
1350
+ place it inside a subdirectory whose name matches an entry in
1351
+ `config.modes`. Files at the docs root are *shared* — visible in every
1352
+ mode.
1353
+
1354
+ ```text
1355
+ app/docs/
1356
+ ├── getting_started.md → shared, visible in every mode
1357
+ ├── billing.md → shared
1358
+ └── technical/
1359
+ ├── architecture.md → technical mode only
1360
+ └── billing.md → technical mode only
1361
+ ```
1362
+
1363
+ URLs follow the filesystem layout: `app/docs/billing.md` is served at
1364
+ `/docs/billing`; `app/docs/technical/billing.md` is served at
1365
+ `/docs/technical/billing`. Both URLs are stable and shareable.
1366
+
1367
+ Subdirectories whose name does not match a configured mode are ignored
1368
+ by document discovery, with a one-line warning at boot.
1369
+
1370
+ ### Audience Filtering by Frontmatter (deprecated)
1371
+
1372
+ The `audience:` frontmatter key from v0.6.0 still works in v0.7.x but is
1373
+ deprecated. A warning is logged the first time each affected file is
1374
+ read. Move the file into the matching mode subdirectory and remove the
1375
+ `audience:` key. See the migration guide below.
1376
+
1377
+ ```yaml
1378
+ audience: technical # deprecated — move to app/docs/technical/
1379
+ audience: [guide, technical] # deprecated — keep at root, drop the key
1380
+ # omit `audience:` # still works for shared docs at root
1381
+ ```
1382
+
1383
+ The `audience:` key is scheduled for removal in v1.0.0.
1384
+
1385
+ ```
1386
+
1387
+ Also update the categories example. Find the `config.categories =` block in the configuration section and update it:
1388
+
1389
+ ```ruby
1390
+ config.categories = {
1391
+ "Getting Started" => %w[welcome quickstart],
1392
+ "Guides" => %w[authentication billing],
1393
+ "Architecture" => %w[technical/architecture technical/billing]
1394
+ }
1395
+ ```
1396
+
1397
+ Add a note immediately after the example:
1398
+
1399
+ > Bare slugs (e.g., `"welcome"`) match files at the docs root.
1400
+ > Path-prefixed slugs (e.g., `"technical/architecture"`) match files
1401
+ > inside the named mode subdirectory. The prefix segment must match
1402
+ > an entry in `config.modes`.
1403
+
1404
+ - [ ] **Step 2: Add a "Migrating from v0.6.x" section**
1405
+
1406
+ Append before the final "## Contributing" section (or wherever the end of the body is) a new top-level section:
1407
+
1408
+ ```markdown
1409
+ ## Migrating from v0.6.x to v0.7.0
1410
+
1411
+ **URL stability.** Every URL from v0.6.x continues to resolve. Hosts
1412
+ that upgrade without moving files see zero URL changes. Path-based
1413
+ routing only introduces *new* URLs (`/docs/<mode>/<slug>`) when you
1414
+ explicitly relocate files into mode subdirectories.
1415
+
1416
+ ### If you don't use `audience:` today
1417
+
1418
+ No action required. Adopt the new convention at your leisure.
1419
+
1420
+ ### If you use `audience: <single-mode>`
1421
+
1422
+ For each affected doc:
1423
+
1424
+ ```diff
1425
+ - app/docs/foo.md
1426
+ - ---
1427
+ - audience: technical
1428
+ - ---
1429
+ + app/docs/technical/foo.md
1430
+ + (no `audience:` key)
1431
+ ```
1432
+
1433
+ The deprecation warning surfaces the suggested target path.
1434
+
1435
+ ### If you use `audience: [guide, technical]`
1436
+
1437
+ The doc is multi-audience — drop the key, the root file is shared:
1438
+
1439
+ ```diff
1440
+ app/docs/foo.md
1441
+ - ---
1442
+ - audience: [guide, technical]
1443
+ - ---
1444
+ + (no `audience:` key)
1445
+ ```
1446
+
1447
+ ### `config.categories` for mode-scoped docs
1448
+
1449
+ Prefix slugs with the mode subdirectory:
1450
+
1451
+ ```diff
1452
+ config.categories = {
1453
+ - "Architecture" => %w[architecture data_model]
1454
+ + "Architecture" => %w[technical/architecture data_model]
1455
+ }
1456
+ ```
1457
+
1458
+ Bare slugs continue to mean "the doc at the root with this name."
1459
+ ```
1460
+
1461
+ - [ ] **Step 3: Commit**
1462
+
1463
+ ```bash
1464
+ git add README.md
1465
+ git commit -m "docs: README updates for path-based audience routing"
1466
+ ```
1467
+
1468
+ ---
1469
+
1470
+ ## Task 11: Add CHANGELOG entry for v0.7.0
1471
+
1472
+ **Files:**
1473
+
1474
+ - Modify: `CHANGELOG.md`
1475
+
1476
+ - [ ] **Step 1: Add the new version section**
1477
+
1478
+ Open `CHANGELOG.md`. Insert a new section above the existing `## [0.6.1] - 2026-05-13` heading:
1479
+
1480
+ ```markdown
1481
+ ## [0.7.0] - 2026-05-15
1482
+
1483
+ ### Added
1484
+
1485
+ - **Path-based audience routing.** A first-level subdirectory of
1486
+ `app/docs/` whose name matches an entry in `config.modes` is now
1487
+ treated as an audience scope. Files inside `app/docs/technical/` are
1488
+ visible only when the current mode is `technical`; files at the root
1489
+ remain shared (visible in every mode). The new convention is the
1490
+ recommended way to scope whole documents and replaces `audience:`
1491
+ frontmatter (see Deprecated below).
1492
+ - **`/docs/:mode/:slug` route.** Mode-scoped documents are served at
1493
+ stable, RESTful URLs (e.g., `/docs/technical/architecture`). The
1494
+ `:mode` segment is constrained to entries in `config.modes`; unknown
1495
+ modes return 404.
1496
+ - **Path-prefixed slugs in `config.categories`.** Slug entries may now
1497
+ include a mode prefix (e.g., `"technical/architecture"`) to attach a
1498
+ mode-scoped doc to a category. Bare slugs continue to match root
1499
+ files. Example:
1500
+
1501
+ config.categories = {
1502
+ "Architecture" => %w[technical/architecture]
1503
+ }
1504
+
1505
+ - **Smart navigation in mode switcher.** Toggling the mode now attempts
1506
+ to navigate to a same-slug document in the target mode's location,
1507
+ falling back to the shared root sibling, then staying put. Sharing
1508
+ links still works because URLs are stable.
1509
+ - **`Markdowndocs.deprecator`** ActiveSupport::Deprecation instance for
1510
+ emitting gem-specific deprecation warnings. Hosts can configure
1511
+ behavior (silence / raise / log) via standard
1512
+ `ActiveSupport::Deprecation` APIs.
1513
+
1514
+ ### Changed
1515
+
1516
+ - `Documentation.all` walks both `app/docs/*.md` and
1517
+ `app/docs/<mode>/*.md` for every configured mode.
1518
+ - `Documentation` instances expose `#path_slug` (the file's path
1519
+ relative to the docs root, sans `.md`).
1520
+ - `Documentation.find_by_slug(slug, mode:)` prefers the mode-scoped
1521
+ file first, then falls back to the root.
1522
+ - `PreferencesController#update` now expects a `current_path` form
1523
+ field (added in `_mode_switcher.html.erb`) and computes the smart-nav
1524
+ target before redirecting. Hosts with custom forms targeting
1525
+ `preference_path` should include `<input type="hidden"
1526
+ name="current_path" value="<%= request.fullpath %>">` to opt into
1527
+ smart navigation. Without it, the controller redirects to the docs
1528
+ index (no behavior loss, just no smart-nav benefit).
1529
+
1530
+ ### Deprecated
1531
+
1532
+ - **`audience:` frontmatter.** Still functional, but emits a one-shot
1533
+ warning per file path per process boot. Will be removed in v1.0.0.
1534
+ Migration: move the file into a matching mode subdirectory (or, for
1535
+ multi-audience docs, drop the key — root files are shared). The
1536
+ warning message includes the suggested target path.
1537
+
1538
+ ### Migration notes
1539
+
1540
+ - See `README.md` ("Migrating from v0.6.x to v0.7.0") for full guidance.
1541
+ - URL stability: every URL from v0.6.x continues to resolve unchanged.
1542
+ - Subdirectories under `app/docs/` whose name doesn't match a
1543
+ configured mode are now ignored (one-line warning at discovery). If
1544
+ you've been using non-mode subdirectories for organization, either
1545
+ flatten them or rename them to match a configured mode.
1546
+
1547
+ ```
1548
+
1549
+ - [ ] **Step 2: Commit**
1550
+
1551
+ ```bash
1552
+ git add CHANGELOG.md
1553
+ git commit -m "docs: changelog entry for v0.7.0"
1554
+ ```
1555
+
1556
+ ---
1557
+
1558
+ ## Task 12: Bump version to 0.7.0 and final verification
1559
+
1560
+ **Files:**
1561
+
1562
+ - Modify: `lib/markdowndocs/version.rb`
1563
+
1564
+ - [ ] **Step 1: Bump the version constant**
1565
+
1566
+ Open `lib/markdowndocs/version.rb`. Replace its contents:
1567
+
1568
+ ```ruby
1569
+ # frozen_string_literal: true
1570
+
1571
+ module Markdowndocs
1572
+ VERSION = "0.7.0"
1573
+ end
1574
+ ```
1575
+
1576
+ - [ ] **Step 2: Run the full test suite and linter**
1577
+
1578
+ Run: `bundle exec rake`
1579
+ Expected: RSpec passes (all green) and `standardrb` passes (no offenses). The default Rake task runs both.
1580
+
1581
+ If `standardrb` flags anything in the new code, fix it inline (typical issues: line length, single vs. double quotes per the project style). Re-run `bundle exec rake` until green.
1582
+
1583
+ - [ ] **Step 3: Commit the version bump**
1584
+
1585
+ ```bash
1586
+ git add lib/markdowndocs/version.rb
1587
+ git commit -m "chore: bump version to 0.7.0"
1588
+ ```
1589
+
1590
+ - [ ] **Step 4: Final manual sanity check**
1591
+
1592
+ This step is **not** automated; run it as a human (or skip if the agent is running headless):
1593
+
1594
+ 1. From the repo root, boot the dummy app and visit the docs in a browser.
1595
+
1596
+ ```bash
1597
+ cd spec/dummy && bin/rails server -p 3000 &
1598
+ sleep 2
1599
+ open http://localhost:3000/docs
1600
+ ```
1601
+
1602
+ 2. Verify in the browser:
1603
+ - `/docs` shows the index with the configured categories. The "Architecture" category should appear when in technical mode and contain "System Architecture" and "Billing Internals".
1604
+ - `/docs/billing` shows the shared billing doc.
1605
+ - `/docs/technical/billing` shows the technical billing doc (different content).
1606
+ - Toggle the mode switcher from `/docs/billing` to technical — URL becomes `/docs/technical/billing`, content swaps.
1607
+ - Toggle back to guide — URL returns to `/docs/billing`.
1608
+ - Visit `/docs/technical/architecture`, toggle to guide — URL stays at `/docs/technical/architecture` (no shared sibling).
1609
+ - Visit `/docs/notamode/foo` — returns 404.
1610
+
1611
+ 3. Stop the server: `kill %1` (or however you backgrounded it).
1612
+
1613
+ If anything visual looks off, capture the issue and either fix in a follow-up commit or roll back the version bump and address before tagging.
1614
+
1615
+ ---
1616
+
1617
+ ## Done
1618
+
1619
+ After Task 12, the v0.7.0 release is implementation-complete. The next step (outside this plan) is the gem release process — covered by the `.claude/skills/gem-release/` skill: tag, build, push to RubyGems. That's a separate operation and is not part of this plan.