infodocs 0.3.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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/CONTRIBUTING.md +31 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +341 -0
  6. data/Rakefile +10 -0
  7. data/app/controllers/infodocs/application_controller.rb +68 -0
  8. data/app/controllers/infodocs/assets_controller.rb +15 -0
  9. data/app/controllers/infodocs/editor_controller.rb +36 -0
  10. data/app/controllers/infodocs/indexes_controller.rb +22 -0
  11. data/app/controllers/infodocs/pages_controller.rb +48 -0
  12. data/app/controllers/infodocs/raws_controller.rb +13 -0
  13. data/app/controllers/infodocs/searches_controller.rb +18 -0
  14. data/app/controllers/infodocs/tags_controller.rb +11 -0
  15. data/app/controllers/infodocs/trees_controller.rb +40 -0
  16. data/app/helpers/infodocs/application_helper.rb +70 -0
  17. data/app/javascript/infodocs/application.js +74 -0
  18. data/app/javascript/infodocs/controllers/editor_controller.js +135 -0
  19. data/app/javascript/infodocs/controllers/highlight_controller.js +44 -0
  20. data/app/javascript/infodocs/controllers/mermaid_controller.js +25 -0
  21. data/app/javascript/infodocs/controllers/search_controller.js +125 -0
  22. data/app/javascript/infodocs/controllers/sidebar_controller.js +34 -0
  23. data/app/javascript/infodocs/controllers/toc_controller.js +18 -0
  24. data/app/javascript/infodocs/controllers/tree_editor_controller.js +65 -0
  25. data/app/javascript/infodocs/controllers/tree_state_controller.js +37 -0
  26. data/app/javascript/infodocs/vendor/sortable.esm.js +3378 -0
  27. data/app/views/infodocs/_delete_button.html.erb +6 -0
  28. data/app/views/infodocs/_handle.html.erb +5 -0
  29. data/app/views/infodocs/_tree.html.erb +35 -0
  30. data/app/views/infodocs/editor/edit.html.erb +61 -0
  31. data/app/views/infodocs/pages/empty_folder.html.erb +6 -0
  32. data/app/views/infodocs/pages/not_found.html.erb +11 -0
  33. data/app/views/infodocs/pages/show.html.erb +92 -0
  34. data/app/views/infodocs/searches/index.html.erb +20 -0
  35. data/app/views/infodocs/tags/index.html.erb +26 -0
  36. data/app/views/layouts/infodocs/application.html.erb +104 -0
  37. data/config/importmap.rb +6 -0
  38. data/config/routes.rb +26 -0
  39. data/lib/generators/infodocs/install/install_generator.rb +59 -0
  40. data/lib/generators/infodocs/install/templates/getting-started.md.tt +8 -0
  41. data/lib/generators/infodocs/install/templates/initializer.rb.tt +51 -0
  42. data/lib/generators/infodocs/views/views_generator.rb +13 -0
  43. data/lib/infodocs/code_theme.rb +130 -0
  44. data/lib/infodocs/configuration.rb +50 -0
  45. data/lib/infodocs/engine.rb +39 -0
  46. data/lib/infodocs/link_checker.rb +43 -0
  47. data/lib/infodocs/linter.rb +48 -0
  48. data/lib/infodocs/page.rb +124 -0
  49. data/lib/infodocs/rails_lexer.rb +39 -0
  50. data/lib/infodocs/renderer.rb +231 -0
  51. data/lib/infodocs/search.rb +131 -0
  52. data/lib/infodocs/source.rb +35 -0
  53. data/lib/infodocs/tree.rb +215 -0
  54. data/lib/infodocs/tree_editor.rb +179 -0
  55. data/lib/infodocs/url_helper.rb +23 -0
  56. data/lib/infodocs/version.rb +5 -0
  57. data/lib/infodocs.rb +74 -0
  58. data/lib/tasks/infodocs.rake +63 -0
  59. data/sig/infodocs.rbs +4 -0
  60. metadata +192 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 533feb13c1f6b783ea3c35e75c9ddee6eb8aeb75824211ba63b14abbef9ff365
4
+ data.tar.gz: 1fa1dbcbfe09aa223887c25f3531da22c85345554c4c7a82ff9c35f15501d8be
5
+ SHA512:
6
+ metadata.gz: 51b65648f39c3aa0ee255d2f6d69d5260ad1078eef149642a7f49a9bf20934bd3a7186a82259f1b6904c3394bae0bedd38f9cdf7fec67837ade5c66fbdf58e2d
7
+ data.tar.gz: 0746e6d98ee89475c1517ea068320d7b843a3d2a476ee1d0ee69c3c74839b4541bfc2572c0e1a85464c6d1ede421a7e0b7f90015a1aedc41a745ff73e01755a8
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0 - 2026-10-28
4
+
5
+ - `infodocs:lint` content checks; `CONTRIBUTING.md` with a browser checklist
6
+ - "Copy Markdown" button and `/docs/_raw/<page>`
7
+ - "On this page" on small screens, sidebar remembers open folders and scrolls to the current page, search terms highlighted on the target page, SortableJS bundled, `config.mermaid_url`
8
+ - Footnotes, heading anchors, code embeds from files (`file=app/x.rb#L1-20`)
9
+ - Branding (`logo`, `favicon`, `header_links`, `head_html`) and `infodocs_page_path` / `infodocs_page_url` helpers for the host app
10
+ - `redirect_from` frontmatter (301); moving pages in edit mode leaves redirects behind
11
+ - `draft: true` frontmatter (development only) and `config.page_authorize` for per-page visibility
12
+
13
+ ## 0.2.0 - 2026-10-19
14
+
15
+ - Read-only docs browser: tree sidebar (`_tree.yml` ordering), FTS5 search with advanced syntax, Rouge highlighting, Mermaid, auth modes (none/basic/devise/lambda), several named sources
16
+ - Development-only edit mode: split Markdown editor with live preview, create, move/reorder, delete
17
+ - Code palettes (`one`, `github`, `vscode`, `catppuccin`), code block header with copy button, line numbers and highlighted lines
18
+ - GitHub-style alerts (`> [!NOTE]`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`)
19
+ - Images and files next to pages are served by the engine; `infodocs:check_links` rake task
20
+ - Page footer: last updated (git) and optional "Edit on GitHub" (`repo_url`, `repo_branch`)
21
+ - Page tags (frontmatter or `_tree.yml`), sidebar tag filter, tag pages, `tag:` search, tags field in the editor
22
+ - Live search dropdown with history; render cache
23
+ - `llms.txt`, `sitemap.xml`, `infodocs:export` static export
24
+ - 404 page suggests matching pages; print stylesheet; skip-to-content link
25
+ - Fix: duplicate heading `id` attributes, crash on headings with inline code/emphasis
26
+ - CI workflow, `rubygems_mfa_required`
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,31 @@
1
+ # Contributing
2
+
3
+ ```sh
4
+ bundle install
5
+ bundle exec rake test
6
+ ```
7
+
8
+ The tests cover the Ruby side and the rendered HTML. The JavaScript (Stimulus controllers) and the look of the pages are **not** covered by automated tests, so before a release click through the checklist below in a real browser, in a host app with the gem installed (`bin/rails g infodocs:install`).
9
+
10
+ ## Browser checklist
11
+
12
+ Desktop (≥ 1280px) and a phone-sized window (≈ 390px), light and dark mode:
13
+
14
+ - [ ] Sidebar: folders open/close, the state survives a reload, the current page is scrolled into view; on a phone it is a drawer.
15
+ - [ ] Header: logo, links and title look right; search field is usable on a phone.
16
+ - [ ] Search: `⌘K` / `/` focuses the field; typing shows results; arrows + Enter open one; the page marks the matching words; an empty field shows recent searches.
17
+ - [ ] Page: breadcrumbs, tags, last updated, previous/next; "On this page" is collapsible below 1280px and a sticky list above.
18
+ - [ ] Code: highlighting for Ruby/JS, Copy button, line numbers and highlighted lines (`{2,4-6} showLineNumbers`), `file=` embeds.
19
+ - [ ] Mermaid diagram renders and follows dark mode.
20
+ - [ ] Alerts (`> [!NOTE]`), footnotes, heading `#` links, images next to a page.
21
+ - [ ] Copy Markdown puts the page source on the clipboard.
22
+ - [ ] Tags: chips filter the sidebar, `/docs/_tags` and `/docs/_tags/<tag>` work.
23
+ - [ ] Draft pages show only in development (with a badge); `page_authorize` hides pages from the sidebar, search and by URL.
24
+ - [ ] Edit mode (development only): editor toolbar and ⌘S, live preview, Tags field, New page/folder, drag & drop reorder/move (a redirect is left behind), delete with confirmation.
25
+ - [ ] Printing a page hides the sidebar, header and buttons.
26
+
27
+ ## Before a release
28
+
29
+ 1. Update `lib/infodocs/version.rb` and `CHANGELOG.md`.
30
+ 2. `bundle exec rake test`, `bin/rails infodocs:check_links` and `bin/rails infodocs:lint` in a host app.
31
+ 3. `gem build infodocs.gemspec`, inspect the file list, then tag and push.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Jakub Cieślar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,341 @@
1
+ # Infodocs
2
+
3
+ A mountable Rails engine that turns the Markdown files in your repository into a modern documentation site: a sidebar tree, powerful full-text search, syntax highlighting, Mermaid diagrams and a layout that works well on phones. Built for apps using **Tailwind**, **Stimulus** and **importmap**.
4
+
5
+ There is no database and no build step. Your `.md` files are the source of truth, so docs are versioned, reviewed and deployed together with your code.
6
+
7
+ ## Features
8
+
9
+ - **Sidebar tree** built from your folders, with collapsible sections and the current page highlighted.
10
+ - **Explicit ordering** through a small `_tree.yml` per folder (title, order, hidden entries, tags).
11
+ - **Full-text search** powered by SQLite FTS5: ranking, snippets with highlighted matches, phrases, `OR`, exclusions, prefixes and field filters; live results while typing. Diacritics are folded (Polish `ł`, `ą`, `ę`… work).
12
+ - **GitHub-flavoured Markdown**: tables, task lists, strikethrough, autolinks, `> [!NOTE]` style alerts.
13
+ - **Server-side syntax highlighting** with Rouge (Ruby, Rails DSL, JS, and every other Rouge language), your choice of palette, copy button, optional line numbers and highlighted lines.
14
+ - **Mermaid diagrams** from ` ```mermaid ` blocks, loaded lazily only on pages that contain one, dark-mode aware.
15
+ - **Relative links and images**: `[setup](../guides/setup.md#install)` and `![](img/flow.png)` work on GitHub and on the docs site; `infodocs:check_links` finds broken ones.
16
+ - **Tags**: several per page, filter chips in the sidebar, tag pages, `tag:` search.
17
+ - **Table of contents** (h2/h3) with scroll-spy (a collapsible "On this page" on small screens), the sidebar remembers open folders and scrolls to the current page, breadcrumbs, previous/next navigation, last-updated date and an optional "Edit on GitHub" link.
18
+ - **Mobile friendly**: the sidebar becomes a drawer, tap targets are 44px, code and tables scroll horizontally.
19
+ - **Dark mode** through Tailwind's `dark:` variants (follows the system setting).
20
+ - **Access control**: public, HTTP basic auth, Devise (with optional role check) or your own lambda.
21
+ - **Keyboard shortcuts**: `⌘K` / `Ctrl+K` or `/` focuses search.
22
+ - **Multiple source folders** from anywhere in the app (or a git submodule / checkout), mounted under one site with their own URL names and titles.
23
+ - **Edit mode (development only)**: Markdown editor with live preview, create, drag & drop reorder/move and delete of pages and folders, all written to your `.md` files.
24
+ - **`llms.txt`, `sitemap.xml` and static export** for hosting the docs elsewhere.
25
+ - **Live pickup of changes** in development: edit a file, refresh the page.
26
+
27
+ ## Requirements
28
+
29
+ Ruby 3.2+, Rails 7.1+ with `tailwindcss-rails` (Tailwind 4), `importmap-rails` and `stimulus-rails` in the host app. SQLite is used for the search index even if your app uses another database. Devise is only needed for `auth = :devise`.
30
+
31
+ ## Installation
32
+
33
+ Add the gem to your `Gemfile` (not published on RubyGems yet, so use the repository or a local path):
34
+
35
+ ```ruby
36
+ gem "infodocs", github: "jcieslar/infodocs"
37
+ # or: gem "infodocs", path: "../infodocs"
38
+ ```
39
+
40
+ Install and run the generator:
41
+
42
+ ```sh
43
+ bundle install
44
+ bin/rails g infodocs:install
45
+ ```
46
+
47
+ Options: `--path=docs` (folder with your Markdown, relative to the app root) and `--mount=/docs` (URL prefix).
48
+
49
+ The generator:
50
+
51
+ 1. creates `config/initializers/infodocs.rb`,
52
+ 2. mounts the engine in `config/routes.rb`,
53
+ 3. creates the docs folder with `_tree.yml` and a sample page,
54
+ 4. adds `@plugin "@tailwindcss/typography"` and an `@source` line for the engine's views to your Tailwind entry CSS (`app/assets/tailwind/application.css`),
55
+ 5. adds `/tmp/infodocs/` to `.gitignore`.
56
+
57
+ Then start the app and open `/docs`. It is safe to re-run the generator; it will not duplicate the route or CSS lines.
58
+
59
+ > The `@source` line contains the absolute path of the installed gem. Re-run `bin/rails g infodocs:install` after the gem path changes (for example after a version bump with a git or path source).
60
+
61
+ ## Writing docs
62
+
63
+ ```
64
+ docs/
65
+ _tree.yml # optional: title, order, hidden, tags
66
+ index.md # landing page of the folder (index.md or README.md)
67
+ getting-started.md
68
+ guides/
69
+ _tree.yml
70
+ setup.md
71
+ deploy.md
72
+ ```
73
+
74
+ Files and folders starting with `_` or `.` are ignored.
75
+
76
+ `_tree.yml`:
77
+
78
+ ```yaml
79
+ title: Guides # name of the folder in the sidebar
80
+ order: [setup, deploy] # file names without .md, or subfolder names; the rest follows alphabetically
81
+ hidden: [drafts] # reachable by URL, not shown in the sidebar or prev/next
82
+ tags: # optional, see "Tags"
83
+ setup: [guide, rails]
84
+ ```
85
+
86
+ Page title: the `title:` key in YAML frontmatter, otherwise the first `# Heading`, otherwise the file name.
87
+
88
+ A page:
89
+
90
+ ````markdown
91
+ ---
92
+ title: Setup guide
93
+ tags: [guide, rails]
94
+ ---
95
+
96
+ # Setup
97
+
98
+ ## Install
99
+
100
+ ```ruby
101
+ puts "highlighted by Rouge"
102
+ ```
103
+
104
+ ```mermaid
105
+ graph LR
106
+ Markdown --> Infodocs --> Docs
107
+ ```
108
+ ````
109
+
110
+ ### Alerts
111
+
112
+ GitHub-style alerts: put `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` on the first line of a blockquote:
113
+
114
+ ```markdown
115
+ > [!WARNING]
116
+ > Back up the database first.
117
+ ```
118
+
119
+ ### Images and files
120
+
121
+ Relative paths work next to a page: `![Diagram](img/flow.png)`. The engine serves png, jpg, gif, webp, avif, svg, pdf, mp4 and webm from inside the docs directories only (never `_`/`.` entries).
122
+
123
+ ### Code blocks
124
+
125
+ Rouge highlights fenced blocks by language: `ruby`, `rails` (Ruby plus Rails DSL: associations, callbacks such as `after_create_commit`, routes, migrations), `erb`, `js`/`javascript`, `yaml`, `shell`, `json`, `html`, `css`, `sql` and everything else Rouge supports. Highlighting happens on the server, so there is no flash of unstyled code.
126
+
127
+ Each block has a header with the language, an optional file name and a **Copy** button. After the language you can add line highlights, `showLineNumbers` and a file name, in any order:
128
+
129
+ ````markdown
130
+ ```ruby {2,4-6} showLineNumbers app/models/note.rb
131
+ class Note < ApplicationRecord
132
+ end
133
+ ```
134
+ ````
135
+
136
+ To show a real file from your app instead of pasting it, use `file=` (optionally with a line range). The block body is ignored:
137
+
138
+ ````markdown
139
+ ```ruby file=app/models/note.rb#L3-12 showLineNumbers
140
+ ```
141
+ ````
142
+
143
+ Only files inside `Rails.root` can be embedded; dotfiles, credentials, keys and `..` paths are refused (the block shows an error and `infodocs:check_links` reports it). The language is guessed from the file name when you leave it out.
144
+
145
+ Headings get a `#` link on hover, and footnotes (`text[^1]` with `[^1]: note`) are supported.
146
+
147
+ Pick a palette in the initializer; every palette has a light and a dark variant (dark follows `prefers-color-scheme` or a `.dark` class):
148
+
149
+ ```ruby
150
+ config.code_theme = :one # :one (default), :github, :vscode, :catppuccin
151
+
152
+ # or tweak colours of a base palette (roles: fg bg comment keyword string number
153
+ # function type variable builtin operator punctuation tag attribute escape regex symbol)
154
+ config.code_theme = { base: :one, dark: { keyword: "#ff79c6" } }
155
+ ```
156
+
157
+ ### Tags
158
+
159
+ A page can have several tags, from either place (they are merged and lower-cased):
160
+
161
+ ```markdown
162
+ ---
163
+ tags: [guide, deploy]
164
+ ---
165
+ ```
166
+
167
+ ```yaml
168
+ # guides/_tree.yml, which is what the edit page's "Tags" field writes (development only)
169
+ tags:
170
+ setup: [guide, rails] # page name => tags
171
+ ```
172
+
173
+ When any tag exists the sidebar shows tag chips: click one to filter the tree (`?tag=guide`, kept while you navigate), click again to clear. `/docs/_tags` lists all tags, `/docs/_tags/guide` the pages with that tag. Tags follow a page when it is moved in the tree.
174
+
175
+ ## Search
176
+
177
+ Results link to the page with `?q=…`, which marks the matching words on the page and scrolls to the first one. Typing in the header field shows live results (arrow keys + Enter); with an empty field the last searches are offered. `GET /docs/search.json?q=…` returns the same results as JSON.
178
+
179
+ | Query | Meaning |
180
+ | --- | --- |
181
+ | `deploy kamal` | both words (AND) |
182
+ | `"exact phrase"` | phrase |
183
+ | `deploy OR release` | either word |
184
+ | `deploy -docker` | exclude a word |
185
+ | `depl*` | prefix |
186
+ | `title:setup` | match in the page title only |
187
+ | `heading:install` | match in headings only |
188
+ | `path:guides` | match in the URL path |
189
+ | `tag:guide` | pages with the tag (alone or combined with words) |
190
+
191
+ Results are ranked with BM25 (title and headings weigh more than body text) and show a snippet with the matching terms marked. The index is stored in `tmp/infodocs/index.sqlite3` and updated from file modification times.
192
+
193
+ ## Configuration
194
+
195
+ `config/initializers/infodocs.rb`:
196
+
197
+ ```ruby
198
+ Infodocs.configure do |config|
199
+ # One or more directories. With several, each becomes a top-level folder
200
+ # named after the directory...
201
+ config.paths = [Rails.root.join("docs"), Rails.root.join("guides")]
202
+
203
+ # ...or name them yourself (slug = URL segment, title = sidebar label):
204
+ config.paths = [
205
+ { path: Rails.root.join("docs"), slug: "app", title: "Application" },
206
+ { path: Rails.root.join("vendor/shared-docs/docs"), slug: "shared" } # e.g. a git submodule
207
+ ]
208
+
209
+ config.title = "Docs" # header + <title>
210
+ config.editable = Rails.env.development? # edit mode, development only (see below)
211
+ config.code_theme = :one # see "Code blocks"
212
+ config.stylesheets = %w[tailwind] # stylesheet tags in the docs layout
213
+ config.index_path = Rails.root.join("tmp/infodocs/index.sqlite3")
214
+
215
+ config.repo_url = "https://github.com/acme/app" # optional: "Edit on GitHub" link in the footer
216
+ config.repo_branch = "main"
217
+
218
+ config.auth = :none # see below
219
+ end
220
+ ```
221
+
222
+ ### Copy as Markdown
223
+
224
+ Every page has a **Copy Markdown** button that copies the source (without frontmatter) to the clipboard, handy for pasting into an AI assistant. The same text is served at `/docs/_raw/<page>` under the page's normal access rules.
225
+
226
+ ### Redirects
227
+
228
+ When a page gets a new address, list the old ones in its frontmatter and they answer with a 301 to the new page:
229
+
230
+ ```yaml
231
+ ---
232
+ redirect_from: [old/path, getting-started]
233
+ ---
234
+ ```
235
+
236
+ Moving a page or a folder in edit mode adds the old addresses for you (the page's frontmatter is rewritten as YAML, so comments inside it are not kept). A real page at the old address always wins over a redirect.
237
+
238
+ ### Drafts and per-page visibility
239
+
240
+ `draft: true` in a page's frontmatter keeps it out of the docs everywhere except development (where it is marked with a *draft* badge): no URL, no sidebar entry, no search result, not in `llms.txt` or `sitemap.xml`.
241
+
242
+ To show some pages only to some visitors, return false from `page_authorize`. A hidden page behaves as if it did not exist (404, missing from the sidebar, search, tags, prev/next, `llms.txt` and `sitemap.xml`):
243
+
244
+ ```ruby
245
+ config.page_authorize = ->(page, user) { !page.tags.include?("internal") || user&.admin? }
246
+ ```
247
+
248
+ `user` is the signed-in Devise user (`auth = :devise`) or `current_user` if your controllers define it, otherwise `nil`. This is independent of the global `auth` check below, which gates the whole site.
249
+
250
+ ### Access control
251
+
252
+ ```ruby
253
+ config.auth = :none # public (default)
254
+
255
+ config.auth = :basic # HTTP basic auth
256
+ config.basic_auth = { user: ENV["DOCS_USER"], password: ENV["DOCS_PASSWORD"] }
257
+
258
+ config.auth = :devise # require a signed-in Devise user
259
+ config.devise = { scope: :user, authorize: ->(user) { user.admin? } }
260
+
261
+ config.auth = -> { current_user&.staff? } # any lambda, evaluated in the controller;
262
+ # truthy allows, falsy returns 403
263
+ ```
264
+
265
+ ## Edit mode (development only)
266
+
267
+ In `Rails.env.development`, with `config.editable = true`, the docs get editing tools (only on this machine, only for users who pass the docs `auth`):
268
+
269
+ - **Edit** on a page opens a Markdown editor with a toolbar (bold, italic, code, link, headings, lists, Ruby/JS/Mermaid blocks), a **Tags** field and a live server-rendered preview (tabs on mobile). `⌘S` / **Save** writes the `.md` file.
270
+ - **New page** in the sidebar creates a page or a folder (a folder needs no `index.md`).
271
+ - **Reorder** lets you drag pages and folders to reorder or move them; `_tree.yml` files are updated and files are moved with `git mv`.
272
+ - The trash icon deletes a page or a folder with its contents (`git rm`, with a confirmation).
273
+
274
+ Everything lands in your working tree: review and commit it with git.
275
+
276
+ Safeguards: the routes return 404 unless the app runs in development, the request comes from this machine and `editable` is on. Users must also pass the docs `auth` check. Only files inside the configured docs folders can be written, moved or deleted.
277
+
278
+ ## Tools
279
+
280
+ ```sh
281
+ bin/rails infodocs:check_links # broken relative links, anchors, images and embeds (exit 1 if any, for CI)
282
+ bin/rails infodocs:lint # content checks, see below (exit 1 if any)
283
+ bin/rails "infodocs:export[tmp/site]" # static HTML export
284
+ ```
285
+
286
+ `infodocs:lint` reports pages without a title (`# Heading` or `title:`), unknown frontmatter keys (known: `title`, `description`, `tags`, `draft`, `redirect_from`), hidden pages that no page links to, and `redirect_from` entries that are shadowed by a real page or claimed twice.
287
+
288
+ The export includes images, `llms.txt`, `sitemap.xml` and `public/assets` if present; run `assets:precompile` first and set `auth = :none`. Live search and edit mode need the Rails server, so they do not work in the export.
289
+
290
+ The engine also serves `/docs/llms.txt` and `/docs/sitemap.xml` behind the same auth as the docs (URLs use the request host). `config.repo_url` adds an "Edit on GitHub" link to the page footer.
291
+
292
+ ## Branding and linking from your app
293
+
294
+ ```ruby
295
+ config.logo = "/logo.svg" # shown before the title
296
+ config.favicon = "/favicon.ico"
297
+ config.header_links = [{ label: "App", url: "/" }, { label: "Status", url: "https://status.example.com" }]
298
+ config.head_html = '<script defer src="https://analytics.example.com/script.js"></script>' # trusted HTML for <head>
299
+
300
+ # logo, favicon and head_html can also be lambdas that receive the view:
301
+ config.logo = ->(view) { view.image_path("logo.svg") }
302
+ ```
303
+
304
+ Mermaid is loaded from a CDN by default (SortableJS for drag & drop ships inside the gem). To self-host Mermaid, e.g. for a strict CSP or an offline network, point `config.mermaid_url` at your copy of `mermaid.esm.min.mjs` (its chunk files must sit next to it).
305
+
306
+ Header links collapse into the sidebar drawer on phones. In your own views and controllers link to a docs page with `infodocs_page_path("guides/setup")` (also `infodocs_page_url`); without an argument it points to the docs home. (`infodocs_path` is the route helper Rails creates for the mount itself and always points to the docs root.)
307
+
308
+ ## Customising the look
309
+
310
+ Everything is plain ERB with Tailwind utility classes. Copy the views into your app and edit them:
311
+
312
+ ```sh
313
+ bin/rails g infodocs:views
314
+ ```
315
+
316
+ Views then live in `app/views/infodocs` and `app/views/layouts/infodocs`.
317
+
318
+ ## How it works
319
+
320
+ - `Infodocs::Tree` scans the configured folders and `_tree.yml` files.
321
+ - `Infodocs::Renderer` converts Markdown with commonmarker, highlights code with Rouge, builds the TOC and rewrites links.
322
+ - `Infodocs::Search` maintains the FTS5 index.
323
+ - The engine ships its own small Stimulus application (`infodocs/application`) with sidebar, search shortcut, TOC and Mermaid controllers, pinned through importmap, so it does not touch your own JavaScript.
324
+
325
+ ## Development
326
+
327
+ ```sh
328
+ bundle install
329
+ bundle exec rake test
330
+ ```
331
+
332
+ ## Roadmap
333
+
334
+ - Sources from remote git repositories.
335
+ - Translations (`docs/pl`, `docs/en`) with a language switcher.
336
+ - A visual (WYSIWYG) editor mode.
337
+ - Browser (system) tests; for now see `CONTRIBUTING.md` for the manual checklist.
338
+
339
+ ## License
340
+
341
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create do |t|
7
+ t.warning = false
8
+ end
9
+
10
+ task default: :test
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ class ApplicationController < ActionController::Base
5
+ layout "infodocs/application"
6
+
7
+ before_action :authenticate_docs!
8
+ helper_method :docs_tree, :page_allowed?, :visible_pages, :visible_tags
9
+
10
+ private
11
+
12
+ # Editing endpoints: development only, this machine only, plus docs auth.
13
+ def ensure_editable!
14
+ head :not_found unless Infodocs.editing_enabled? && request.local?
15
+ end
16
+
17
+ def docs_tree
18
+ Infodocs.tree
19
+ end
20
+
21
+ # The signed-in user handed to `config.page_authorize` (nil without one).
22
+ def docs_user
23
+ auth = Infodocs.config.auth
24
+ if auth == :devise
25
+ send(:"current_#{Infodocs.config.devise.fetch(:scope, :user)}")
26
+ elsif respond_to?(:current_user, true)
27
+ current_user
28
+ end
29
+ end
30
+
31
+ def page_allowed?(page)
32
+ authorize = Infodocs.config.page_authorize
33
+ authorize.nil? || page.nil? || !!authorize.call(page, docs_user)
34
+ end
35
+
36
+ def visible_pages
37
+ @visible_pages ||= docs_tree.pages.select { |page| page_allowed?(page) }
38
+ end
39
+
40
+ def visible_tags
41
+ @visible_tags ||= visible_pages.flat_map(&:tags).tally.sort.to_h
42
+ end
43
+
44
+ def authenticate_docs!
45
+ auth = Infodocs.config.auth
46
+
47
+ if auth.respond_to?(:call)
48
+ instance_exec(&auth) || head(:forbidden)
49
+ elsif auth == :basic
50
+ credentials = Infodocs.config.basic_auth
51
+ authenticate_or_request_with_http_basic("Docs") do |user, password|
52
+ ActiveSupport::SecurityUtils.secure_compare(user, credentials[:user].to_s) &
53
+ ActiveSupport::SecurityUtils.secure_compare(password, credentials[:password].to_s)
54
+ end
55
+ elsif auth == :devise
56
+ authenticate_devise_user!
57
+ end
58
+ end
59
+
60
+ def authenticate_devise_user!
61
+ scope = Infodocs.config.devise.fetch(:scope, :user)
62
+ send(:"authenticate_#{scope}!")
63
+ user = send(:"current_#{scope}")
64
+ authorize = Infodocs.config.devise[:authorize]
65
+ head :forbidden if authorize && !authorize.call(user)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ # Images and other files that sit next to the markdown pages.
5
+ class AssetsController < ApplicationController
6
+ def show
7
+ file = docs_tree.asset(params[:path])
8
+ return head(:not_found) unless file
9
+
10
+ # SVG may carry scripts; the sandbox keeps them inert when opened directly.
11
+ response.set_header("Content-Security-Policy", "sandbox")
12
+ send_file file, type: Tree::ASSET_TYPES.fetch(file.extname.downcase), disposition: "inline"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ # Writes edited Markdown straight to the source file (to be committed by the
5
+ # developer). Only reachable in development, from this machine, and only for
6
+ # users who already pass the docs authentication.
7
+ class EditorController < ApplicationController
8
+ before_action :ensure_editable!
9
+ before_action :load_page
10
+
11
+ def edit
12
+ @content = @page.raw
13
+ end
14
+
15
+ def update
16
+ @page.write(params.require(:content))
17
+ Infodocs.reload!
18
+ render json: { saved: true, path: @page.relative_path, title: Infodocs.tree.find(@page.slug)&.title }
19
+ rescue ActionController::ParameterMissing
20
+ render json: { saved: false, error: "content missing" }, status: :unprocessable_entity
21
+ end
22
+
23
+ def preview
24
+ draft = Page.new(file: @page.file, slug: @page.slug, root: @page.root, raw: params[:content].to_s)
25
+ rendered = Renderer.new(draft, mount_path: root_path).call
26
+ render html: rendered.html.html_safe, layout: false
27
+ end
28
+
29
+ private
30
+
31
+ def load_page
32
+ @page = docs_tree.find(params[:path].to_s)
33
+ head(:not_found) unless @page && page_allowed?(@page)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ # Machine-readable indexes: llms.txt (https://llmstxt.org) and sitemap.xml.
5
+ class IndexesController < ApplicationController
6
+ def llms
7
+ lines = ["# #{Infodocs.config.title}", ""]
8
+ visible_pages.each do |page|
9
+ note = page.description
10
+ lines << "- [#{page.title}](#{page_url(page.slug)})#{": #{note}" if note.present?}"
11
+ end
12
+ render plain: lines.join("\n") + "\n", content_type: "text/plain"
13
+ end
14
+
15
+ def sitemap
16
+ urls = visible_pages.map do |page|
17
+ " <url><loc>#{ERB::Util.html_escape(page_url(page.slug))}</loc><lastmod>#{page.updated_at.utc.iso8601}</lastmod></url>"
18
+ end
19
+ render xml: %(<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n#{urls.join("\n")}\n</urlset>\n)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ class PagesController < ApplicationController
5
+ def show
6
+ @page = docs_tree.find(params[:path].to_s) || first_page
7
+ return show_folder if @page.nil?
8
+ return render_not_found unless page_allowed?(@page)
9
+
10
+ @rendered = Infodocs.render(@page, mount_path: root_path)
11
+ @trail = docs_tree.trail(@page.slug)
12
+ index = visible_pages.index(@page)
13
+ @previous = visible_pages[index - 1] if index&.positive?
14
+ @next = visible_pages[index + 1] if index
15
+ end
16
+
17
+ private
18
+
19
+ # A folder without a landing page opens its first page; an empty one says so.
20
+ def show_folder
21
+ moved = docs_tree.redirect_for(params[:path].to_s)
22
+ moved_page = moved && docs_tree.find(moved)
23
+ return redirect_to(page_path(moved), status: :moved_permanently) if moved_page && page_allowed?(moved_page)
24
+
25
+ folder = docs_tree.node(params[:path].to_s)
26
+ return render_not_found unless folder&.folder?
27
+
28
+ first = docs_tree.first_page_under(folder)
29
+ first = visible_pages.find { |page| page.slug.start_with?("#{folder.slug}/") } unless first.nil? || page_allowed?(first)
30
+ return redirect_to(page_path(first.slug)) if first
31
+
32
+ @folder = folder
33
+ @trail = docs_tree.trail(folder.slug)
34
+ render "infodocs/pages/empty_folder"
35
+ end
36
+
37
+ def first_page
38
+ visible_pages.first if params[:path].blank?
39
+ end
40
+
41
+ # Unknown path: offer pages whose text matches the words in the URL.
42
+ def render_not_found
43
+ words = params[:path].to_s.split(/[^\p{L}\p{N}]+/).select { |word| word.length > 2 }.first(5)
44
+ @suggestions = words.any? ? Infodocs.search.search(words.join(" OR "), limit: 5) : []
45
+ render "infodocs/pages/not_found", status: :not_found
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ # The Markdown source of a page (without frontmatter), for "Copy Markdown".
5
+ class RawsController < ApplicationController
6
+ def show
7
+ page = docs_tree.find(params[:path].to_s) || (docs_tree.pages.first if params[:path].blank?)
8
+ return head(:not_found) unless page && page_allowed?(page)
9
+
10
+ render plain: page.body, content_type: "text/plain"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infodocs
4
+ class SearchesController < ApplicationController
5
+ def index
6
+ @query = params[:q].to_s.strip
7
+ @results = @query.empty? ? [] : Infodocs.search.search(@query, limit: 100).select { |r| page_allowed?(docs_tree.find(r.slug)) }
8
+ @results = @results.first(request.format.json? ? 8 : 20)
9
+
10
+ respond_to do |format|
11
+ format.html
12
+ format.json do
13
+ render json: @results.map { |r| { title: r.title, path: r.slug, url: page_path(r.slug), snippet: r.snippet } }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end