howdoc 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: 02cc27d9848b0932cc7ba10c66b8e5682c955071f2cde03f27e6c9061c52d82c
4
+ data.tar.gz: 1bed4d6da3860c0ea8ac1f3f680218f24941f45fbb7d8e3b0699085e70588811
5
+ SHA512:
6
+ metadata.gz: 72e52519fec7d24abdb8765aa146702ef053a627fd87e4b40e1ff77775ba69ed626db5250accbb307719bbfee68a7f12e8d1fac6d966a0ab05df9859a1b38326
7
+ data.tar.gz: 70208bdc3b1557985b69bbdceb70ade90a91c39b9063412ed922df7cd1cae26e5629875dd1f66a16277364af39d49eff10ede8df80695c678a1792675f0f0b42
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. Extracted from a working in-house documentation helper that had
6
+ been generating user guides from a Rails application's Capybara system tests.
7
+
8
+ - Narrates Capybara actions into prose through I18n, so the vocabulary is
9
+ translatable and no sentence is hardcoded in the engine. English and Estonian
10
+ are included.
11
+ - Captures a screenshot per interesting step.
12
+ - Writes an illustrated HTML guide per document.
13
+ - Builds an index across all generated guides, read back from the pages
14
+ themselves at the end of the run.
15
+ - Templates are Haml and overridable per directory, so an application supplies
16
+ its own page chrome without forking the gem.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Priit Tark
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,221 @@
1
+ # Howdoc
2
+
3
+ Generate end-user documentation from your Capybara system tests.
4
+
5
+ A system test already knows every step a person would take through your
6
+ application: which link they click, what they type, what they should see next.
7
+ Howdoc listens to those steps, narrates them in prose, photographs the browser
8
+ along the way, and writes an illustrated guide.
9
+
10
+ Because the guide is produced by a test that has to pass, it cannot quietly
11
+ drift away from the application it describes. When the workflow changes, either
12
+ the guide changes with it or the test goes red.
13
+
14
+ ```
15
+ test 'should add a claim', doc: '1.3', permalink: 'how_to_add_a_claim' do
16
+ sign_in :user
17
+
18
+ tap_menu ['Contacts', 'Claims']
19
+ tap_link 'New'
20
+
21
+ fill_in 'Ref', with: 'TestREF'
22
+ select 'Test', from: 'Contact'
23
+ click_button 'Save'
24
+
25
+ assert_text 'TestREF'
26
+ end
27
+ ```
28
+
29
+ becomes a page titled *1.3. How to add a claim?* with numbered instructions and
30
+ a screenshot at each point where the reader arrives somewhere new.
31
+
32
+ ## Installation
33
+
34
+ ```ruby
35
+ group :test do
36
+ gem 'howdoc'
37
+ end
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ ```ruby
43
+ Howdoc.configure do |config|
44
+ config.root = 'public/docs' # where guides are written
45
+ config.enabled = ENV['DOC'].present? # off unless asked for
46
+ config.host = 'example.com' # the address a reader should open
47
+ config.formats = %i[html]
48
+
49
+ config.register_locale_path 'test/howdoc/locales/*.yml'
50
+ config.register_template_path 'test/howdoc/templates'
51
+ end
52
+ ```
53
+
54
+ Nothing above has a sensible universal default except the formats, which is why
55
+ none of it is baked into the engine.
56
+
57
+ ## Wiring it into a Minitest suite
58
+
59
+ Howdoc does not guess how your suite starts and finishes a test. The wiring is
60
+ short and belongs in your own test case, where you can see it:
61
+
62
+ ```ruby
63
+ require 'howdoc/capybara_actions'
64
+
65
+ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
66
+ include Howdoc::CapybaraActions # include last: each wrapper ends in super
67
+
68
+ setup do
69
+ Howdoc.start(
70
+ id: metadata[:doc],
71
+ permalink: metadata[:permalink],
72
+ intro: metadata[:intro],
73
+ title: "How to #{metadata[:description]}?",
74
+ locale: I18n.locale
75
+ )
76
+ end
77
+
78
+ teardown { Howdoc.finish }
79
+ end
80
+ ```
81
+
82
+ `Howdoc.finish` is called in `teardown` rather than after a successful
83
+ assertion, so a test that breaks halfway still leaves a partial guide showing
84
+ exactly how far the reader would have got.
85
+
86
+ Once the whole run is over, build the index:
87
+
88
+ ```ruby
89
+ Howdoc::MinitestIntegration.install_at_exit!
90
+ ```
91
+
92
+ or call `Howdoc.finalize` from wherever your suite already reports completion.
93
+ Tests run in forked workers, so the index is assembled at the end from the
94
+ manifest each guide leaves on disk, not from anything held in memory.
95
+
96
+ ## What ends up in a guide
97
+
98
+ Howdoc wraps the Capybara verbs a person would recognise as an instruction:
99
+ `visit`, `fill_in`, `select`, `check`, `uncheck`, `choose`, `click_button`,
100
+ `click_link`, `click_on`, `attach_file` and `assert_text`. Everything else your
101
+ test does happens silently.
102
+
103
+ Each of them accepts four extra options, removed before the call reaches
104
+ Capybara:
105
+
106
+ | Option | Effect |
107
+ | --- | --- |
108
+ | `nodoc: true` | leave this action out of the guide |
109
+ | `screenshot: true` | illustrate an action that normally is not illustrated |
110
+ | `no_screenshot: true` | skip the illustration |
111
+ | `full_page: true` | capture the whole scrollable page |
112
+
113
+ By default `visit` and `assert_text` are illustrated and the action verbs are
114
+ not, on the grounds that a picture is worth showing when the reader arrives
115
+ somewhere, not every time they press a button.
116
+
117
+ ## Your own helpers
118
+
119
+ Real suites have helpers Capybara has no verb for -- a custom editor, a menu
120
+ widget, a checkbox drawn as something else. Howdoc cannot know about those, so
121
+ it exposes the same recording API its own wrappers use:
122
+
123
+ ```ruby
124
+ def choose_from_fancy_menu(*path)
125
+ Howdoc.record(:fancy_menu, path: path.join(' → '))
126
+ Howdoc.capture(page)
127
+
128
+ within('#navbar') { path.each { |item| click_link(item, nodoc: true) } }
129
+ end
130
+ ```
131
+
132
+ `Howdoc.record` is a no-op when documentation is switched off, so helpers need
133
+ no guards. Pass `html:` instead of an action name for the rare step no sentence
134
+ describes.
135
+
136
+ Three actions come translated even though no wrapper records them, because
137
+ almost every suite ends up writing a helper that needs one: `menu` (a `path`),
138
+ `fill_in_editor` (a `value`) and `sign_in_required` (a `text`).
139
+
140
+ ## Wording
141
+
142
+ Every sentence is an I18n key under `howdoc.actions`, so the engine contains no
143
+ prose of its own. English and Estonian ship with the gem, complete; an
144
+ application needs no translation file of its own unless it changes the wording,
145
+ adds a language, or records an action of its own invention:
146
+
147
+ ```yaml
148
+ fi:
149
+ howdoc:
150
+ actions:
151
+ click_button: Paina painiketta <strong>"%{locator}"</strong>.
152
+ ```
153
+
154
+ Interpolated values are HTML-escaped, the translations carry the markup.
155
+
156
+ Field names are humanised before they reach a translation: `user_email` becomes
157
+ "email", because a reader does not know your schema. Give a field a name of its
158
+ own by translating `howdoc.fields.<locator>`, or replace the whole convention:
159
+
160
+ ```ruby
161
+ config.field_label { |locator| MyLabels.for(locator) }
162
+ ```
163
+
164
+ Any key that is used but not translated is reported at the end of the run rather
165
+ than leaving a silent hole in the guide.
166
+
167
+ ## Templates
168
+
169
+ Page chrome lives in templates, never in the code that records steps, so
170
+ restyling a guide does not mean running a browser suite again.
171
+
172
+ Templates are Haml, looked up as
173
+ `<registered path>/default/<type>/<format>/<file>`, searched most recently
174
+ registered first, with the gem's own set searched last. To change only the page
175
+ shell, mirror that one path:
176
+
177
+ ```
178
+ test/howdoc/templates/default/document/html/layout.haml
179
+ ```
180
+
181
+ Everything else keeps coming from the gem. The arrangement is borrowed from
182
+ YARD, which has been proving it works for well over a decade.
183
+
184
+ Assigns arrive as instance variables (`@document`, `@records`, `@config`) and
185
+ `render` pulls in a partial. Haml escapes `=` output, so a narrated sentence --
186
+ which carries its own markup and whose values were escaped when the step was
187
+ recorded -- is written with `!=`.
188
+
189
+ An overriding document layout has exactly one obligation: keep the heading in
190
+ `<title>`. That is what the index reads. A page without one is left out of the
191
+ index rather than listed blank. Everything else the index shows -- which locale
192
+ a guide belongs to, what it links to -- comes from where the file sits.
193
+
194
+ ## Output
195
+
196
+ ```
197
+ public/docs/
198
+ assets/howdoc.css
199
+ en/
200
+ index.html
201
+ how_to_add_a_claim.html
202
+ images/how_to_add_a_claim_4.png
203
+ ```
204
+
205
+ A guide is a page and nothing else. The index is built at the end of the run by
206
+ reading those pages back: tests run in forked workers, so nothing survives in
207
+ memory, and the pages are the only record of what was produced. Their markup is
208
+ not a foreign format to be parsed defensively -- this gem wrote it.
209
+
210
+ ## Rake tasks
211
+
212
+ ```ruby
213
+ require 'howdoc/tasks'
214
+ ```
215
+
216
+ * `howdoc:clean` — remove generated guides, leaving `config.preserved_files` alone
217
+ * `howdoc:index` — rebuild the index from manifests already on disk
218
+
219
+ ## Licence
220
+
221
+ MIT.
@@ -0,0 +1,24 @@
1
+ en:
2
+ howdoc:
3
+ actions:
4
+ assert_text: You should now see <strong>"%{text}"</strong>.
5
+ attach_file: Attach a file to <strong>%{field}</strong>.
6
+ check: Tick <strong>"%{field}"</strong>.
7
+ choose: Choose <strong>"%{field}"</strong>.
8
+ click_button: Press the <strong>"%{locator}"</strong> button.
9
+ click_link: Click the <strong>"%{locator}"</strong> link.
10
+ click_on: Click <strong>"%{locator}"</strong>.
11
+ fill_in: Fill in <strong>%{field}</strong> with <strong>"%{value}"</strong>.
12
+ fill_in_editor: Fill the text editor with <strong>"%{value}"</strong>.
13
+ fill_in_email: Enter an email address, for example <strong>"%{value}"</strong>.
14
+ fill_in_password: Enter a password, for example <strong>"%{value}"</strong>.
15
+ menu: 'Click menu items: <strong>"%{path}"</strong>.'
16
+ select: Select <strong>"%{value}"</strong> from <strong>%{field}</strong>.
17
+ sign_in_required: Please log in to the system to access <strong>"%{text}"</strong>.
18
+ uncheck: Clear the <strong>"%{field}"</strong> tick.
19
+ visit: Open <strong>%{url}</strong> in your browser.
20
+ document:
21
+ follow_steps: 'Follow these steps:'
22
+ index:
23
+ empty: No guides have been generated yet.
24
+ title: Guides
@@ -0,0 +1,24 @@
1
+ et:
2
+ howdoc:
3
+ actions:
4
+ assert_text: Näete teksti <strong>"%{text}"</strong>.
5
+ attach_file: Lisa fail väljale <strong>%{field}</strong>.
6
+ check: Lisa linnuke <strong>"%{field}"</strong>.
7
+ choose: Vali <strong>"%{field}"</strong>.
8
+ click_button: Vajuta nupule <strong>"%{locator}"</strong>.
9
+ click_link: Vajuta lingile <strong>"%{locator}"</strong>.
10
+ click_on: Klõpsa <strong>"%{locator}"</strong>.
11
+ fill_in: Sisesta näiteks <strong>"%{value}"</strong>.
12
+ fill_in_editor: Sisesta tekstiredaktorisse näiteks <strong>"%{value}"</strong>.
13
+ fill_in_email: Sisesta eposti aadress, näiteks <strong>"%{value}"</strong>.
14
+ fill_in_password: Sisesta parool, näiteks <strong>"%{value}"</strong>.
15
+ menu: 'Vali menüüs: <strong>"%{path}"</strong>.'
16
+ select: Vali <strong>"%{value}"</strong> hüpikmenüüst "%{field}".
17
+ sign_in_required: 'Palun logige süsteemi sisse, et pääseda rakenduse keskkonda: <strong>"%{text}"</strong>.'
18
+ uncheck: Eemalda linnuke <strong>"%{field}"</strong>.
19
+ visit: Palun sisesta <strong>%{url}</strong> oma brauserisse.
20
+ document:
21
+ follow_steps: 'Palun jälgige järgnevaid samme:'
22
+ index:
23
+ empty: Ühtegi juhendit ei ole veel loodud.
24
+ title: Juhendid
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'howdoc'
4
+
5
+ module Howdoc
6
+ # Wraps the Capybara verbs a person would recognise as an instruction, so an
7
+ # ordinary test narrates itself. Include it after any other module that wraps
8
+ # the same verbs -- Ruby searches the most recently included module first, and
9
+ # each wrapper here ends in +super+.
10
+ #
11
+ # Every wrapper accepts four extra options, which are removed before the call
12
+ # reaches Capybara:
13
+ #
14
+ # nodoc: leave this action out of the guide entirely
15
+ # screenshot: illustrate this action even though it normally is not
16
+ # no_screenshot: do not illustrate this action even though it normally is
17
+ # full_page: capture the whole scrollable page, not one screenful
18
+ #
19
+ # Anything the application does that Capybara has no verb for is recorded by
20
+ # calling Howdoc.record directly from the application's own helper.
21
+ module CapybaraActions
22
+ def visit(visit_uri, **options)
23
+ doc = Howdoc.extract_options!(options)
24
+ result = super(visit_uri)
25
+
26
+ narrate(:visit, doc, capture: true, url: displayed_url(visit_uri))
27
+ result
28
+ end
29
+
30
+ def fill_in(locator = nil, with:, currently_with: nil, fill_options: {}, **options)
31
+ doc = Howdoc.extract_options!(options)
32
+ label = howdoc_field_label(locator)
33
+
34
+ narrate(fill_in_action(label), doc, field: label, value: with)
35
+
36
+ super(locator, with:, currently_with:, fill_options:, **options)
37
+ end
38
+
39
+ def select(value = nil, from: nil, **options)
40
+ doc = Howdoc.extract_options!(options)
41
+
42
+ narrate(:select, doc, value:, field: howdoc_field_label(from))
43
+
44
+ super(value, from:, **options)
45
+ end
46
+
47
+ def check(locator = nil, **options)
48
+ doc = Howdoc.extract_options!(options)
49
+
50
+ narrate(:check, doc, field: howdoc_field_label(locator))
51
+
52
+ super(locator, **options)
53
+ end
54
+
55
+ def uncheck(locator = nil, **options)
56
+ doc = Howdoc.extract_options!(options)
57
+
58
+ narrate(:uncheck, doc, field: howdoc_field_label(locator))
59
+
60
+ super(locator, **options)
61
+ end
62
+
63
+ def choose(locator = nil, **options)
64
+ doc = Howdoc.extract_options!(options)
65
+
66
+ narrate(:choose, doc, field: howdoc_field_label(locator))
67
+
68
+ super(locator, **options)
69
+ end
70
+
71
+ def click_button(locator = nil, **options)
72
+ doc = Howdoc.extract_options!(options)
73
+
74
+ narrate(:click_button, doc, locator:)
75
+
76
+ super(locator, **options)
77
+ end
78
+
79
+ def click_link(locator = nil, **options)
80
+ doc = Howdoc.extract_options!(options)
81
+
82
+ narrate(:click_link, doc, locator:)
83
+
84
+ super(locator, **options)
85
+ end
86
+
87
+ def click_on(locator = nil, **options)
88
+ doc = Howdoc.extract_options!(options)
89
+
90
+ narrate(:click_on, doc, locator:)
91
+
92
+ super(locator, **options)
93
+ end
94
+
95
+ # rubocop:disable Style/OptionalArguments
96
+ def attach_file(locator = nil, paths, make_visible: nil, **options)
97
+ doc = Howdoc.extract_options!(options)
98
+
99
+ narrate(:attach_file, doc, field: howdoc_field_label(locator))
100
+
101
+ super(locator, paths, make_visible:, **options)
102
+ end
103
+ # rubocop:enable Style/OptionalArguments
104
+
105
+ # The one assertion that is also an instruction: it is how a guide tells the
106
+ # reader they have arrived somewhere and what they should see there.
107
+ def assert_text(locator, **options)
108
+ doc = Howdoc.extract_options!(options)
109
+ result = super(locator, **options)
110
+
111
+ narrate(:assert_text, doc, capture: true, arrival: true, text: locator)
112
+ result
113
+ end
114
+
115
+ private
116
+
117
+ def howdoc_field_label(locator)
118
+ Howdoc::Narrator.field_label(locator, locale: howdoc_locale)
119
+ end
120
+
121
+ def howdoc_locale
122
+ Howdoc.current&.locale || I18n.locale
123
+ end
124
+
125
+ def fill_in_action(label)
126
+ case label.to_s
127
+ when 'email' then :fill_in_email
128
+ when 'password' then :fill_in_password
129
+ else :fill_in
130
+ end
131
+ end
132
+
133
+ # A guide tells the reader to open the application's real address, not the
134
+ # ephemeral host and port the test server happens to be listening on.
135
+ def displayed_url(visit_uri)
136
+ host = Howdoc.config.host
137
+ host.nil? ? visit_uri.to_s : "https://#{host}#{visit_uri}"
138
+ end
139
+
140
+ def narrate(action, doc_options, capture: false, arrival: false, **payload)
141
+ step = Howdoc.record(action, arrival:, nodoc: doc_options[:nodoc], **payload)
142
+ return nil if step.nil?
143
+
144
+ illustrate = doc_options.fetch(:screenshot, capture)
145
+ return step unless illustrate
146
+
147
+ Howdoc.capture(
148
+ page,
149
+ full_page: doc_options[:full_page] || false,
150
+ no_screenshot: doc_options[:no_screenshot] || false
151
+ )
152
+ step
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Howdoc
4
+ # Everything an application is allowed to decide: where guides are written,
5
+ # what they are called, which templates dress them and how the index is
6
+ # ordered. The engine itself holds no application knowledge, so anything that
7
+ # would otherwise be hardcoded belongs here.
8
+ class Configuration
9
+ # Directory the generated guides are written into, relative to the working
10
+ # directory unless given as an absolute path.
11
+ attr_accessor :root
12
+
13
+ # Guides are only generated when this is true. Keep it tied to an
14
+ # environment variable so an ordinary test run stays fast.
15
+ attr_accessor :enabled
16
+
17
+ # Public host name of the documented application. It appears in the prose
18
+ # for +visit+, where telling the reader to open "localhost:9200" would be
19
+ # useless.
20
+ attr_accessor :host
21
+
22
+ # Writers to run for every finished document, in order.
23
+ attr_accessor :formats
24
+
25
+ # Locales to build an index for. Left nil, only the locales that actually
26
+ # produced a guide get one, which means a run in a single language quietly
27
+ # drops the other languages' indexes. Naming them keeps every index in
28
+ # place, empty ones included.
29
+ attr_accessor :locales
30
+
31
+ # Screenshots are taken at this height unless the step asked for a full
32
+ # page, so that guides do not mix wildly different image proportions.
33
+ attr_accessor :screenshot_height
34
+
35
+ # Files in the output directory that the clean task must leave alone,
36
+ # typically hand-maintained landing pages.
37
+ attr_accessor :preserved_files
38
+
39
+ # Whether the templates' assets directory is copied into the output. An
40
+ # application whose templates link its own stylesheets turns this off.
41
+ attr_accessor :install_assets
42
+
43
+ # Directories searched for templates, most recently registered first. The
44
+ # gem's own directory is always searched last, so an application overrides
45
+ # a single file by mirroring its path rather than copying the whole set.
46
+ attr_reader :template_paths
47
+
48
+ # Globs of translation files to add to I18n's load path. The gem's English
49
+ # is loaded first so an application only translates what it wants to change.
50
+ attr_reader :locale_paths
51
+
52
+ def initialize
53
+ @root = 'doc/howdoc'
54
+ @enabled = false
55
+ @host = nil
56
+ @formats = %i[html]
57
+ @locales = nil
58
+ @screenshot_height = 940
59
+ @preserved_files = %w[index.html .keep]
60
+ @install_assets = true
61
+ @template_paths = []
62
+ @locale_paths = []
63
+ @field_label = nil
64
+ @sort_key = nil
65
+ @group_label = nil
66
+ end
67
+
68
+ def register_template_path(path)
69
+ @template_paths.unshift(path.to_s)
70
+ end
71
+
72
+ def register_locale_path(glob)
73
+ @locale_paths << glob.to_s
74
+ end
75
+
76
+ # How a form field locator becomes something a reader recognises. The
77
+ # default turns +user_email+ into "email", which suits Rails' own naming.
78
+ # Applications with their own conventions replace it wholesale.
79
+ def field_label(&block)
80
+ return @field_label = block if block
81
+
82
+ @field_label ||= ->(locator) { Howdoc::Narrator.humanize_locator(locator) }
83
+ end
84
+
85
+ # Sorts documents in the index. Receives a Howdoc::Registry::Record.
86
+ def sort_key(&block)
87
+ return @sort_key = block if block
88
+
89
+ @sort_key ||= ->(record) { record.heading.to_s }
90
+ end
91
+
92
+ # Names the index section a document belongs under, or nil for no section.
93
+ # Receives a Howdoc::Registry::Record, so it decides from the heading, the
94
+ # identifier or the file name -- there is nowhere else for a section to
95
+ # come from once a guide is a page like any other.
96
+ def group_label(&block)
97
+ return @group_label = block if block
98
+
99
+ @group_label ||= ->(_record) {}
100
+ end
101
+
102
+ def enabled?
103
+ !!@enabled
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Howdoc
4
+ # One guide: the identity a test gave it, plus the steps recorded while that
5
+ # test ran. A document collects everything in memory and is written out once,
6
+ # so a writer always sees the whole guide rather than a half-built page.
7
+ class Document
8
+ # Letters that carry no accent to strip: they are their own character, so
9
+ # Unicode decomposition leaves them untouched and a filename filter would
10
+ # otherwise drop them entirely.
11
+ INDIVISIBLE = {
12
+ 'ß' => 'ss', 'æ' => 'ae', 'œ' => 'oe', 'ø' => 'o',
13
+ 'đ' => 'd', 'ð' => 'd', 'ł' => 'l', 'þ' => 'th'
14
+ }.freeze
15
+
16
+ attr_reader :id, :permalink, :title, :intro, :locale, :steps
17
+
18
+ def initialize(id:, title:, locale:, permalink: nil, intro: nil)
19
+ @id = id
20
+ @title = title
21
+ @locale = locale.to_sym
22
+ @permalink = permalink
23
+ @intro = intro
24
+ @steps = []
25
+ @counter = 0
26
+ end
27
+
28
+ def slug
29
+ @slug ||= (permalink || slugify(title)).to_s
30
+ end
31
+
32
+ def heading
33
+ id.nil? ? title : "#{id}. #{title}"
34
+ end
35
+
36
+ def new_step(**attributes)
37
+ @counter += 1
38
+ step = Step.new(number: @counter, **attributes)
39
+ steps << step
40
+ step
41
+ end
42
+
43
+ def last_step
44
+ steps.last
45
+ end
46
+
47
+ def dir
48
+ File.join(Howdoc.config.root, locale.to_s)
49
+ end
50
+
51
+ def image_dir
52
+ File.join(dir, 'images')
53
+ end
54
+
55
+ def path(extension)
56
+ File.join(dir, "#{slug}.#{extension}")
57
+ end
58
+
59
+ def image_filename(number)
60
+ "#{slug}_#{number}.png"
61
+ end
62
+
63
+ private
64
+
65
+ # Deliberately not ActiveSupport's parameterize: the engine has no business
66
+ # dragging Rails into a project that only wanted a documentation generator.
67
+ #
68
+ # Accents are decomposed and their marks dropped rather than deleted whole,
69
+ # so an Estonian title turns into "kuidas_lisada_noue" instead of the
70
+ # unreadable "kuidas_lisada_n_ue" a plain ASCII filter would leave behind.
71
+ def slugify(string)
72
+ string
73
+ .to_s
74
+ .downcase
75
+ .gsub(/[#{INDIVISIBLE.keys.join}]/, INDIVISIBLE)
76
+ .unicode_normalize(:nfd)
77
+ .gsub(/\p{Mn}/, '')
78
+ .downcase
79
+ .gsub(/[^a-z0-9]+/, '_')
80
+ .gsub(/\A_+|_+\z/, '')
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest'
4
+ require 'howdoc'
5
+
6
+ module Howdoc
7
+ # Optional convenience for Minitest suites. The gem deliberately does not try
8
+ # to guess how a project starts and finishes a test, because that wiring is
9
+ # six readable lines in the project's own test case and any guess would be
10
+ # wrong somewhere. All this offers is the one hook that has no good place in
11
+ # a test case: assembling the index once the whole run is over.
12
+ module MinitestIntegration
13
+ class << self
14
+ def install_at_exit!
15
+ return if @installed
16
+
17
+ @installed = true
18
+ ::Minitest.after_run { Howdoc.finalize }
19
+ end
20
+ end
21
+ end
22
+ end