auto-platform-guide 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 +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE.txt +21 -0
- data/README.md +161 -0
- data/app/views/docs/show.html.erb +46 -0
- data/lib/auto-platform-guide.rb +3 -0
- data/lib/auto_platform_guide/configuration.rb +48 -0
- data/lib/auto_platform_guide/docs_controller_actions.rb +18 -0
- data/lib/auto_platform_guide/document.rb +219 -0
- data/lib/auto_platform_guide/engine.rb +15 -0
- data/lib/auto_platform_guide/generator.rb +300 -0
- data/lib/auto_platform_guide/rake_tasks.rb +44 -0
- data/lib/auto_platform_guide/rspec.rb +133 -0
- data/lib/auto_platform_guide/spec_file.rb +100 -0
- data/lib/auto_platform_guide/version.rb +5 -0
- data/lib/auto_platform_guide.rb +17 -0
- metadata +158 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5ccccab1be99709c35e9851337b7f95f722124a4f23368de67c76c7e6539f6c5
|
|
4
|
+
data.tar.gz: cebdb9082d0502cd57550a0c567809bfee9beeb606faf7800b898dfa049da294
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 797fdaf7358ada5c81bf394f57f38490e2734c1bb9d269676d3d0c51e9b0513f7f9d1db9386a409c453f41f80f2951ddae076936e092967b58fd8db702e350fd
|
|
7
|
+
data.tar.gz: 6f6b428ebf18973783dc42b5ca427a41a8ab0b91bdffd9fad9ed972f8e617e13b7d784f5c499da9d2eacdc6dcdc8aaee0d380d672570aad39d0ec74a95c6e899
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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.1.0/),
|
|
6
|
+
and this project adheres to
|
|
7
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [0.1.0] - 2026-10-26
|
|
12
|
+
|
|
13
|
+
Initial release: spec-driven doc generator, `doc_screenshot` helper, configurable
|
|
14
|
+
browser driver wiring, and markdown docs viewer.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Rake tasks for generating, checking, re-rendering and rebuilding the TOC,
|
|
19
|
+
shipped with the gem.
|
|
20
|
+
- `readme_preamble` configuration for the markdown above the generated README
|
|
21
|
+
table of contents.
|
|
22
|
+
- The gem's own guide in `docs/platform-guide/`, generated from `spec/docs/` and
|
|
23
|
+
verified in CI.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Viewer links, breadcrumbs and wikilinks follow the `docs_path` configuration
|
|
28
|
+
instead of hardcoding `/docs`.
|
|
29
|
+
- The docs viewer template no longer depends on host helpers, partials or
|
|
30
|
+
assets (`content_header`, `shared/_box`, a host `docs_path` helper, `docs.js`).
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Doc comments preserve indentation beyond the `# ` prefix, so fenced code
|
|
35
|
+
blocks keep their layout.
|
|
36
|
+
- Adjacent callout blockquotes render as separate callouts instead of one merged
|
|
37
|
+
quote.
|
|
38
|
+
|
|
39
|
+
### Removed
|
|
40
|
+
|
|
41
|
+
- `bin/generate-docs`; use `auto_platform_guide:generate` instead.
|
|
42
|
+
|
|
43
|
+
[Unreleased]: https://github.com/mauriciozaffari/auto-platform-guide/compare/v0.1.0...HEAD
|
|
44
|
+
[0.1.0]: https://github.com/mauriciozaffari/auto-platform-guide/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mauricio Zaffari
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Auto Platform Guide
|
|
2
|
+
|
|
3
|
+
Generate in-app documentation from system specs.
|
|
4
|
+
|
|
5
|
+
The gem documents itself: `docs/platform-guide/` is generated from
|
|
6
|
+
`spec/docs/` by `rake auto_platform_guide:render` and checked in CI.
|
|
7
|
+
|
|
8
|
+
- `AutoPlatformGuide::Generator` renders markdown guides from `# =` doc comments in
|
|
9
|
+
system specs into `generated_docs_dir`, with an ordered README TOC grouped by
|
|
10
|
+
`@category`/`@order` directives.
|
|
11
|
+
- `AutoPlatformGuide.setup_rspec(config)` wires system-spec screenshot support:
|
|
12
|
+
`doc_screenshot('name')` captures to `screenshots_dir` when `SCREENSHOTS=true`,
|
|
13
|
+
and specs run on `rack_test` unless they need a browser (`:js` tag or
|
|
14
|
+
`SCREENSHOTS=true`). Browser driver is `:playwright` by default; set
|
|
15
|
+
`config.browser_driver = :selenium` to use Selenium.
|
|
16
|
+
- The markdown viewer: `Document` + the `docs/show` template, rendered through
|
|
17
|
+
`AutoPlatformGuide::DocsControllerActions`. Include it in the host app's docs
|
|
18
|
+
controller so authentication stays application-owned:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
class DocsController < AdminController
|
|
22
|
+
include AutoPlatformGuide::DocsControllerActions
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# config/routes.rb
|
|
28
|
+
get '/docs(/*id)', to: 'docs#show', as: 'docs'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The shipped `docs/show` template uses plain markup and inline JavaScript; it
|
|
32
|
+
needs no host helpers, partials or assets. Links are built from `docs_path`, so
|
|
33
|
+
set it to the path you mount (default `/docs`). Mermaid fenced blocks render
|
|
34
|
+
when a global `mermaid` is present and stay readable as source otherwise.
|
|
35
|
+
Override the template in the host app to change the surrounding chrome.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# Gemfile
|
|
41
|
+
gem 'auto-platform-guide'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Tagged releases publish to RubyGems automatically.
|
|
45
|
+
|
|
46
|
+
## Setup
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# config/initializers/auto_platform_guide.rb
|
|
50
|
+
AutoPlatformGuide.configure do |config|
|
|
51
|
+
config.docs_root = 'docs'
|
|
52
|
+
config.docs_path = '/docs'
|
|
53
|
+
config.generated_docs_dir = 'docs/platform-guide'
|
|
54
|
+
config.screenshots_dir = 'docs/screenshots'
|
|
55
|
+
config.specs_glob = 'spec/system/**/*_spec.rb'
|
|
56
|
+
config.browser_driver = :playwright # or :selenium
|
|
57
|
+
config.readme_preamble = nil
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Every key is optional; the values above are the defaults. Keep
|
|
62
|
+
`generated_docs_dir` inside `docs_root`, since the viewer only reads from
|
|
63
|
+
`docs_root`.
|
|
64
|
+
|
|
65
|
+
The `:playwright` default needs `capybara-playwright-driver` in the host app;
|
|
66
|
+
`:selenium` needs `selenium-webdriver`. `capybara` itself is a gem dependency.
|
|
67
|
+
`readme_preamble` is markdown placed above the generated README's table of
|
|
68
|
+
contents; `nil` keeps `AutoPlatformGuide::Generator::README_PREAMBLE`. Note:
|
|
69
|
+
`:selenium` support is unit-tested with mocked drivers — no CI job boots a real
|
|
70
|
+
browser under either driver.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# spec/rails_helper.rb
|
|
74
|
+
require 'auto_platform_guide/rspec'
|
|
75
|
+
|
|
76
|
+
RSpec.configure do |config|
|
|
77
|
+
AutoPlatformGuide.setup_rspec(config)
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Writing a documented spec
|
|
82
|
+
|
|
83
|
+
Doc comments are parsed from the files matched by `specs_glob`. A spec file is
|
|
84
|
+
picked up when it has a `# = Heading` at column 0.
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# spec/system/orders_spec.rb
|
|
88
|
+
require 'rails_helper'
|
|
89
|
+
|
|
90
|
+
# @category Orders
|
|
91
|
+
# @order 200
|
|
92
|
+
#
|
|
93
|
+
# = Orders
|
|
94
|
+
# Create and track customer orders.
|
|
95
|
+
#
|
|
96
|
+
# == Creating an order
|
|
97
|
+
# Pick a customer, add line items and save.
|
|
98
|
+
|
|
99
|
+
RSpec.describe 'Orders', type: :system do
|
|
100
|
+
it 'creates an order' do
|
|
101
|
+
visit new_admin_order_path
|
|
102
|
+
|
|
103
|
+
fill_in 'Reference', with: 'ORD-1'
|
|
104
|
+
click_button 'Save'
|
|
105
|
+
expect(page).to have_content('Order created')
|
|
106
|
+
|
|
107
|
+
doc_screenshot('orders/created')
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `# = Heading` renders as `<h1>`; every extra `=` adds a level (`# ==` → `<h2>`).
|
|
113
|
+
- Comment lines after a heading are its body, up to the next heading or the first
|
|
114
|
+
non-comment line. Sections can also sit directly above the examples they describe.
|
|
115
|
+
- `@category` and `@order` only place the guide in the README TOC; they are
|
|
116
|
+
stripped from the body. Specs without them fall under `Other` / `999_999`.
|
|
117
|
+
- The generator parses comments and embeds only screenshots whose PNG already
|
|
118
|
+
exists on disk. `generate` runs the spec first to capture them; `render` and
|
|
119
|
+
`check` never run specs.
|
|
120
|
+
|
|
121
|
+
`doc_screenshot('orders/created')` writes `docs/screenshots/orders/created.png`
|
|
122
|
+
when screenshots are enabled, and does nothing otherwise.
|
|
123
|
+
|
|
124
|
+
## Capturing screenshots
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
SCREENSHOTS=true bundle exec rspec spec/system/orders_spec.rb
|
|
128
|
+
rake auto_platform_guide:generate
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`SCREENSHOTS=true` drives every system example in that run through the configured
|
|
132
|
+
browser (`:playwright` by default) and saves each `doc_screenshot` call. Without
|
|
133
|
+
it, system specs use `rack_test`; tag individual examples `:js` when only they
|
|
134
|
+
need a browser.
|
|
135
|
+
|
|
136
|
+
The helper is included for `type: :system` only, so keep that metadata. A
|
|
137
|
+
screenshot is rendered after the heading block that precedes its call.
|
|
138
|
+
|
|
139
|
+
## Rake tasks
|
|
140
|
+
|
|
141
|
+
Generating docs is a rake task shipped with the gem. Rails apps load it through
|
|
142
|
+
the engine; anywhere else, require it from your `Rakefile`:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
require 'auto_platform_guide/rake_tasks'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
rake auto_platform_guide:generate # all documented specs
|
|
150
|
+
rake "auto_platform_guide:generate[spec/foo_spec.rb]" # one spec
|
|
151
|
+
rake auto_platform_guide:check # verify docs; non-zero when stale
|
|
152
|
+
rake auto_platform_guide:readme # rebuild the README TOC
|
|
153
|
+
rake auto_platform_guide:render # re-render, reuse screenshots on disk
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Tests
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
bin/setup
|
|
160
|
+
bundle exec rspec
|
|
161
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<% content_for :title, @document.title %>
|
|
2
|
+
|
|
3
|
+
<section class="content-header">
|
|
4
|
+
<h1><%= @document.title %></h1>
|
|
5
|
+
|
|
6
|
+
<ol class="breadcrumb">
|
|
7
|
+
<li><%= link_to 'Documentation', AutoPlatformGuide::Document.docs_path %></li>
|
|
8
|
+
<% @document.breadcrumbs.each do |crumb| %>
|
|
9
|
+
<li><%= link_to crumb[:name], crumb[:path] %></li>
|
|
10
|
+
<% end %>
|
|
11
|
+
</ol>
|
|
12
|
+
</section>
|
|
13
|
+
|
|
14
|
+
<section class="content documentation">
|
|
15
|
+
<div class="box">
|
|
16
|
+
<div class="box-body">
|
|
17
|
+
<%= @document.render %>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</section>
|
|
21
|
+
|
|
22
|
+
<%# Mermaid diagrams are an enhancement: without a global `mermaid`, the
|
|
23
|
+
renderer's <pre class="mermaid"> blocks stay visible as source. %>
|
|
24
|
+
<%= javascript_tag nonce: true do %>
|
|
25
|
+
(() => {
|
|
26
|
+
if (window.autoPlatformGuideDocsReady) return
|
|
27
|
+
window.autoPlatformGuideDocsReady = true
|
|
28
|
+
|
|
29
|
+
const renderDiagrams = () => {
|
|
30
|
+
if (!window.mermaid) return
|
|
31
|
+
|
|
32
|
+
const nodes = document.querySelectorAll('pre.mermaid:not([data-processed="true"])')
|
|
33
|
+
if (!nodes.length) return
|
|
34
|
+
|
|
35
|
+
window.mermaid.initialize({
|
|
36
|
+
startOnLoad: false,
|
|
37
|
+
securityLevel: 'strict',
|
|
38
|
+
theme: window.darkMode ? 'dark' : 'default'
|
|
39
|
+
})
|
|
40
|
+
window.mermaid.run({ nodes })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
document.addEventListener('turbo:load', renderDiagrams)
|
|
44
|
+
if (document.readyState === 'complete') renderDiagrams()
|
|
45
|
+
})()
|
|
46
|
+
<% end %>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AutoPlatformGuide
|
|
4
|
+
class Configuration
|
|
5
|
+
# Where the viewer looks for markdown. The generator writes under
|
|
6
|
+
# generated_docs_dir, which lives inside this directory.
|
|
7
|
+
attr_accessor :docs_root
|
|
8
|
+
# Where DocGenerator writes the generated guides (specs -> markdown).
|
|
9
|
+
attr_accessor :generated_docs_dir
|
|
10
|
+
# Where doc_screenshot writes PNGs.
|
|
11
|
+
attr_accessor :screenshots_dir
|
|
12
|
+
# Glob (relative to repo root) for specs searched for doc comments.
|
|
13
|
+
attr_accessor :specs_glob
|
|
14
|
+
# Browser driver used when a system spec needs a real browser:
|
|
15
|
+
# :playwright (default) or :selenium.
|
|
16
|
+
attr_accessor :browser_driver
|
|
17
|
+
# Markdown written above the generated README table of contents. Defaults to
|
|
18
|
+
# Generator::README_PREAMBLE when nil.
|
|
19
|
+
attr_accessor :readme_preamble
|
|
20
|
+
# URL prefix where the docs viewer is mounted. Links, breadcrumbs and
|
|
21
|
+
# wikilinks are built from it, so it must match the host's route.
|
|
22
|
+
attr_reader :docs_path
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@docs_root = 'docs'
|
|
26
|
+
@generated_docs_dir = 'docs/platform-guide'
|
|
27
|
+
@screenshots_dir = 'docs/screenshots'
|
|
28
|
+
@specs_glob = 'spec/system/**/*_spec.rb'
|
|
29
|
+
@browser_driver = :playwright
|
|
30
|
+
@readme_preamble = nil
|
|
31
|
+
@docs_path = '/docs'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def docs_path=(path)
|
|
35
|
+
@docs_path = path.chomp('/')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
def configuration
|
|
41
|
+
@configuration ||= Configuration.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def configure
|
|
45
|
+
yield configuration
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AutoPlatformGuide
|
|
4
|
+
# Include into the host app's docs controller. The host keeps its own
|
|
5
|
+
# controller class so authentication (before_actions, layouts) stays
|
|
6
|
+
# application-owned:
|
|
7
|
+
#
|
|
8
|
+
# class DocsController < AdminController
|
|
9
|
+
# include AutoPlatformGuide::DocsControllerActions
|
|
10
|
+
# end
|
|
11
|
+
module DocsControllerActions
|
|
12
|
+
def show
|
|
13
|
+
@document = Document.find(params[:id] || 'README')
|
|
14
|
+
# Markdown links end in `.md`, which Rails would otherwise read as the response format.
|
|
15
|
+
render template: 'docs/show', formats: :html
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'redcarpet'
|
|
6
|
+
require 'rouge'
|
|
7
|
+
require 'active_model'
|
|
8
|
+
require 'action_view'
|
|
9
|
+
require 'active_support/core_ext/enumerable'
|
|
10
|
+
require 'active_support/core_ext/module/delegation'
|
|
11
|
+
require 'active_support/core_ext/string/inflections'
|
|
12
|
+
|
|
13
|
+
module AutoPlatformGuide
|
|
14
|
+
# A markdown document under the configured docs root, rendered for /docs.
|
|
15
|
+
class Document
|
|
16
|
+
include ActiveModel::Model
|
|
17
|
+
|
|
18
|
+
class RecordNotFound < StandardError; end
|
|
19
|
+
|
|
20
|
+
FOLDER_INDEX_NAMES = ['README', '00 - Index'].freeze
|
|
21
|
+
|
|
22
|
+
class Renderer < Redcarpet::Render::HTML
|
|
23
|
+
include ActionView::Helpers::TagHelper
|
|
24
|
+
include ActionView::Helpers::SanitizeHelper
|
|
25
|
+
|
|
26
|
+
# Obsidian wikilink: [[Note]], [[Note#Heading]], [[Note|Alias]] (and combinations).
|
|
27
|
+
WIKILINK = /\[\[([^\]#|]+)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/
|
|
28
|
+
CALLOUT = /\A<p>\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:<br>\s*)?/i
|
|
29
|
+
CALLOUT_ICONS = {
|
|
30
|
+
'note' => 'info-circle',
|
|
31
|
+
'tip' => 'lightbulb-o',
|
|
32
|
+
'important' => 'exclamation-circle',
|
|
33
|
+
'warning' => 'warning',
|
|
34
|
+
'caution' => 'ban'
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Redcarpet merges adjacent blockquotes into one callback, so split the
|
|
38
|
+
# quote at each callout marker and render them separately.
|
|
39
|
+
CALLOUT_SPLIT = /(?=<p>\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\])/i
|
|
40
|
+
|
|
41
|
+
def self.render(path, base_dir: nil)
|
|
42
|
+
new(base_dir:).render(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(base_dir: nil, docs_path: nil, **options)
|
|
46
|
+
@base_dir = base_dir
|
|
47
|
+
@docs_path = (docs_path || Document.docs_path).chomp('/')
|
|
48
|
+
super(options.merge(with_toc_data: true, hard_wrap: true))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sanitize(html)
|
|
52
|
+
tags = ActionView::Base.sanitized_allowed_tags + %w[table thead tbody th tr td img i]
|
|
53
|
+
attributes = ActionView::Base.sanitized_allowed_attributes + %w[style colspan src alt id class aria-hidden]
|
|
54
|
+
super(html, tags:, attributes:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def markdown
|
|
58
|
+
@markdown ||= Redcarpet::Markdown.new(
|
|
59
|
+
self,
|
|
60
|
+
fenced_code_blocks: true,
|
|
61
|
+
tables: true,
|
|
62
|
+
autolink: true,
|
|
63
|
+
strikethrough: true
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render(path)
|
|
68
|
+
sanitize(markdown.render(File.read(path)))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Convert Obsidian wikilinks to markdown links before parsing, leaving any
|
|
72
|
+
# text inside fenced or inline code untouched.
|
|
73
|
+
def preprocess(document)
|
|
74
|
+
replace_wikilinks(document)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def block_code(code, language)
|
|
78
|
+
# Mermaid diagrams are rendered client-side: emit the raw source in a
|
|
79
|
+
# <pre class="mermaid"> for mermaid.js, never syntax-highlighted.
|
|
80
|
+
return content_tag(:pre, code, class: 'mermaid') if language == 'mermaid'
|
|
81
|
+
|
|
82
|
+
lexer = Rouge::Lexer.find_fancy(language.to_s, code) || Rouge::Lexers::PlainText.new
|
|
83
|
+
formatter = Rouge::Formatters::HTMLInline.new(Rouge::Themes::Github.mode(:dark).new)
|
|
84
|
+
code = formatter.format(lexer.lex(code))
|
|
85
|
+
content_tag(:pre, class: 'code-block') do
|
|
86
|
+
content_tag(:code, sanitize(code), class: "language-#{lexer.tag}")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def image(link, title, alt_text)
|
|
91
|
+
# tag.img renders HTML5 without the self-closing slash the specs assert.
|
|
92
|
+
tag(:img, src: link, alt: alt_text, title:, class: 'img-responsive') # rubocop:disable Rails/ContentTag
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def block_quote(quote)
|
|
96
|
+
quote.split(CALLOUT_SPLIT).reject(&:empty?).map { |segment| render_quote(segment) }.join("\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def render_quote(segment)
|
|
102
|
+
callout = segment.match(CALLOUT)
|
|
103
|
+
return %(<blockquote>#{segment}</blockquote>) unless callout
|
|
104
|
+
|
|
105
|
+
type = callout[1].downcase
|
|
106
|
+
body = segment.sub(CALLOUT, '<p>')
|
|
107
|
+
icon = tag.i(class: "fa fa-#{CALLOUT_ICONS.fetch(type)}", aria: { hidden: true })
|
|
108
|
+
title = %(<p class="callout-title">#{icon} #{type.titleize}</p>)
|
|
109
|
+
|
|
110
|
+
%(<blockquote class="callout callout-#{type}">#{title}\n#{body}</blockquote>)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def replace_wikilinks(document)
|
|
114
|
+
in_fence = false
|
|
115
|
+
|
|
116
|
+
document.each_line.map do |line|
|
|
117
|
+
if line.start_with?('```')
|
|
118
|
+
in_fence = !in_fence
|
|
119
|
+
line
|
|
120
|
+
elsif in_fence
|
|
121
|
+
line
|
|
122
|
+
else
|
|
123
|
+
replace_wikilinks_in_line(line)
|
|
124
|
+
end
|
|
125
|
+
end.join
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def replace_wikilinks_in_line(line)
|
|
129
|
+
line.split(/(`[^`]*`)/).map do |part|
|
|
130
|
+
if part.start_with?('`')
|
|
131
|
+
part
|
|
132
|
+
else
|
|
133
|
+
part.gsub(WIKILINK) do
|
|
134
|
+
wikilink_markdown(::Regexp.last_match(1), ::Regexp.last_match(2), ::Regexp.last_match(3))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end.join
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def wikilink_markdown(name, heading, alias_text)
|
|
141
|
+
name = name.strip
|
|
142
|
+
label = alias_text&.strip
|
|
143
|
+
label ||= heading ? "#{name} › #{heading.strip}" : name
|
|
144
|
+
"[#{escape_markdown(label)}](#{wikilink_url(name, heading)})"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Escape characters the link label would otherwise trigger as markdown
|
|
148
|
+
# (emphasis, links, code) so snake_case names render literally.
|
|
149
|
+
def escape_markdown(text)
|
|
150
|
+
text.gsub(/[\\_*\[\]`]/) { |char| "\\#{char}" }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def wikilink_url(name, heading)
|
|
154
|
+
segments = [@base_dir, name].compact_blank
|
|
155
|
+
url = "#{@docs_path}/#{segments.map { |segment| ERB::Util.url_encode(segment) }.join('/')}"
|
|
156
|
+
url += "##{heading_anchor(heading)}" if heading
|
|
157
|
+
url
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Mirror Redcarpet's with_toc_data anchor generation for [[Note#Heading]]:
|
|
161
|
+
# drop non-alphanumerics (underscores included), then collapse whitespace to "-".
|
|
162
|
+
def heading_anchor(heading)
|
|
163
|
+
heading.strip.downcase.gsub(/[^a-z0-9\s]/, '').gsub(/\s+/, '-').gsub(/^-|-$/, '')
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
attr_reader :id, :name, :path
|
|
168
|
+
|
|
169
|
+
class << self
|
|
170
|
+
def where(folder: nil, name: nil)
|
|
171
|
+
prefix = folder ? "#{docs_root}/#{folder}/" : "{#{docs_root}/**/,}"
|
|
172
|
+
Rails.root.glob("#{prefix}#{name || '*'}.md").sort.map { |path| new(path) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
alias all where
|
|
176
|
+
delegate :first, :last, to: :where
|
|
177
|
+
|
|
178
|
+
def find(name)
|
|
179
|
+
# .lazy stops at the first index file that exists.
|
|
180
|
+
where(name:).first ||
|
|
181
|
+
FOLDER_INDEX_NAMES.lazy.filter_map { |index| where(folder: name, name: index).first }.first ||
|
|
182
|
+
raise(RecordNotFound, "Couldn't find document #{name}")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def docs_root
|
|
186
|
+
AutoPlatformGuide.configuration.docs_root
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def docs_path
|
|
190
|
+
AutoPlatformGuide.configuration.docs_path
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def initialize(path)
|
|
195
|
+
@path = path
|
|
196
|
+
relative_path = Pathname(path).relative_path_from(Rails.root).to_s
|
|
197
|
+
prefix = Regexp.escape(AutoPlatformGuide.configuration.docs_root)
|
|
198
|
+
@id = relative_path.match(%r{\A(?:#{prefix}/)?(?<id>.*)\.md\z})&.[](:id)
|
|
199
|
+
@name = File.basename(path, '.md')
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def render
|
|
203
|
+
dir = File.dirname(id)
|
|
204
|
+
Renderer.render(path, base_dir: dir == '.' ? nil : dir)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def title
|
|
208
|
+
name.titleize
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def breadcrumbs
|
|
212
|
+
parts = id.split('/')
|
|
213
|
+
|
|
214
|
+
parts.each_with_index.map do |part, i|
|
|
215
|
+
{ name: part.titleize, path: "#{self.class.docs_path}/#{parts[0..i].join('/')}" }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/engine'
|
|
4
|
+
|
|
5
|
+
module AutoPlatformGuide
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
initializer 'auto_platform_guide.view_paths', before: :add_view_paths do |app|
|
|
8
|
+
app.config.paths['app/views'].push(root.join('app', 'views'))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
rake_tasks do
|
|
12
|
+
require 'auto_platform_guide/rake_tasks'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module AutoPlatformGuide
|
|
7
|
+
# Generates markdown guides from system specs with doc comments, plus the
|
|
8
|
+
# README table of contents. Doc comments are `# = Heading` blocks in spec
|
|
9
|
+
# files; `doc_screenshot('name')` calls reference PNGs captured during a
|
|
10
|
+
# SCREENSHOTS=true run.
|
|
11
|
+
class Generator
|
|
12
|
+
# Fallbacks for specs that omit the directives below. Specs with no
|
|
13
|
+
# `@category` collect under DEFAULT_CATEGORY; specs with no `@order` sort
|
|
14
|
+
# after numbered ones. Both defaults push such docs to the end of the TOC.
|
|
15
|
+
DEFAULT_CATEGORY = 'Other'
|
|
16
|
+
DEFAULT_ORDER = 999_999
|
|
17
|
+
|
|
18
|
+
FLAGS = %w[--check --readme-only --render-only].freeze
|
|
19
|
+
|
|
20
|
+
README_PREAMBLE = <<~MARKDOWN
|
|
21
|
+
<h1 id="platform-guide">Platform Guide</h1>
|
|
22
|
+
|
|
23
|
+
This guide is the primary, automatically generated reference for this
|
|
24
|
+
application. It is extracted from documented specs, so every page reflects
|
|
25
|
+
behaviour that is actually tested in the codebase.
|
|
26
|
+
|
|
27
|
+
Guides are grouped by area below and ordered to follow a typical workflow, from
|
|
28
|
+
the first area through to the last.
|
|
29
|
+
MARKDOWN
|
|
30
|
+
|
|
31
|
+
# Returns a process exit status (0 ok, 1 stale in --check mode).
|
|
32
|
+
# The bin wrapper passes it straight to exit.
|
|
33
|
+
def self.run(argv)
|
|
34
|
+
spec_files = argv - FLAGS
|
|
35
|
+
generator = new(spec_files.empty? ? nil : spec_files)
|
|
36
|
+
|
|
37
|
+
case FLAGS.find { |flag| argv.include?(flag) }
|
|
38
|
+
when '--check'
|
|
39
|
+
# Verify docs without running specs; used by CI.
|
|
40
|
+
generator.up_to_date? ? 0 : 1
|
|
41
|
+
when '--readme-only'
|
|
42
|
+
# Rebuild the TOC from committed docs + spec directives only (fast).
|
|
43
|
+
generator.regenerate_readme
|
|
44
|
+
0
|
|
45
|
+
when '--render-only'
|
|
46
|
+
# Re-render doc bodies from doc comments using screenshots on disk.
|
|
47
|
+
generator.generate(run_specs: false)
|
|
48
|
+
0
|
|
49
|
+
else
|
|
50
|
+
generator.generate
|
|
51
|
+
0
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(spec_files = nil)
|
|
56
|
+
@spec_files = spec_files || find_documented_specs
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def config
|
|
60
|
+
AutoPlatformGuide.configuration
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def generate(run_specs: true)
|
|
64
|
+
if @spec_files.empty?
|
|
65
|
+
puts 'No system specs with documentation comments found.'
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@spec_files.each do |spec_file|
|
|
70
|
+
puts "\nGenerating documentation for #{spec_file}..."
|
|
71
|
+
process_spec(spec_file, run_specs:)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
generate_readme
|
|
75
|
+
|
|
76
|
+
puts "\n✓ Documentation generation complete!"
|
|
77
|
+
puts " Generated #{@spec_files.size} documentation file(s) in #{config.generated_docs_dir}/"
|
|
78
|
+
puts ' Generated README.md with table of contents'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Verify the committed docs are in sync with their documented specs,
|
|
82
|
+
# without running the specs. Re-renders the doc body and compares against
|
|
83
|
+
# the committed markdown, ignoring the generation timestamp and the
|
|
84
|
+
# screenshot binaries themselves. Returns true when everything is up to date.
|
|
85
|
+
def up_to_date?
|
|
86
|
+
stale = find_documented_specs.select { |spec_file| doc_stale?(spec_file) }
|
|
87
|
+
|
|
88
|
+
if stale.empty?
|
|
89
|
+
puts '✓ Platform-guide docs are up to date.'
|
|
90
|
+
return true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
warn "✗ Platform-guide docs are out of date for #{stale.size} spec(s):"
|
|
94
|
+
stale.each { |spec_file| warn " - #{spec_file} -> #{generate_output_filename(spec_file)}" }
|
|
95
|
+
warn ''
|
|
96
|
+
warn 'Regenerate the affected guide(s) and commit the result:'
|
|
97
|
+
stale.each { |spec_file| warn %( rake "auto_platform_guide:generate[#{spec_file}]") }
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Rebuild only the README table of contents from the committed docs and
|
|
102
|
+
# their spec `@category`/`@order` directives, without running any specs.
|
|
103
|
+
def regenerate_readme
|
|
104
|
+
generate_readme
|
|
105
|
+
puts '✓ Regenerated README.md table of contents.'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def doc_stale?(spec_file)
|
|
111
|
+
output_file = generate_output_filename(spec_file)
|
|
112
|
+
return true unless File.exist?(output_file)
|
|
113
|
+
|
|
114
|
+
expected = "#{render_doc_body(spec(spec_file).doc_comments, spec(spec_file).screenshot_calls).rstrip}\n"
|
|
115
|
+
committed = strip_timestamp_header(File.read(output_file, encoding: 'UTF-8'))
|
|
116
|
+
expected != committed
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def strip_timestamp_header(content)
|
|
120
|
+
content.sub(/\A<!--\nGenerated: [^\n]*\n-->\n\n/, '')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def find_documented_specs
|
|
124
|
+
Dir.glob(config.specs_glob).select { |file| spec(file).documented? }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Parsed spec, memoized so a run reads each file once.
|
|
128
|
+
def spec(spec_file)
|
|
129
|
+
@specs ||= {}
|
|
130
|
+
@specs[spec_file] ||= SpecFile.new(spec_file)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def process_spec(spec_file, run_specs: true)
|
|
134
|
+
parsed = spec(spec_file)
|
|
135
|
+
|
|
136
|
+
if run_specs
|
|
137
|
+
puts ' Running spec with SCREENSHOTS=true...'
|
|
138
|
+
run_spec_with_screenshots(spec_file)
|
|
139
|
+
else
|
|
140
|
+
puts ' Re-rendering from doc comments (using existing screenshots)...'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Generate markdown file
|
|
144
|
+
output_file = generate_output_filename(spec_file)
|
|
145
|
+
generate_markdown(output_file, parsed.doc_comments, parsed.screenshot_calls)
|
|
146
|
+
|
|
147
|
+
puts " ✓ Generated #{output_file}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def run_spec_with_screenshots(spec_file)
|
|
151
|
+
FileUtils.mkdir_p(config.screenshots_dir)
|
|
152
|
+
|
|
153
|
+
command = "SCREENSHOTS=true bundle exec rspec --format documentation #{spec_file}"
|
|
154
|
+
system(command, err: File::NULL)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def generate_output_filename(spec_file)
|
|
158
|
+
basename = File.basename(spec_file, '_spec.rb')
|
|
159
|
+
output_name = "#{basename.upcase.tr('_', '-')}.md"
|
|
160
|
+
File.join(config.generated_docs_dir, output_name)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def generate_markdown(output_file, doc_comments, screenshot_calls)
|
|
164
|
+
FileUtils.mkdir_p(config.generated_docs_dir)
|
|
165
|
+
File.write(output_file, "#{timestamp_header}#{render_doc_body(doc_comments, screenshot_calls).rstrip}\n")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def timestamp_header
|
|
169
|
+
"<!--\nGenerated: #{generated_at}\n-->\n\n"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def generated_at
|
|
173
|
+
Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Render the markdown body (everything after the timestamp header) from a
|
|
177
|
+
# spec's doc comments and screenshot calls. Shared by generation and --check
|
|
178
|
+
# so both produce byte-identical output for the same spec.
|
|
179
|
+
def render_doc_body(doc_comments, screenshot_calls)
|
|
180
|
+
body = +''
|
|
181
|
+
|
|
182
|
+
doc_comments.each_with_index do |comment, index|
|
|
183
|
+
heading_tag = "h#{comment[:level]}"
|
|
184
|
+
heading_id = slugify(comment[:title])
|
|
185
|
+
body << "<#{heading_tag} id=\"#{heading_id}\">#{comment[:title]}</#{heading_tag}>\n\n"
|
|
186
|
+
|
|
187
|
+
comment[:content].each { |line| body << "#{line}\n" }
|
|
188
|
+
body << "\n"
|
|
189
|
+
|
|
190
|
+
next_comment_line = doc_comments[index + 1]&.dig(:line_number) || Float::INFINITY
|
|
191
|
+
screenshots = find_related_screenshots(comment[:line_number], next_comment_line, screenshot_calls)
|
|
192
|
+
screenshots.each do |screenshot|
|
|
193
|
+
rendered = render_screenshot(screenshot[:path])
|
|
194
|
+
body << rendered if rendered
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
body
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def find_related_screenshots(current_line, next_line, screenshot_calls)
|
|
202
|
+
# Find screenshots that appear after this comment but before the next one
|
|
203
|
+
screenshot_calls.select do |screenshot|
|
|
204
|
+
screenshot[:line_number] > current_line && screenshot[:line_number] < next_line
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def render_screenshot(screenshot_path)
|
|
209
|
+
# Only embed screenshots whose generated file exists on disk. Image URLs
|
|
210
|
+
# mirror screenshots_dir so a customized dir keeps working links.
|
|
211
|
+
return unless File.exist?(File.join(config.screenshots_dir, "#{screenshot_path}.png"))
|
|
212
|
+
|
|
213
|
+
prefix = "/#{config.screenshots_dir.sub(%r{\A/+}, '').sub(%r{/+\z}, '')}"
|
|
214
|
+
"\n\n"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def slugify(text)
|
|
218
|
+
text.downcase
|
|
219
|
+
.gsub(/[^a-z0-9\s-]/, '')
|
|
220
|
+
.gsub(/\s+/, '-').squeeze('-')
|
|
221
|
+
.gsub(/^-|-$/, '')
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Writes the README table of contents grouped by `@category` and ordered by
|
|
225
|
+
# `@order`. Called at the end of a full/partial generation run and standalone
|
|
226
|
+
# via `regenerate_readme` (the `--readme-only` mode).
|
|
227
|
+
def generate_readme
|
|
228
|
+
readme_path = File.join(config.generated_docs_dir, 'README.md')
|
|
229
|
+
|
|
230
|
+
File.open(readme_path, 'w') do |f|
|
|
231
|
+
f.puts '<!--'
|
|
232
|
+
f.puts "Generated: #{generated_at}"
|
|
233
|
+
f.puts '-->'
|
|
234
|
+
f.puts
|
|
235
|
+
|
|
236
|
+
f.write(config.readme_preamble || README_PREAMBLE)
|
|
237
|
+
|
|
238
|
+
# Build the TOC from every documented spec (not just the ones regenerated
|
|
239
|
+
# in this run) so running for a subset of specs doesn't drop other
|
|
240
|
+
# entries. Group by `@category` and order by `@order`.
|
|
241
|
+
groups = toc_groups
|
|
242
|
+
|
|
243
|
+
f.puts category_workflow_diagram(groups.map(&:first))
|
|
244
|
+
f.puts
|
|
245
|
+
f.puts '<h2 id="table-of-contents">Table of Contents</h2>'
|
|
246
|
+
|
|
247
|
+
groups.each do |category, entries|
|
|
248
|
+
f.puts
|
|
249
|
+
f.puts %(<h3 id="#{slugify(category)}">#{category}</h3>)
|
|
250
|
+
f.puts
|
|
251
|
+
entries.each do |entry|
|
|
252
|
+
f.puts "- [#{entry[:title]}](#{entry[:file]})"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# A Mermaid flowchart of the guide categories in workflow order, rendered by
|
|
259
|
+
# the in-app docs viewer. Derived from the same ordered categories as the TOC,
|
|
260
|
+
# so it never drifts from the actual guides.
|
|
261
|
+
def category_workflow_diagram(categories)
|
|
262
|
+
return '' if categories.empty?
|
|
263
|
+
|
|
264
|
+
nodes = categories.each_with_index.map { |category, index| %(c#{index}["#{category}"]) }
|
|
265
|
+
<<~DIAGRAM.chomp
|
|
266
|
+
|
|
267
|
+
```mermaid
|
|
268
|
+
flowchart LR
|
|
269
|
+
#{nodes.join(' --> ')}
|
|
270
|
+
```
|
|
271
|
+
DIAGRAM
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Returns the TOC as an ordered list of [category, entries] pairs.
|
|
275
|
+
# Category order is derived entirely from the specs: categories are
|
|
276
|
+
# listed in ascending order of the smallest `@order` they contain (ties broken
|
|
277
|
+
# alphabetically), and entries within a category are ordered by `@order` then
|
|
278
|
+
# title. Only specs whose generated doc actually exists on disk are linked.
|
|
279
|
+
def toc_groups
|
|
280
|
+
entries = find_documented_specs.filter_map do |spec_file|
|
|
281
|
+
file = File.basename(generate_output_filename(spec_file))
|
|
282
|
+
next unless File.exist?(File.join(config.generated_docs_dir, file))
|
|
283
|
+
|
|
284
|
+
parsed = spec(spec_file)
|
|
285
|
+
directives = parsed.directives
|
|
286
|
+
{
|
|
287
|
+
file:,
|
|
288
|
+
title: parsed.title,
|
|
289
|
+
category: directives[:category] || DEFAULT_CATEGORY,
|
|
290
|
+
order: directives[:order] || DEFAULT_ORDER
|
|
291
|
+
}
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
entries
|
|
295
|
+
.group_by { |entry| entry[:category] }
|
|
296
|
+
.sort_by { |category, group| [group.map { |entry| entry[:order] }.min, category] }
|
|
297
|
+
.map { |category, group| [category, group.sort_by { |entry| [entry[:order], entry[:title]] }] }
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'auto_platform_guide'
|
|
4
|
+
|
|
5
|
+
module AutoPlatformGuide
|
|
6
|
+
# Rake task bodies. Tasks themselves are declared below; this module keeps the
|
|
7
|
+
# exit-status handling testable without invoking rake.
|
|
8
|
+
module RakeTasks
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def run(argv, failure:)
|
|
12
|
+
status = Generator.run(argv)
|
|
13
|
+
return if status.zero?
|
|
14
|
+
|
|
15
|
+
abort(failure)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
namespace :auto_platform_guide do
|
|
21
|
+
desc 'Generate guides from every documented system spec'
|
|
22
|
+
task :generate, [:spec_file] do |_task, args|
|
|
23
|
+
spec_files = [args[:spec_file], *args.extras].compact
|
|
24
|
+
AutoPlatformGuide::RakeTasks.run(spec_files, failure: 'Guide generation failed')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc 'Verify committed guides match their specs; fails when stale'
|
|
28
|
+
task :check do
|
|
29
|
+
AutoPlatformGuide::RakeTasks.run(
|
|
30
|
+
['--check'],
|
|
31
|
+
failure: 'Committed guides are stale; run rake auto_platform_guide:generate'
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
desc 'Rebuild only the README table of contents'
|
|
36
|
+
task :readme do
|
|
37
|
+
AutoPlatformGuide::RakeTasks.run(['--readme-only'], failure: 'README generation failed')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc 'Re-render guides from doc comments, reusing screenshots on disk'
|
|
41
|
+
task :render do
|
|
42
|
+
AutoPlatformGuide::RakeTasks.run(['--render-only'], failure: 'Guide rendering failed')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/object/blank'
|
|
4
|
+
require 'capybara'
|
|
5
|
+
|
|
6
|
+
module AutoPlatformGuide
|
|
7
|
+
# RSpec wiring for system specs: a rack_test default, a real browser where a
|
|
8
|
+
# spec needs one (examples tagged :js, plus every system spec when
|
|
9
|
+
# SCREENSHOTS=true), doc_screenshot support, and forgery protection.
|
|
10
|
+
#
|
|
11
|
+
# RSpec.configure do |config|
|
|
12
|
+
# AutoPlatformGuide.setup_rspec(config)
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# The browser driver is configuration.browser_driver (:playwright default,
|
|
16
|
+
# :selenium supported).
|
|
17
|
+
module DocScreenshotHelper
|
|
18
|
+
# Conditional screenshot capture. Enable with: SCREENSHOTS=true rspec spec/...
|
|
19
|
+
def doc_screenshot(filename)
|
|
20
|
+
return unless AutoPlatformGuide.screenshots_enabled?
|
|
21
|
+
|
|
22
|
+
sleep 0.5 # Allow time for animations to complete
|
|
23
|
+
save_screenshot(Rails.root.join(AutoPlatformGuide.configuration.screenshots_dir, "#{filename}.png")) # rubocop:disable Lint/Debugger
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.setup_rspec(config)
|
|
28
|
+
# A contended machine (CI, or sibling spec:parallel workers) lets async Turbo
|
|
29
|
+
# Stream deliveries exceed a short wait. Only the slow path lengthens.
|
|
30
|
+
contended = ci? || ENV.key?('TEST_ENV_NUMBER')
|
|
31
|
+
max_wait_time = contended ? 10 : 5
|
|
32
|
+
|
|
33
|
+
screen_size = ENV.fetch('CAPYBARA_WINDOW_SIZE', '1920,900').split(',').map(&:to_i)
|
|
34
|
+
browser_options = driver_options(max_wait_time)
|
|
35
|
+
|
|
36
|
+
configure_capybara
|
|
37
|
+
|
|
38
|
+
Capybara.save_path = './log/capybara'
|
|
39
|
+
|
|
40
|
+
config.include DocScreenshotHelper, type: :system
|
|
41
|
+
|
|
42
|
+
# rack_test by default, for performance
|
|
43
|
+
config.before(type: :system) do
|
|
44
|
+
AutoPlatformGuide.prepare_system_example(self, max_wait_time, screen_size, browser_options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
config.around(:each, type: :system) do |example|
|
|
48
|
+
AutoPlatformGuide.with_forgery_protection(example)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
config.before(type: :system, js: true) do
|
|
52
|
+
AutoPlatformGuide.prepare_js_example(self, screen_size, browser_options)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.prepare_system_example(example, max_wait_time, screen_size, browser_options)
|
|
57
|
+
Capybara.default_max_wait_time = max_wait_time
|
|
58
|
+
|
|
59
|
+
if screenshots_enabled?
|
|
60
|
+
drive_with_browser(example, screen_size:, options: browser_options)
|
|
61
|
+
else
|
|
62
|
+
example.driven_by(:rack_test)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.with_forgery_protection(example)
|
|
67
|
+
ActionController::Base.allow_forgery_protection = true
|
|
68
|
+
example.run
|
|
69
|
+
ensure
|
|
70
|
+
ActionController::Base.allow_forgery_protection = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.prepare_js_example(example, screen_size, browser_options)
|
|
74
|
+
drive_with_browser(example, screen_size:, options: browser_options)
|
|
75
|
+
attach_browser_logging(example) if configuration.browser_driver == :playwright
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.configure_capybara
|
|
79
|
+
Capybara.configure do |capybara|
|
|
80
|
+
capybara.server_host = ENV.fetch(
|
|
81
|
+
'CAPYBARA_SERVER_HOST',
|
|
82
|
+
ci? ? IPSocket.getaddress(Socket.gethostname) : 'localhost'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Different ports per parallel worker to avoid conflicts
|
|
86
|
+
base_port = ci? ? 3000 : 3500
|
|
87
|
+
port_offset = ENV['TEST_ENV_NUMBER'].to_i
|
|
88
|
+
capybara.server_port = base_port + port_offset
|
|
89
|
+
|
|
90
|
+
capybara.app_host = ENV.fetch('CAPYBARA_APP_HOST', "http://#{capybara.server_host}:#{capybara.server_port}")
|
|
91
|
+
capybara.raise_server_errors = false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.drive_with_browser(example, screen_size:, options:)
|
|
96
|
+
case configuration.browser_driver
|
|
97
|
+
when :playwright
|
|
98
|
+
example.driven_by(:playwright, screen_size:, options:)
|
|
99
|
+
when :selenium
|
|
100
|
+
example.driven_by(:selenium, using: :headless_chrome, screen_size:)
|
|
101
|
+
else
|
|
102
|
+
raise ArgumentError, "Unknown browser_driver: #{configuration.browser_driver.inspect}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Each example gets a fresh browser context, so web storage cannot leak between them.
|
|
107
|
+
def self.attach_browser_logging(example)
|
|
108
|
+
example.page.driver.with_playwright_page do |playwright_page|
|
|
109
|
+
playwright_page.on('console', lambda { |message|
|
|
110
|
+
Rails.logger.error("[BROWSER][#{message.type}] #{message.text}") if message.type == 'error'
|
|
111
|
+
})
|
|
112
|
+
playwright_page.on('pageerror', lambda { |error|
|
|
113
|
+
Rails.logger.error("[BROWSER][pageerror] #{error.message}")
|
|
114
|
+
})
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.driver_options(max_wait_time)
|
|
119
|
+
return {} unless configuration.browser_driver == :playwright
|
|
120
|
+
|
|
121
|
+
# Keystrokes would otherwise inherit Playwright's own 30s timeout; navigation keeps a
|
|
122
|
+
# long budget because a click that waits for one is bounded by the action timeout.
|
|
123
|
+
{ default_timeout: max_wait_time, default_navigation_timeout: 30 }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.ci?
|
|
127
|
+
ENV['CI'].present?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.screenshots_enabled?
|
|
131
|
+
ENV['SCREENSHOTS'] == 'true'
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AutoPlatformGuide
|
|
4
|
+
# A system spec file carrying `# = Heading` doc comments and
|
|
5
|
+
# `doc_screenshot('name')` calls. Parses it once and exposes what the
|
|
6
|
+
# generator needs: headings with their body, screenshots, TOC directives and
|
|
7
|
+
# the doc title.
|
|
8
|
+
class SpecFile
|
|
9
|
+
# One `=` per heading level: `# = Title`, `# == Sub-title`.
|
|
10
|
+
HEADING = /^\s*#\s*(=+)\s*(.+?)\s*$/
|
|
11
|
+
# Exactly one space after `#` is the comment prefix; anything beyond it is
|
|
12
|
+
# content, so indented code inside fenced blocks survives.
|
|
13
|
+
COMMENT = /^\s*#\s?(.*?)\s*$/
|
|
14
|
+
|
|
15
|
+
# `@category` / `@order` control TOC placement only and are stripped from the
|
|
16
|
+
# body. `@order` is global across guides and categories sort by their
|
|
17
|
+
# smallest `@order`, so there is no category list to keep in sync.
|
|
18
|
+
CATEGORY_DIRECTIVE = /^\s*#\s*@category\s+(.+?)\s*$/
|
|
19
|
+
ORDER_DIRECTIVE = /^\s*#\s*@order\s+(\d+)\s*$/
|
|
20
|
+
DIRECTIVE = /^\s*#\s*@(?:category|order)\b/
|
|
21
|
+
|
|
22
|
+
def initialize(path)
|
|
23
|
+
@path = path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# True when the spec has at least one top-level doc comment.
|
|
27
|
+
def documented?
|
|
28
|
+
lines.any? { |line| line.match?(/^#\s*=/) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Comment blocks in source order: `{ level:, title:, content:, line_number: }`.
|
|
32
|
+
# A block runs until the next heading or the first non-comment line;
|
|
33
|
+
# `@category`/`@order` lines are TOC metadata, never body content.
|
|
34
|
+
def doc_comments
|
|
35
|
+
@doc_comments ||= parse_doc_comments
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# `{ path:, line_number: }` for every `doc_screenshot('...')` call.
|
|
39
|
+
def screenshot_calls
|
|
40
|
+
@screenshot_calls ||= parse_screenshot_calls
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# First `@category` / `@order` in the file, or nil.
|
|
44
|
+
def directives
|
|
45
|
+
@directives ||= { category: first_match(CATEGORY_DIRECTIVE), order: first_match(ORDER_DIRECTIVE)&.to_i }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The top-level (`# =`, not `# ==`) heading, falling back to the file name.
|
|
49
|
+
def title
|
|
50
|
+
@title ||= lines.filter_map { |line| line.match(/^\s*#\s*=(?!=)\s*(.+?)\s*$/)&.[](1)&.strip }
|
|
51
|
+
.reject(&:empty?).first ||
|
|
52
|
+
File.basename(@path, '_spec.rb').split('_').map(&:capitalize).join(' ')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def lines
|
|
58
|
+
@lines ||= File.readlines(@path, encoding: 'UTF-8')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def first_match(pattern)
|
|
62
|
+
lines.filter_map { |line| line.match(pattern)&.[](1) }.first
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_doc_comments
|
|
66
|
+
blocks = []
|
|
67
|
+
current = nil
|
|
68
|
+
|
|
69
|
+
lines.each.with_index(1) do |line, number|
|
|
70
|
+
next if DIRECTIVE.match?(line)
|
|
71
|
+
|
|
72
|
+
if (heading = line.match(HEADING))
|
|
73
|
+
blocks << current if current
|
|
74
|
+
title = heading[2].strip
|
|
75
|
+
# A divider line like `# ==== ` has no title and is not a heading.
|
|
76
|
+
current = title.empty? ? nil : { level: heading[1].length, title:, content: [], line_number: number }
|
|
77
|
+
elsif current.nil?
|
|
78
|
+
next
|
|
79
|
+
elsif (comment = line.match(COMMENT))
|
|
80
|
+
current[:content] << comment[1]
|
|
81
|
+
else
|
|
82
|
+
blocks << current
|
|
83
|
+
current = nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
blocks << current if current
|
|
88
|
+
blocks
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_screenshot_calls
|
|
92
|
+
call = %r{doc_screenshot\(['"]([\w\-/]+)['"]\)}
|
|
93
|
+
|
|
94
|
+
lines.each.with_index(1).filter_map do |line, number|
|
|
95
|
+
path = line[call, 1]
|
|
96
|
+
{ path:, line_number: number } if path
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'auto_platform_guide/version'
|
|
4
|
+
require 'auto_platform_guide/configuration'
|
|
5
|
+
require 'auto_platform_guide/spec_file'
|
|
6
|
+
require 'auto_platform_guide/document'
|
|
7
|
+
require 'auto_platform_guide/generator'
|
|
8
|
+
require 'auto_platform_guide/docs_controller_actions'
|
|
9
|
+
|
|
10
|
+
module AutoPlatformGuide
|
|
11
|
+
def self.load_engine
|
|
12
|
+
require 'auto_platform_guide/engine' if defined?(Rails::Engine)
|
|
13
|
+
end
|
|
14
|
+
private_class_method :load_engine
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
AutoPlatformGuide.send(:load_engine)
|
metadata
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: auto-platform-guide
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mauricio Zaffari
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: actionview
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: activemodel
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '7.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '7.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: capybara
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.40'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.40'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: railties
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '7.1'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '7.1'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: redcarpet
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.6'
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.6'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: rouge
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '5.0'
|
|
103
|
+
type: :runtime
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '5.0'
|
|
110
|
+
description: Generator and spec helpers that turn doc comments and screenshots in
|
|
111
|
+
system specs into markdown platform guides, plus a markdown docs viewer for Rails
|
|
112
|
+
admin apps.
|
|
113
|
+
email:
|
|
114
|
+
- mauricio@develoz.com
|
|
115
|
+
executables: []
|
|
116
|
+
extensions: []
|
|
117
|
+
extra_rdoc_files: []
|
|
118
|
+
files:
|
|
119
|
+
- CHANGELOG.md
|
|
120
|
+
- LICENSE.txt
|
|
121
|
+
- README.md
|
|
122
|
+
- app/views/docs/show.html.erb
|
|
123
|
+
- lib/auto-platform-guide.rb
|
|
124
|
+
- lib/auto_platform_guide.rb
|
|
125
|
+
- lib/auto_platform_guide/configuration.rb
|
|
126
|
+
- lib/auto_platform_guide/docs_controller_actions.rb
|
|
127
|
+
- lib/auto_platform_guide/document.rb
|
|
128
|
+
- lib/auto_platform_guide/engine.rb
|
|
129
|
+
- lib/auto_platform_guide/generator.rb
|
|
130
|
+
- lib/auto_platform_guide/rake_tasks.rb
|
|
131
|
+
- lib/auto_platform_guide/rspec.rb
|
|
132
|
+
- lib/auto_platform_guide/spec_file.rb
|
|
133
|
+
- lib/auto_platform_guide/version.rb
|
|
134
|
+
homepage: https://github.com/mauriciozaffari/auto-platform-guide
|
|
135
|
+
licenses:
|
|
136
|
+
- MIT
|
|
137
|
+
metadata:
|
|
138
|
+
source_code_uri: https://github.com/mauriciozaffari/auto-platform-guide
|
|
139
|
+
changelog_uri: https://github.com/mauriciozaffari/auto-platform-guide/blob/main/CHANGELOG.md
|
|
140
|
+
rubygems_mfa_required: 'true'
|
|
141
|
+
rdoc_options: []
|
|
142
|
+
require_paths:
|
|
143
|
+
- lib
|
|
144
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
145
|
+
requirements:
|
|
146
|
+
- - ">="
|
|
147
|
+
- !ruby/object:Gem::Version
|
|
148
|
+
version: '3.2'
|
|
149
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
150
|
+
requirements:
|
|
151
|
+
- - ">="
|
|
152
|
+
- !ruby/object:Gem::Version
|
|
153
|
+
version: '0'
|
|
154
|
+
requirements: []
|
|
155
|
+
rubygems_version: 4.0.16
|
|
156
|
+
specification_version: 4
|
|
157
|
+
summary: Generate in-app documentation from system specs.
|
|
158
|
+
test_files: []
|