markdowndocs 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 42524ce732ca270ae79839b4d26522b30fab9e088793df8c62e0c20ac0c5b1c3
4
+ data.tar.gz: c999713e3b504d54838db523a0f708110f9f28a049efd1568dacfbafa3b9d1c6
5
+ SHA512:
6
+ metadata.gz: 66583f10a3cf8c80eb651a0d3b0cbd99f86604313a36e91713d90569ee1ac1eb4889782969fcac16ad91dbb067a2a3853a20b5f99ddb7a7767529b7a489d7216
7
+ data.tar.gz: 705f2632e8a1429571b483d6a5ef6285af1a6380f428743b8adb5181342e2854027b327eceb3df001be4a6af3b932f713c2b6e48a5c81870569d6ae2dcfdb1fd
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-02-20
9
+
10
+ ### Added
11
+
12
+ - Mountable Rails engine that serves markdown files as a browsable documentation site
13
+ - GitHub Flavored Markdown rendering via Commonmarker (tables, task lists, strikethrough, autolinks, footnotes)
14
+ - Syntax highlighting via Rouge with configurable theme
15
+ - YAML front matter support for per-document title, description, and mode availability
16
+ - Mode-based content filtering using HTML comment blocks
17
+ - Category organization for the index page
18
+ - Auto-generated table of contents from H2/H3 headings with anchor links
19
+ - Breadcrumb navigation and related-documents sidebar
20
+ - File-mtime-based cache invalidation using Rails.cache
21
+ - HTML sanitization via rails-html-sanitizer
22
+ - Directory traversal prevention via slug validation
23
+ - i18n support for all UI strings
24
+ - Install generator (`rails generate markdowndocs:install`)
25
+
26
+ [0.1.0]: https://github.com/dschmura/markdowndocs/releases/tag/v0.1.0
data/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # Markdowndocs
2
+
3
+ A drop-in mountable Rails engine that turns a folder of markdown files into a browsable documentation site with syntax highlighting, category grouping, and mode-based content filtering.
4
+
5
+ ## Features
6
+
7
+ - **GitHub Flavored Markdown** — Tables, task lists, strikethrough, footnotes, autolinks, and more via [Commonmarker](https://github.com/gjtorikian/commonmarker)
8
+ - **Syntax highlighting** — Code blocks highlighted with [Rouge](https://github.com/rouge-ruby/rouge) (configurable theme)
9
+ - **Category organization** — Group docs into named categories for the index page
10
+ - **Mode-based content filtering** — Show different content to different audiences (e.g., "User Guide" vs "Developer Guide")
11
+ - **Table of contents** — Auto-generated from H2/H3 headings with anchor links
12
+ - **YAML front matter** — Set title, description, and mode availability per document
13
+ - **Breadcrumb navigation** — Category-aware breadcrumbs on each doc page
14
+ - **Related documents** — Sidebar links to other docs in the same category
15
+ - **Responsive design** — Tailwind CSS with mobile support
16
+ - **Security** — HTML sanitization, slug validation, directory traversal prevention
17
+ - **Caching** — Rendered markdown is cached with file-mtime-based invalidation
18
+ - **i18n support** — All UI strings are translatable
19
+
20
+ ## Requirements
21
+
22
+ - Ruby >= 3.2
23
+ - Rails >= 7.1
24
+
25
+ ## Installation
26
+
27
+ Add the gem to your application's Gemfile:
28
+
29
+ ```ruby
30
+ gem "markdowndocs"
31
+ ```
32
+
33
+ Then run:
34
+
35
+ ```bash
36
+ bundle install
37
+ rails generate markdowndocs:install
38
+ ```
39
+
40
+ The generator will:
41
+
42
+ 1. Create `config/initializers/markdowndocs.rb` with default configuration
43
+ 2. Create the `app/docs/` directory for your markdown files
44
+ 3. Mount the engine at `/docs` in your routes
45
+
46
+ ## Configuration
47
+
48
+ Edit `config/initializers/markdowndocs.rb` to customize behavior:
49
+
50
+ ```ruby
51
+ Markdowndocs.configure do |config|
52
+ # Path to markdown files (default: Rails.root.join("app/docs"))
53
+ # config.docs_path = Rails.root.join("app", "docs")
54
+
55
+ # Category → slug mapping
56
+ config.categories = {
57
+ "Getting Started" => %w[welcome quickstart],
58
+ "Guides" => %w[authentication deployment],
59
+ "Reference" => %w[api-reference configuration]
60
+ }
61
+
62
+ # Available documentation modes (default: %w[guide technical])
63
+ # config.modes = %w[guide technical]
64
+
65
+ # Default mode (default: "guide")
66
+ # config.default_mode = "guide"
67
+
68
+ # Rouge syntax highlighting theme (default: "github")
69
+ # config.rouge_theme = "github"
70
+
71
+ # Cache expiry for rendered markdown (default: 1.hour)
72
+ # config.cache_expiry = 1.hour
73
+
74
+ # Optional: Resolve current user's mode preference from database
75
+ # config.user_mode_resolver = ->(controller) {
76
+ # controller.send(:current_user)&.preferences&.docs_mode
77
+ # }
78
+
79
+ # Optional: Save user's mode preference to database
80
+ # config.user_mode_saver = ->(controller, mode) {
81
+ # controller.send(:current_user)&.preferences&.update!(docs_mode: mode)
82
+ # }
83
+ end
84
+ ```
85
+
86
+ ### Configuration Options
87
+
88
+ | Option | Default | Description |
89
+ | -------------------- | ------------------------------ | -------------------------------------------------------------- |
90
+ | `docs_path` | `Rails.root.join("app/docs")` | Directory containing your markdown files |
91
+ | `categories` | `{}` | Maps category names to arrays of document slugs |
92
+ | `modes` | `%w[guide technical]` | Available viewing modes |
93
+ | `default_mode` | `"guide"` | Mode shown by default |
94
+ | `rouge_theme` | `"github"` | Syntax highlighting color scheme |
95
+ | `cache_expiry` | `1.hour` | Cache duration for rendered markdown |
96
+ | `user_mode_resolver` | `nil` | Lambda to load a user's mode preference from the database |
97
+ | `user_mode_saver` | `nil` | Lambda to persist a user's mode preference to the database |
98
+
99
+ ## Writing Documentation
100
+
101
+ Create markdown files in `app/docs/`. The filename (without `.md`) becomes the URL slug — `app/docs/quickstart.md` is served at `/docs/quickstart`.
102
+
103
+ ### Front Matter
104
+
105
+ Add optional YAML front matter to set metadata:
106
+
107
+ ```markdown
108
+ ---
109
+ title: "Quick Start Guide"
110
+ description: "Get up and running in five minutes"
111
+ modes:
112
+ - guide
113
+ - technical
114
+ default_mode: guide
115
+ ---
116
+
117
+ # Quick Start Guide
118
+
119
+ Your content here...
120
+ ```
121
+
122
+ If front matter is omitted, the title is extracted from the first H1 heading and the description from the first paragraph.
123
+
124
+ ### Mode Blocks
125
+
126
+ Use HTML comments to show content only in specific modes:
127
+
128
+ ```markdown
129
+ ## Setup
130
+
131
+ This paragraph appears in all modes.
132
+
133
+ <!-- mode: guide -->
134
+ Follow these steps to get started:
135
+ 1. Click the "Install" button
136
+ 2. Follow the on-screen prompts
137
+ <!-- /mode -->
138
+
139
+ <!-- mode: technical -->
140
+ Add the dependency to your Gemfile and run the install generator:
141
+ \`\`\`bash
142
+ bundle add markdowndocs
143
+ rails generate markdowndocs:install
144
+ \`\`\`
145
+ <!-- /mode -->
146
+ ```
147
+
148
+ ### Syntax Highlighting
149
+
150
+ Code blocks are automatically syntax-highlighted. Specify the language after the opening fence:
151
+
152
+ ````markdown
153
+ ```ruby
154
+ def hello
155
+ puts "Hello, world!"
156
+ end
157
+ ```
158
+
159
+ ```javascript
160
+ function hello() {
161
+ console.log("Hello, world!");
162
+ }
163
+ ```
164
+ ````
165
+
166
+ Supported languages include Ruby, JavaScript, Python, Bash, YAML, JSON, HTML, CSS, SQL, and [many more](https://github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers).
167
+
168
+ ### Categories
169
+
170
+ To organize docs on the index page, map category names to slugs in your configuration:
171
+
172
+ ```ruby
173
+ config.categories = {
174
+ "Getting Started" => %w[welcome quickstart],
175
+ "Guides" => %w[authentication deployment]
176
+ }
177
+ ```
178
+
179
+ Documents not assigned to a category will appear in an "Uncategorized" group.
180
+
181
+ ## Rendering Pipeline
182
+
183
+ When a documentation page is requested, the markdown goes through these stages:
184
+
185
+ 1. **File reading** — Load raw markdown from `app/docs/`
186
+ 2. **Mode filtering** — Strip content blocks not matching the current viewing mode
187
+ 3. **Commonmarker parsing** — Parse with GFM extensions (tables, strikethrough, autolinks, footnotes, task lists)
188
+ 4. **Syntax highlighting** — Apply Rouge highlighting to fenced code blocks
189
+ 5. **HTML sanitization** — Whitelist-based sanitization strips dangerous tags and attributes
190
+ 6. **Heading anchors** — Inject `id` attributes on H2/H3 headings for TOC linking
191
+ 7. **Caching** — Store rendered HTML keyed by file path, mtime, and mode
192
+
193
+ ## Caching
194
+
195
+ Rendered HTML is cached using `Rails.cache` with a composite cache key based on file path, file modification time, and viewing mode. Cache is automatically invalidated when file content changes.
196
+
197
+ To manually clear documentation caches:
198
+
199
+ ```ruby
200
+ # In Rails console
201
+ Rails.cache.clear
202
+
203
+ # Or delete matched keys
204
+ Rails.cache.delete_matched("markdown_*")
205
+ ```
206
+
207
+ The default cache expiry is 1 hour, configurable via `config.cache_expiry`.
208
+
209
+ ## Security
210
+
211
+ ### Directory Traversal Prevention
212
+
213
+ Slugs are validated to contain only alphanumeric characters, hyphens, and underscores. Patterns like `../` and `/` are rejected, ensuring only files within `app/docs/` are accessible.
214
+
215
+ ### HTML Sanitization
216
+
217
+ All rendered HTML is passed through a whitelist-based sanitizer. Safe tags (headings, paragraphs, code blocks, lists, links, images, tables) are allowed. Script tags, event handlers, and dangerous attributes are stripped.
218
+
219
+ ### YAML Parsing
220
+
221
+ Front matter is parsed with `YAML.safe_load` to prevent code execution.
222
+
223
+ ## Best Practices
224
+
225
+ 1. **Start with H1** — Every document should have exactly one H1 heading at the top
226
+ 2. **Write descriptive first paragraphs** — The first paragraph becomes the card description on the index page
227
+ 3. **Use meaningful filenames** — The filename becomes the URL slug; use kebab-case (e.g., `api-reference.md`)
228
+ 4. **Include code examples** — Use fenced code blocks with a language specifier for syntax highlighting
229
+ 5. **Link between docs** — Reference other docs with relative links: `[See authentication](/docs/authentication)`
230
+ 6. **Keep files focused** — Break large topics into multiple documents
231
+ 7. **Use sequential headings** — Don't skip levels (e.g., H1 to H3); this ensures proper TOC generation
232
+
233
+ ## Troubleshooting
234
+
235
+ ### Document Not Appearing
236
+
237
+ 1. Check the filename matches the slug in your category mapping
238
+ 2. Verify the file has a `.md` extension
239
+ 3. Ensure the file is in the `app/docs/` directory
240
+ 4. Restart the server if you modified the initializer
241
+
242
+ ### Syntax Highlighting Not Working
243
+
244
+ 1. Verify the code fence has a language specified (e.g., `` ```ruby ``)
245
+ 2. Check the Rouge theme is configured in the initializer
246
+ 3. Clear the cache: `Rails.cache.clear`
247
+
248
+ ### 404 Errors
249
+
250
+ 1. Verify the slug matches the filename (use kebab-case)
251
+ 2. Check the file exists in `app/docs/`
252
+ 3. Look for typos in the slug or filename
253
+
254
+ ## Development
255
+
256
+ After checking out the repo, run `bin/setup` to install dependencies. Then run the tests:
257
+
258
+ ```bash
259
+ bundle exec rspec
260
+ ```
261
+
262
+ ## Releasing
263
+
264
+ 1. Update `CHANGELOG.md` with a new `## [x.y.z] - YYYY-MM-DD` section and add a comparison link at the bottom.
265
+
266
+ 2. Bump the version in `lib/markdowndocs/version.rb`:
267
+
268
+ ```ruby
269
+ module Markdowndocs
270
+ VERSION = "x.y.z"
271
+ end
272
+ ```
273
+
274
+ 3. Commit and tag the release:
275
+
276
+ ```bash
277
+ git add lib/markdowndocs/version.rb CHANGELOG.md
278
+ git commit -m "Release vx.y.z"
279
+ git tag vx.y.z
280
+ git push origin main --tags
281
+ ```
282
+
283
+ Pushing the tag triggers the GitHub Actions release workflow, which builds and publishes the gem to RubyGems automatically.
284
+
285
+ ## Contributing
286
+
287
+ Bug reports and pull requests are welcome on GitHub at [github.com/dschmura/markdowndocs](https://github.com/dschmura/markdowndocs).
288
+
289
+ ## License
290
+
291
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
@@ -0,0 +1,58 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Documentation Mode Controller
5
+ *
6
+ * Handles localStorage persistence for guest users and provides
7
+ * optimistic UI updates for the documentation mode switcher.
8
+ *
9
+ * For authenticated users, the preference is stored in the database
10
+ * via the PreferencesController. For guests, we use localStorage
11
+ * as a fallback to persist their preference across sessions.
12
+ */
13
+ export default class extends Controller {
14
+ static values = {
15
+ current: { type: String, default: "guide" }
16
+ }
17
+
18
+ static STORAGE_KEY = "markdowndocs_mode"
19
+
20
+ connect() {
21
+ if (!this.isAuthenticated()) {
22
+ this.restoreGuestMode()
23
+ }
24
+ }
25
+
26
+ isAuthenticated() {
27
+ const meta = document.querySelector('meta[name="user-authenticated"]')
28
+ return meta?.content === "true"
29
+ }
30
+
31
+ restoreGuestMode() {
32
+ try {
33
+ const savedMode = localStorage.getItem(this.constructor.STORAGE_KEY)
34
+
35
+ if (savedMode && savedMode !== this.currentValue) {
36
+ const url = new URL(window.location)
37
+ url.searchParams.set("mode", savedMode)
38
+ window.location.replace(url)
39
+ }
40
+ } catch (e) {
41
+ console.debug("localStorage unavailable for docs mode persistence")
42
+ }
43
+ }
44
+
45
+ saveGuestMode(mode) {
46
+ try {
47
+ localStorage.setItem(this.constructor.STORAGE_KEY, mode)
48
+ } catch (e) {
49
+ console.debug("localStorage unavailable for docs mode persistence")
50
+ }
51
+ }
52
+
53
+ currentValueChanged() {
54
+ if (!this.isAuthenticated()) {
55
+ this.saveGuestMode(this.currentValue)
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdowndocs
4
+ class ApplicationController < ::ApplicationController
5
+ protect_from_forgery with: :exception
6
+
7
+ # Make host app route helpers available in engine views
8
+ # (needed because isolate_namespace blocks host helpers by default)
9
+ helper Rails.application.routes.url_helpers
10
+
11
+ # Support Rails 8 built-in authentication (allow_unauthenticated_access)
12
+ # without requiring it — works with any auth system or none at all
13
+ if respond_to?(:allow_unauthenticated_access)
14
+ allow_unauthenticated_access
15
+ end
16
+
17
+ # Resume session if the host app supports it (Rails 8 auth)
18
+ before_action :resume_session, if: -> { respond_to?(:resume_session, true) }
19
+ end
20
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdowndocs
4
+ class DocsController < ApplicationController
5
+ before_action :validate_slug, only: :show
6
+ before_action :set_docs_mode
7
+ helper Markdowndocs::DocsHelper
8
+
9
+ SAFE_SLUG_PATTERN = /\A[a-zA-Z0-9_-]+\z/
10
+
11
+ def index
12
+ @docs_by_category = Documentation.grouped_by_category
13
+ end
14
+
15
+ def show
16
+ @doc = Documentation.find_by_slug(params[:slug])
17
+
18
+ if @doc.nil?
19
+ render_not_found
20
+ return
21
+ end
22
+
23
+ rendered_html = MarkdownRenderer.render(
24
+ @doc.content,
25
+ cache_key: @doc.cache_key,
26
+ mode: @docs_mode
27
+ )
28
+ @rendered_content = helpers.add_heading_anchors(rendered_html)
29
+ @related_docs = Documentation.by_category(@doc.category).reject { |d| d.slug == @doc.slug }
30
+ @available_modes = @doc.available_modes
31
+ @toc_items = helpers.generate_table_of_contents(@rendered_content)
32
+ end
33
+
34
+ private
35
+
36
+ def validate_slug
37
+ slug = params[:slug].to_s
38
+
39
+ unless slug.match?(SAFE_SLUG_PATTERN)
40
+ render_not_found
41
+ end
42
+ end
43
+
44
+ def render_not_found
45
+ file_404 = Rails.public_path.join("404.html")
46
+ if file_404.exist?
47
+ render file: file_404, status: :not_found, layout: false
48
+ else
49
+ head :not_found
50
+ end
51
+ end
52
+
53
+ def set_docs_mode
54
+ @docs_mode = determine_docs_mode
55
+ end
56
+
57
+ def determine_docs_mode
58
+ mode = params[:mode] ||
59
+ resolve_user_mode ||
60
+ cookies[:markdowndocs_mode] ||
61
+ Markdowndocs.config.default_mode
62
+
63
+ valid_modes = Markdowndocs.config.modes
64
+ valid_modes.include?(mode) ? mode : Markdowndocs.config.default_mode
65
+ end
66
+
67
+ def resolve_user_mode
68
+ resolver = Markdowndocs.config.user_mode_resolver
69
+ return nil unless resolver.respond_to?(:call)
70
+
71
+ resolver.call(self)
72
+ rescue
73
+ nil
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdowndocs
4
+ class PreferencesController < ApplicationController
5
+ def update
6
+ mode = params[:mode].to_s
7
+
8
+ unless Markdowndocs.config.modes.include?(mode)
9
+ head :unprocessable_entity
10
+ return
11
+ end
12
+
13
+ # Save to database via host app's lambda (if configured)
14
+ saver = Markdowndocs.config.user_mode_saver
15
+ if saver.respond_to?(:call)
16
+ begin
17
+ saver.call(self, mode)
18
+ rescue => e
19
+ Rails.logger.warn("Markdowndocs: user_mode_saver failed: #{e.message}")
20
+ end
21
+ end
22
+
23
+ # Always set cookie as fallback
24
+ cookies[:markdowndocs_mode] = {
25
+ value: mode,
26
+ expires: 1.year.from_now,
27
+ httponly: true
28
+ }
29
+
30
+ redirect_back(fallback_location: markdowndocs.root_path, status: :see_other)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdowndocs
4
+ module DocsHelper
5
+ def generate_table_of_contents(html)
6
+ return [] if html.blank?
7
+
8
+ doc = Nokogiri::HTML.fragment(html)
9
+ toc_items = []
10
+
11
+ doc.css("h2, h3").each do |heading|
12
+ text = heading.text.strip
13
+ next if text.blank?
14
+
15
+ slug = heading["id"].presence || slugify_heading(text)
16
+
17
+ toc_items << {
18
+ text: text,
19
+ slug: slug,
20
+ level: heading.name[1].to_i
21
+ }
22
+ end
23
+
24
+ toc_items
25
+ end
26
+
27
+ def slugify_heading(text)
28
+ text.to_s
29
+ .downcase
30
+ .gsub(/[^\w\s-]/, "")
31
+ .gsub(/\s+/, "-").squeeze("-")
32
+ .gsub(/^-|-$/, "")
33
+ end
34
+
35
+ def add_heading_anchors(html)
36
+ return html if html.blank?
37
+
38
+ doc = Nokogiri::HTML.fragment(html)
39
+
40
+ doc.css("h2, h3").each do |heading|
41
+ text = heading.text.strip
42
+ next if text.blank?
43
+
44
+ unless heading["id"].present?
45
+ slug = slugify_heading(text)
46
+ heading["id"] = slug
47
+ end
48
+ end
49
+
50
+ doc.css("a.anchor").each do |anchor|
51
+ anchor.remove if anchor.text.strip.empty?
52
+ end
53
+
54
+ doc.to_html
55
+ end
56
+
57
+ def markdowndocs_format_breadcrumbs(category, title)
58
+ [
59
+ {name: "Docs", path: markdowndocs.root_path, current: false},
60
+ {name: category, path: nil, current: false},
61
+ {name: title, path: nil, current: true}
62
+ ]
63
+ end
64
+ end
65
+ end