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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +95 -14
- data/app/controllers/markdowndocs/docs_controller.rb +29 -9
- data/app/controllers/markdowndocs/preferences_controller.rb +62 -3
- data/app/helpers/markdowndocs/docs_helper.rb +6 -2
- data/app/models/markdowndocs/documentation.rb +136 -20
- data/app/services/markdowndocs/markdown_renderer.rb +50 -19
- 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 +11 -0
- data/docs/superpowers/plans/2026-05-15-path-based-audience-routing.md +1619 -0
- data/docs/superpowers/specs/2026-05-15-path-based-audience-routing-design.md +311 -0
- data/lib/markdowndocs/configuration.rb +9 -1
- data/lib/markdowndocs/version.rb +1 -1
- data/lib/markdowndocs.rb +7 -0
- metadata +3 -1
|
@@ -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.
|