howdoc 0.1.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +85 -4
- data/config/locales/howdoc.en.yml +5 -0
- data/config/locales/howdoc.et.yml +5 -0
- data/lib/howdoc/capybara_actions.rb +10 -9
- data/lib/howdoc/configuration.rb +7 -4
- data/lib/howdoc/document.rb +57 -26
- data/lib/howdoc/edition.rb +67 -0
- data/lib/howdoc/narrator.rb +115 -13
- data/lib/howdoc/navigation.rb +118 -0
- data/lib/howdoc/recorder.rb +9 -7
- data/lib/howdoc/registry.rb +13 -3
- data/lib/howdoc/step.rb +39 -5
- data/lib/howdoc/version.rb +1 -1
- data/lib/howdoc/writers.rb +34 -7
- data/lib/howdoc.rb +30 -3
- data/templates/default/assets/howdoc.css +107 -7
- data/templates/default/document/html/layout.haml +6 -1
- data/templates/default/index/html/layout.haml +8 -13
- data/templates/default/shared/html/nav.haml +25 -0
- data/templates/default/shared/html/pager.haml +11 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72edea92225be55592acd2d56c732d5d82e140193b06dc276b0c691440ff2e3f
|
|
4
|
+
data.tar.gz: 2ec1eea553ec1b5ca6e23f9f320e082367ded34280cf6701e8f284f06f1ef34c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9692dcfc9505fa3cf80eff5ea7a6c2dcb244619a41aa163cbab98c04a16b01bb13d82d646db46b1e98025f386a47594553abfd4b59a6e937040b555a6f9ecadb
|
|
7
|
+
data.tar.gz: 8ee1913418a639198f1134958ce9748ff83a231a5d86b8fb237675ab264d62b50ad9f5cdfd9e8433310b0c6c226bbf35ec6a21d9afd22c5eee0884b6b9042c86
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
Every page now carries the menu, so a guide opened from a link is a whole page
|
|
6
|
+
rather than a dead end, and a documentation set no longer needs a frame to hold
|
|
7
|
+
its navigation together.
|
|
8
|
+
|
|
9
|
+
- A guide leaves an empty `<nav data-howdoc-nav>`, and the index pass fills it
|
|
10
|
+
in once every guide is on disk -- a guide cannot draw a menu of guides when it
|
|
11
|
+
is written, because no other guide exists yet. A template without the
|
|
12
|
+
placeholder is left exactly as it was.
|
|
13
|
+
- The same pass writes previous/next links into `<nav data-howdoc-pager>`.
|
|
14
|
+
- The menu links the same guide in the other languages, matched by identifier,
|
|
15
|
+
since a translated heading gives a translated file name. Each language names
|
|
16
|
+
itself in its own language.
|
|
17
|
+
- `config.group_label` now heads sections in the menu as well as the index.
|
|
18
|
+
- The index carries `howdoc.index.intro` when an application translates it, so
|
|
19
|
+
the landing page can say something of its own without a hand-kept file.
|
|
20
|
+
- Templates read the gem's own words through a lookup an application's I18n
|
|
21
|
+
cascade cannot answer with something unrelated.
|
|
22
|
+
|
|
23
|
+
## 0.2.0
|
|
24
|
+
|
|
25
|
+
Guides are now written in every language the application publishes, from a
|
|
26
|
+
single test run. Previously a run produced guides in the language the suite
|
|
27
|
+
happened to be running in, and the other languages were silently left behind.
|
|
28
|
+
|
|
29
|
+
- `config.locales` names the languages every guide is written in, not only the
|
|
30
|
+
languages an index is built for.
|
|
31
|
+
- A step is recorded as an action and its values rather than as a finished
|
|
32
|
+
sentence, and narrated once per language when the guide is written.
|
|
33
|
+
- Screenshots are taken once and copied into each language's guide.
|
|
34
|
+
- A guide's own heading and introduction can be translated under
|
|
35
|
+
`howdoc.documents.<permalink>`, since they come from the test rather than
|
|
36
|
+
from the engine.
|
|
37
|
+
- `Howdoc::Narrator.field` defers a field label until the language is known.
|
|
38
|
+
`Howdoc.start(locale:)` still pins a guide to one language.
|
|
39
|
+
- Untranslated keys are reported with the language they are missing from.
|
|
40
|
+
|
|
3
41
|
## 0.1.0
|
|
4
42
|
|
|
5
43
|
First release. Extracted from a working in-house documentation helper that had
|
data/README.md
CHANGED
|
@@ -45,6 +45,7 @@ Howdoc.configure do |config|
|
|
|
45
45
|
config.enabled = ENV['DOC'].present? # off unless asked for
|
|
46
46
|
config.host = 'example.com' # the address a reader should open
|
|
47
47
|
config.formats = %i[html]
|
|
48
|
+
config.locales = %i[en et] # languages every guide is written in
|
|
48
49
|
|
|
49
50
|
config.register_locale_path 'test/howdoc/locales/*.yml'
|
|
50
51
|
config.register_template_path 'test/howdoc/templates'
|
|
@@ -70,8 +71,7 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
|
|
70
71
|
id: metadata[:doc],
|
|
71
72
|
permalink: metadata[:permalink],
|
|
72
73
|
intro: metadata[:intro],
|
|
73
|
-
title: "How to #{metadata[:description]}?"
|
|
74
|
-
locale: I18n.locale
|
|
74
|
+
title: "How to #{metadata[:description]}?"
|
|
75
75
|
)
|
|
76
76
|
end
|
|
77
77
|
|
|
@@ -137,6 +137,52 @@ Three actions come translated even though no wrapper records them, because
|
|
|
137
137
|
almost every suite ends up writing a helper that needs one: `menu` (a `path`),
|
|
138
138
|
`fill_in_editor` (a `value`) and `sign_in_required` (a `text`).
|
|
139
139
|
|
|
140
|
+
## Languages
|
|
141
|
+
|
|
142
|
+
A test runs in one language. Its guide is written in all of them.
|
|
143
|
+
|
|
144
|
+
Name the languages your application publishes and every recorded guide is
|
|
145
|
+
written once per language, from the same run:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
config.locales = %i[en et]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
public/docs/en/how_to_add_a_claim.html
|
|
153
|
+
public/docs/et/how_to_add_a_claim.html
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Nothing is recorded as a finished sentence, which is what makes this possible:
|
|
157
|
+
a step is kept as the action and the values it happened with, and the sentence
|
|
158
|
+
is composed once per language when the page is written. The screenshot is taken
|
|
159
|
+
once, while the browser is still on the page, and copied into each language's
|
|
160
|
+
guide -- it is a picture of the same application either way.
|
|
161
|
+
|
|
162
|
+
A guide's own heading is the exception, because it comes from the test rather
|
|
163
|
+
than from the engine. Translate `howdoc.documents.<permalink>.title` to give the
|
|
164
|
+
same guide a heading in another language, and `.intro` for its introduction:
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
167
|
+
et:
|
|
168
|
+
howdoc:
|
|
169
|
+
documents:
|
|
170
|
+
how_to_add_a_claim:
|
|
171
|
+
title: Kuidas lisada nõue?
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Left untranslated, every language gets the heading the test gave it. A guide
|
|
175
|
+
that belongs to one language only -- something only that language's users ever
|
|
176
|
+
see -- says so: `Howdoc.start(..., locale: :et)`.
|
|
177
|
+
|
|
178
|
+
A value that reads differently in different languages must not be resolved
|
|
179
|
+
while the test runs, or it freezes into the language the test ran in. Field
|
|
180
|
+
labels are the usual case, and `Howdoc::Narrator.field` defers one:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
Howdoc.record(:check, field: Howdoc::Narrator.field(locator))
|
|
184
|
+
```
|
|
185
|
+
|
|
140
186
|
## Wording
|
|
141
187
|
|
|
142
188
|
Every sentence is an I18n key under `howdoc.actions`, so the engine contains no
|
|
@@ -161,8 +207,43 @@ own by translating `howdoc.fields.<locator>`, or replace the whole convention:
|
|
|
161
207
|
config.field_label { |locator| MyLabels.for(locator) }
|
|
162
208
|
```
|
|
163
209
|
|
|
164
|
-
Any key that is used but not translated is reported at the end of the run
|
|
165
|
-
than leaving a silent hole in the
|
|
210
|
+
Any key that is used but not translated is reported at the end of the run,
|
|
211
|
+
naming the language it is missing from, rather than leaving a silent hole in the
|
|
212
|
+
guide. A step no language can describe is not recorded at all; a step one
|
|
213
|
+
language cannot describe is left out of that language's guide only.
|
|
214
|
+
|
|
215
|
+
## Navigation
|
|
216
|
+
|
|
217
|
+
Every page carries the menu, and a guide links the ones before and after it:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
public/docs/en/how_to_add_a_claim.html
|
|
221
|
+
┌────────────┬────────────────────┐
|
|
222
|
+
│ Guides │ 1.3. How to add a … │
|
|
223
|
+
│ EVERYDAY │ 1. Log in … │
|
|
224
|
+
│ 1.1 … │ [screenshot] │
|
|
225
|
+
│ 1.3 ● │ ‹ Previous Next › │
|
|
226
|
+
└────────────┴────────────────────┘
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
A guide cannot draw that menu when it is written: tests run in forked workers,
|
|
230
|
+
each one writes its own page, and no other guide exists yet. So the guide leaves
|
|
231
|
+
an empty placeholder and the index pass -- which has just read every page back
|
|
232
|
+
-- fills it in:
|
|
233
|
+
|
|
234
|
+
```haml
|
|
235
|
+
%nav.howdoc-nav{ data: { howdoc_nav: true } } -# the menu
|
|
236
|
+
%nav.howdoc-pager{ data: { howdoc_pager: true } } -# previous / next
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Both are optional. A template that carries neither is left exactly as it is.
|
|
240
|
+
|
|
241
|
+
The menu also links the same guide in every other language the application
|
|
242
|
+
publishes. Guides are matched by their identifier rather than their file name,
|
|
243
|
+
because a translated heading gives a translated file name, and each language
|
|
244
|
+
names itself in its own language (`howdoc.languages.<code>`).
|
|
245
|
+
|
|
246
|
+
`config.group_label` heads the sections, in the menu and in the index alike.
|
|
166
247
|
|
|
167
248
|
## Templates
|
|
168
249
|
|
|
@@ -19,6 +19,11 @@ en:
|
|
|
19
19
|
visit: Open <strong>%{url}</strong> in your browser.
|
|
20
20
|
document:
|
|
21
21
|
follow_steps: 'Follow these steps:'
|
|
22
|
+
languages:
|
|
23
|
+
en: English
|
|
24
|
+
pager:
|
|
25
|
+
next: Next
|
|
26
|
+
previous: Previous
|
|
22
27
|
index:
|
|
23
28
|
empty: No guides have been generated yet.
|
|
24
29
|
title: Guides
|
|
@@ -19,6 +19,11 @@ et:
|
|
|
19
19
|
visit: Palun sisesta <strong>%{url}</strong> oma brauserisse.
|
|
20
20
|
document:
|
|
21
21
|
follow_steps: 'Palun jälgige järgnevaid samme:'
|
|
22
|
+
languages:
|
|
23
|
+
et: Eesti
|
|
24
|
+
pager:
|
|
25
|
+
next: Järgmine
|
|
26
|
+
previous: Eelmine
|
|
22
27
|
index:
|
|
23
28
|
empty: Ühtegi juhendit ei ole veel loodud.
|
|
24
29
|
title: Juhendid
|
|
@@ -29,9 +29,8 @@ module Howdoc
|
|
|
29
29
|
|
|
30
30
|
def fill_in(locator = nil, with:, currently_with: nil, fill_options: {}, **options)
|
|
31
31
|
doc = Howdoc.extract_options!(options)
|
|
32
|
-
label = howdoc_field_label(locator)
|
|
33
32
|
|
|
34
|
-
narrate(fill_in_action(
|
|
33
|
+
narrate(fill_in_action(locator), doc, field: howdoc_field_label(locator), value: with)
|
|
35
34
|
|
|
36
35
|
super(locator, with:, currently_with:, fill_options:, **options)
|
|
37
36
|
end
|
|
@@ -114,16 +113,18 @@ module Howdoc
|
|
|
114
113
|
|
|
115
114
|
private
|
|
116
115
|
|
|
116
|
+
# Not the label itself: a guide is written in every language the
|
|
117
|
+
# application publishes, and what a reader calls a field is a translation,
|
|
118
|
+
# so the label is settled once per language when the page is written.
|
|
117
119
|
def howdoc_field_label(locator)
|
|
118
|
-
Howdoc::Narrator.
|
|
120
|
+
Howdoc::Narrator.field(locator)
|
|
119
121
|
end
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
case label.to_s
|
|
123
|
+
# Which sentence a fill_in gets is decided from the field's own name rather
|
|
124
|
+
# than from its label, because the name is the same in every language and
|
|
125
|
+
# the choice must be too.
|
|
126
|
+
def fill_in_action(locator)
|
|
127
|
+
case Howdoc.config.field_label.call(locator).to_s
|
|
127
128
|
when 'email' then :fill_in_email
|
|
128
129
|
when 'password' then :fill_in_password
|
|
129
130
|
else :fill_in
|
data/lib/howdoc/configuration.rb
CHANGED
|
@@ -22,10 +22,13 @@ module Howdoc
|
|
|
22
22
|
# Writers to run for every finished document, in order.
|
|
23
23
|
attr_accessor :formats
|
|
24
24
|
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
25
|
+
# The languages the application publishes its guides in. A test runs in one
|
|
26
|
+
# language, but every guide it records is written in all of these, so a
|
|
27
|
+
# second language costs a translation file rather than a second test run.
|
|
28
|
+
#
|
|
29
|
+
# These are also the locales an index is built for. Left nil, a guide is
|
|
30
|
+
# written only in the language the suite is running in, and only the
|
|
31
|
+
# locales that actually produced a guide get an index.
|
|
29
32
|
attr_accessor :locales
|
|
30
33
|
|
|
31
34
|
# Screenshots are taken at this height unless the step asked for a full
|
data/lib/howdoc/document.rb
CHANGED
|
@@ -4,6 +4,11 @@ module Howdoc
|
|
|
4
4
|
# One guide: the identity a test gave it, plus the steps recorded while that
|
|
5
5
|
# test ran. A document collects everything in memory and is written out once,
|
|
6
6
|
# so a writer always sees the whole guide rather than a half-built page.
|
|
7
|
+
#
|
|
8
|
+
# A test runs in one language but a guide is published in all of them, so a
|
|
9
|
+
# document holds nothing that a language would change. Every such thing --
|
|
10
|
+
# the heading, the sentences, the file name -- belongs to an Edition, and a
|
|
11
|
+
# document has one per language.
|
|
7
12
|
class Document
|
|
8
13
|
# Letters that carry no accent to strip: they are their own character, so
|
|
9
14
|
# Unicode decomposition leaves them untouched and a filename filter would
|
|
@@ -13,24 +18,44 @@ module Howdoc
|
|
|
13
18
|
'đ' => 'd', 'ð' => 'd', 'ł' => 'l', 'þ' => 'th'
|
|
14
19
|
}.freeze
|
|
15
20
|
|
|
16
|
-
attr_reader :id, :permalink, :
|
|
21
|
+
attr_reader :id, :permalink, :steps, :locales
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
# +title+ and +intro+ are whatever the test knew: a string in the language
|
|
24
|
+
# it was written in, or a hash keyed by locale when the test itself knows
|
|
25
|
+
# more than one. Either way a translation under howdoc.documents wins, so a
|
|
26
|
+
# guide gets a heading in a language no test speaks.
|
|
27
|
+
def initialize(id:, title:, locales:, permalink: nil, intro: nil)
|
|
19
28
|
@id = id
|
|
20
29
|
@title = title
|
|
21
|
-
@locale = locale.to_sym
|
|
22
|
-
@permalink = permalink
|
|
23
30
|
@intro = intro
|
|
31
|
+
@permalink = permalink
|
|
32
|
+
@locales = Array(locales).map(&:to_sym)
|
|
24
33
|
@steps = []
|
|
25
34
|
@counter = 0
|
|
26
35
|
end
|
|
27
36
|
|
|
28
|
-
def
|
|
29
|
-
@
|
|
37
|
+
def editions
|
|
38
|
+
@editions ||= locales.map { |locale| Edition.new(self, locale) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The edition a test is recording into. Screenshots are taken while the
|
|
42
|
+
# browser is still on the page, long before the other editions are written,
|
|
43
|
+
# so they are saved where this one keeps its pictures and copied across when
|
|
44
|
+
# the guides are written.
|
|
45
|
+
def primary
|
|
46
|
+
editions.first
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def locale
|
|
50
|
+
primary.locale
|
|
30
51
|
end
|
|
31
52
|
|
|
32
|
-
def
|
|
33
|
-
|
|
53
|
+
def title_for(locale)
|
|
54
|
+
Narrator.document_text(:title, key: translation_key, locale:, default: in_locale(@title, locale))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def intro_for(locale)
|
|
58
|
+
Narrator.document_text(:intro, key: translation_key, locale:, default: in_locale(@intro, locale))
|
|
34
59
|
end
|
|
35
60
|
|
|
36
61
|
def new_step(**attributes)
|
|
@@ -44,23 +69,14 @@ module Howdoc
|
|
|
44
69
|
steps.last
|
|
45
70
|
end
|
|
46
71
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
72
|
+
# What the recording edition answers, asked of the document itself, because
|
|
73
|
+
# that is what a recorder has in its hand.
|
|
74
|
+
def slug = primary.slug
|
|
75
|
+
def heading = primary.heading
|
|
76
|
+
def dir = primary.dir
|
|
77
|
+
def image_dir = primary.image_dir
|
|
78
|
+
def path(extension) = primary.path(extension)
|
|
79
|
+
def image_filename(number) = primary.image_filename(number)
|
|
64
80
|
|
|
65
81
|
# Deliberately not ActiveSupport's parameterize: the engine has no business
|
|
66
82
|
# dragging Rails into a project that only wanted a documentation generator.
|
|
@@ -68,7 +84,7 @@ module Howdoc
|
|
|
68
84
|
# Accents are decomposed and their marks dropped rather than deleted whole,
|
|
69
85
|
# so an Estonian title turns into "kuidas_lisada_noue" instead of the
|
|
70
86
|
# unreadable "kuidas_lisada_n_ue" a plain ASCII filter would leave behind.
|
|
71
|
-
def slugify(string)
|
|
87
|
+
def self.slugify(string)
|
|
72
88
|
string
|
|
73
89
|
.to_s
|
|
74
90
|
.downcase
|
|
@@ -79,5 +95,20 @@ module Howdoc
|
|
|
79
95
|
.gsub(/[^a-z0-9]+/, '_')
|
|
80
96
|
.gsub(/\A_+|_+\z/, '')
|
|
81
97
|
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# The name a translation of this guide's own words is filed under. A
|
|
102
|
+
# permalink is the obvious one; without it the untranslated title has to do,
|
|
103
|
+
# since it is the only stable thing a test gave us.
|
|
104
|
+
def translation_key
|
|
105
|
+
@translation_key ||= (permalink || self.class.slugify(in_locale(@title, locales.first))).to_s
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def in_locale(value, locale)
|
|
109
|
+
return value unless value.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
value[locale.to_sym] || value[locale.to_s] || value.values.first
|
|
112
|
+
end
|
|
82
113
|
end
|
|
83
114
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Howdoc
|
|
4
|
+
# One language's version of a guide.
|
|
5
|
+
#
|
|
6
|
+
# A guide is recorded once -- the same clicks, the same screenshots -- and
|
|
7
|
+
# written out once per language. Everything that differs between those
|
|
8
|
+
# versions is here: the heading, the sentences, the directory the page lands
|
|
9
|
+
# in and the names of the pictures in it. A template is handed the edition for
|
|
10
|
+
# the language being written, so nothing in a template has to know that a
|
|
11
|
+
# second language exists.
|
|
12
|
+
class Edition
|
|
13
|
+
attr_reader :document, :locale
|
|
14
|
+
|
|
15
|
+
def initialize(document, locale)
|
|
16
|
+
@document = document
|
|
17
|
+
@locale = locale.to_sym
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def id
|
|
21
|
+
document.id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def permalink
|
|
25
|
+
document.permalink
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def title
|
|
29
|
+
@title ||= document.title_for(locale)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def intro
|
|
33
|
+
@intro ||= document.intro_for(locale)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def heading
|
|
37
|
+
id.nil? ? title : "#{id}. #{title}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A permalink is the same in every language, so the guides sit beside each
|
|
41
|
+
# other under one name. Without one the file is named after the heading,
|
|
42
|
+
# which means a translated heading gives a translated file name.
|
|
43
|
+
def slug
|
|
44
|
+
@slug ||= (permalink || Document.slugify(title)).to_s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def steps
|
|
48
|
+
@steps ||= document.steps.map { |step| step.narrate(self) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def dir
|
|
52
|
+
File.join(Howdoc.config.root, locale.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def image_dir
|
|
56
|
+
File.join(dir, 'images')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def path(extension)
|
|
60
|
+
File.join(dir, "#{slug}.#{extension}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def image_filename(number)
|
|
64
|
+
"#{slug}_#{number}.png"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/howdoc/narrator.rb
CHANGED
|
@@ -7,12 +7,30 @@ module Howdoc
|
|
|
7
7
|
# sentence itself -- every one of them is an I18n key, so the vocabulary is
|
|
8
8
|
# translatable and an application can reword any instruction without touching
|
|
9
9
|
# the code that records it.
|
|
10
|
+
#
|
|
11
|
+
# Nothing here is settled while a test runs. A guide is recorded once and
|
|
12
|
+
# written out in every language it is published in, so a sentence is composed
|
|
13
|
+
# at writing time, once per language.
|
|
10
14
|
module Narrator
|
|
11
|
-
|
|
15
|
+
ROOT = 'howdoc'
|
|
16
|
+
ACTIONS = :actions
|
|
17
|
+
FIELDS = :fields
|
|
18
|
+
DOCUMENTS = :documents
|
|
19
|
+
|
|
20
|
+
# A value that cannot be settled until the language is known. A form field
|
|
21
|
+
# is the usual case: what a reader calls it is a translation, so recording
|
|
22
|
+
# the resolved label would freeze the guide into the language the test
|
|
23
|
+
# happened to run in.
|
|
24
|
+
Field = Struct.new(:locator) do
|
|
25
|
+
def to_howdoc_text(locale)
|
|
26
|
+
Narrator.field_label(locator, locale:)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
12
29
|
|
|
13
30
|
class << self
|
|
14
31
|
# Keys that were asked for but not translated, reported once at the end of
|
|
15
|
-
# a run rather than silently producing a guide with holes in it.
|
|
32
|
+
# a run rather than silently producing a guide with holes in it. A key is
|
|
33
|
+
# missing in a particular language, so the language is part of it.
|
|
16
34
|
def missing_keys
|
|
17
35
|
@missing_keys ||= []
|
|
18
36
|
end
|
|
@@ -21,16 +39,39 @@ module Howdoc
|
|
|
21
39
|
@missing_keys = []
|
|
22
40
|
end
|
|
23
41
|
|
|
42
|
+
# Wraps a form field locator so it is named in the language of whichever
|
|
43
|
+
# edition is being written, rather than in the one the test ran in.
|
|
44
|
+
def field(locator)
|
|
45
|
+
Field.new(locator)
|
|
46
|
+
end
|
|
47
|
+
|
|
24
48
|
def call(action, locale:, **payload)
|
|
25
|
-
|
|
26
|
-
text = I18n.t(key, locale:, default: nil, **escape(payload))
|
|
49
|
+
sentence = lookup(ACTIONS, action, locale:)
|
|
27
50
|
|
|
28
|
-
if
|
|
29
|
-
|
|
51
|
+
if sentence.nil?
|
|
52
|
+
record_missing(action_key(action), locale)
|
|
30
53
|
return nil
|
|
31
54
|
end
|
|
32
55
|
|
|
33
|
-
|
|
56
|
+
values = resolve(payload, locale)
|
|
57
|
+
values.empty? ? sentence : I18n.interpolate(sentence, values)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether any of the languages a guide is published in has a sentence for
|
|
61
|
+
# this action. A step no language can say anything about is not recorded
|
|
62
|
+
# at all -- it would otherwise leave a numbered instruction with nothing
|
|
63
|
+
# in it, or an illustration of nothing.
|
|
64
|
+
def known?(action, locales)
|
|
65
|
+
return false if action.nil?
|
|
66
|
+
|
|
67
|
+
return true if locales.any? { |locale| translated?(action, locale) }
|
|
68
|
+
|
|
69
|
+
locales.each { |locale| record_missing(action_key(action), locale) }
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def translated?(action, locale)
|
|
74
|
+
!lookup(ACTIONS, action, locale:).nil?
|
|
34
75
|
end
|
|
35
76
|
|
|
36
77
|
# The label a reader would recognise for a form field.
|
|
@@ -41,10 +82,25 @@ module Howdoc
|
|
|
41
82
|
def field_label(locator, locale:)
|
|
42
83
|
return '' if locator.nil?
|
|
43
84
|
|
|
44
|
-
|
|
45
|
-
|
|
85
|
+
lookup(FIELDS, locator, locale:) || Howdoc.config.field_label.call(locator)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A guide's own words -- its title, its introduction -- come from the test
|
|
89
|
+
# that recorded it and are therefore written in one language. Translating
|
|
90
|
+
# howdoc.documents.<permalink>.title gives the same guide a heading in
|
|
91
|
+
# another language without the test knowing there is another language.
|
|
92
|
+
def document_text(field, key:, locale:, default: nil)
|
|
93
|
+
return default if key.nil? || key.empty?
|
|
94
|
+
|
|
95
|
+
lookup(DOCUMENTS, key, field, locale:) || default
|
|
96
|
+
end
|
|
46
97
|
|
|
47
|
-
|
|
98
|
+
# The gem's own words -- the ones its templates print rather than the ones
|
|
99
|
+
# it narrates a step with. Looked up the safe way, so an application whose
|
|
100
|
+
# I18n cascades cannot answer an untranslated "howdoc.index.intro" with
|
|
101
|
+
# some unrelated top-level "intro" of its own.
|
|
102
|
+
def phrase(*path, locale:, default: nil)
|
|
103
|
+
lookup(*path, locale:) || default
|
|
48
104
|
end
|
|
49
105
|
|
|
50
106
|
# Rails names fields after the model that owns them, which is noise to
|
|
@@ -63,10 +119,56 @@ module Howdoc
|
|
|
63
119
|
|
|
64
120
|
private
|
|
65
121
|
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
|
|
122
|
+
# Nothing here asks I18n for a leaf.
|
|
123
|
+
#
|
|
124
|
+
# How a key is resolved belongs to the application, and an application
|
|
125
|
+
# that has switched cascading on answers a missing
|
|
126
|
+
# "howdoc.documents.some_guide.title" with whatever unrelated top-level
|
|
127
|
+
# "title" it happens to have. A guide would then be headed with a word
|
|
128
|
+
# from somewhere else entirely. So the gem's own tree is fetched whole --
|
|
129
|
+
# it is always there, so nothing cascades past it -- and the leaf is dug
|
|
130
|
+
# out of the result, where a key that is missing is simply missing.
|
|
131
|
+
#
|
|
132
|
+
# An application's fallback chain is still honoured, because a sentence
|
|
133
|
+
# in the wrong language reads better than a numbered step with nothing
|
|
134
|
+
# in it.
|
|
135
|
+
def lookup(*path, locale:)
|
|
136
|
+
keys = path.map(&:to_sym)
|
|
137
|
+
|
|
138
|
+
fallback_chain(locale).each do |candidate|
|
|
139
|
+
tree = I18n.t(ROOT, locale: candidate, default: nil)
|
|
140
|
+
next unless tree.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
value = tree.dig(*keys)
|
|
143
|
+
return value if value.is_a?(String)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def fallback_chain(locale)
|
|
150
|
+
return [locale] unless I18n.respond_to?(:fallbacks)
|
|
151
|
+
|
|
152
|
+
I18n.fallbacks[locale]
|
|
153
|
+
rescue StandardError
|
|
154
|
+
[locale]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def action_key(action)
|
|
158
|
+
"#{ROOT}.#{ACTIONS}.#{action}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def record_missing(key, locale)
|
|
162
|
+
entry = "#{key} (#{locale})"
|
|
163
|
+
missing_keys << entry unless missing_keys.include?(entry)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Values that only a language can settle are settled here, and then
|
|
167
|
+
# escaped like any other: translations carry the markup, values do not, so
|
|
168
|
+
# a label containing an angle bracket cannot break the page it lands on.
|
|
169
|
+
def resolve(payload, locale)
|
|
69
170
|
payload.transform_values do |value|
|
|
171
|
+
value = value.to_howdoc_text(locale) if value.respond_to?(:to_howdoc_text)
|
|
70
172
|
value.is_a?(String) || value.is_a?(Symbol) ? ERB::Util.html_escape(value.to_s) : value
|
|
71
173
|
end
|
|
72
174
|
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Howdoc
|
|
4
|
+
# The menu, and the links from one guide to the next.
|
|
5
|
+
#
|
|
6
|
+
# A guide is written the moment its test finishes, when no other guide is
|
|
7
|
+
# known yet -- tests run in forked workers and each one writes its own page --
|
|
8
|
+
# so a guide cannot draw a menu of guides. It leaves an empty <nav> instead,
|
|
9
|
+
# and this fills it in at the end of the run, when the index has just read
|
|
10
|
+
# every page back and knows what there is.
|
|
11
|
+
#
|
|
12
|
+
# Filling in rather than re-rendering: the steps a guide was made of are long
|
|
13
|
+
# gone by then, but its page is not, and this gem wrote that page. Running the
|
|
14
|
+
# pass again simply replaces what it wrote the last time.
|
|
15
|
+
#
|
|
16
|
+
# A template that carries no placeholder is left exactly as it is, so an
|
|
17
|
+
# application with its own page chrome loses nothing by not having one.
|
|
18
|
+
module Navigation
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Writes the menu and the pager into every guide. +by_locale+ is the sorted
|
|
22
|
+
# records the index was built from, so the menu lists the guides in the same
|
|
23
|
+
# order the index does.
|
|
24
|
+
def install(by_locale, root: Howdoc.config.root)
|
|
25
|
+
by_locale.each_value.flat_map do |records|
|
|
26
|
+
records.each_with_index.filter_map do |record, position|
|
|
27
|
+
write(record, records:, position:, by_locale:, root:)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write(record, records:, position:, by_locale:, root:)
|
|
33
|
+
path = File.join(root, record.locale.to_s, record.filename)
|
|
34
|
+
return nil unless File.file?(path)
|
|
35
|
+
|
|
36
|
+
markup = File.read(path)
|
|
37
|
+
filled = fill(markup, :nav) { nav(record, records, by_locale) }
|
|
38
|
+
filled = fill(filled, :pager) { pager(record, records, position) }
|
|
39
|
+
return nil if filled == markup
|
|
40
|
+
|
|
41
|
+
File.write(path, filled)
|
|
42
|
+
path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stripped, so that filling a page in a second time leaves it exactly as the
|
|
46
|
+
# first time left it: the pass runs once per suite and again at the end of a
|
|
47
|
+
# run, and a page that grew a blank line each time would show up as changed
|
|
48
|
+
# in a repository for no reason at all.
|
|
49
|
+
def fill(markup, name)
|
|
50
|
+
markup.sub(placeholder(name)) { yield.strip }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def placeholder(name)
|
|
54
|
+
%r{<nav[^>]*data-howdoc-#{name}[^>]*>.*?</nav>}m
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def nav(record, records, by_locale)
|
|
58
|
+
Templates.render(
|
|
59
|
+
'shared/html/nav.haml',
|
|
60
|
+
locale: record.locale, records:, current: record, config: Howdoc.config,
|
|
61
|
+
languages: languages(current_locale: record.locale, record:, by_locale:)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pager(record, records, position)
|
|
66
|
+
Templates.render(
|
|
67
|
+
'shared/html/pager.haml',
|
|
68
|
+
locale: record.locale, config: Howdoc.config,
|
|
69
|
+
previous: (records[position - 1] if position.positive?),
|
|
70
|
+
next: records[position + 1]
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The same guide in the other languages the application publishes.
|
|
75
|
+
#
|
|
76
|
+
# Matched by identifier rather than by file name: a guide with a translated
|
|
77
|
+
# heading gets a translated file name, so "1.3" is the only thing the two
|
|
78
|
+
# pages are sure to have in common. A guide that was never written in the
|
|
79
|
+
# other language points at that language's index instead of nowhere.
|
|
80
|
+
def languages(current_locale:, by_locale:, record: nil)
|
|
81
|
+
ordered(by_locale.keys).map do |locale|
|
|
82
|
+
current = locale.to_s == current_locale.to_s
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
code: locale.to_s,
|
|
86
|
+
label: label(locale),
|
|
87
|
+
current:,
|
|
88
|
+
href: (destination(locale, record, by_locale) unless current)
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def destination(locale, record, by_locale)
|
|
94
|
+
counterpart = record && counterpart(record, by_locale[locale] || [])
|
|
95
|
+
|
|
96
|
+
"../#{locale}/#{counterpart ? counterpart.filename : Registry::INDEX_FILENAME}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def counterpart(record, records)
|
|
100
|
+
return records.find { |other| other.id == record.id } unless record.id.nil?
|
|
101
|
+
|
|
102
|
+
records.find { |other| other.permalink == record.permalink }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A language names itself in its own language, which is how a reader who
|
|
106
|
+
# cannot read the current page still recognises the way out of it.
|
|
107
|
+
def label(locale)
|
|
108
|
+
Narrator.phrase(:languages, locale, locale:, default: locale.to_s.upcase)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ordered(locales)
|
|
112
|
+
configured = Howdoc.config.locales&.map(&:to_s)
|
|
113
|
+
return locales.map(&:to_s).sort if configured.nil?
|
|
114
|
+
|
|
115
|
+
(configured & locales.map(&:to_s)) | locales.map(&:to_s).sort
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/howdoc/recorder.rb
CHANGED
|
@@ -37,16 +37,18 @@ module Howdoc
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Records one instruction. +action+ names an I18n key under howdoc.actions;
|
|
40
|
-
# the remaining keyword arguments are interpolated into it
|
|
41
|
-
# <tt>html:</tt> instead to supply
|
|
42
|
-
# step no sentence describes.
|
|
40
|
+
# the remaining keyword arguments are interpolated into it when the guide is
|
|
41
|
+
# written, once per language. Pass <tt>html:</tt> instead to supply
|
|
42
|
+
# already-rendered markup, for the rare step no sentence describes.
|
|
43
|
+
#
|
|
44
|
+
# Values that read differently in different languages must not be resolved
|
|
45
|
+
# here: pass Howdoc::Narrator.field(locator) and the label is settled by
|
|
46
|
+
# each edition as it is written.
|
|
43
47
|
def record(action = nil, html: nil, arrival: false, nodoc: false, **payload)
|
|
44
48
|
return nil if nodoc || !recording?
|
|
49
|
+
return nil if html.nil? && !Narrator.known?(action, current.locales)
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
return nil if markup.nil?
|
|
48
|
-
|
|
49
|
-
current.new_step(html: markup, arrival:)
|
|
51
|
+
current.new_step(action:, payload:, html:, arrival:)
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
# Illustrates the step recorded most recently. Separate from +record+
|
data/lib/howdoc/registry.rb
CHANGED
|
@@ -80,13 +80,23 @@ module Howdoc
|
|
|
80
80
|
Howdoc.config.locales.to_h { |locale| [locale.to_s, found.fetch(locale.to_s, [])] }
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
# Builds the index for every locale that produced at least one guide
|
|
83
|
+
# Builds the index for every locale that produced at least one guide, and
|
|
84
|
+
# then writes the menu into the guides themselves: they were written one at
|
|
85
|
+
# a time, before there was a menu to draw.
|
|
84
86
|
def write_indexes(root = Howdoc.config.root)
|
|
85
87
|
Templates.install_assets(File.join(root, 'assets')) if Howdoc.config.install_assets
|
|
86
88
|
|
|
87
|
-
by_locale(root).
|
|
88
|
-
|
|
89
|
+
catalogue = by_locale(root).transform_values { |records| sort(records) }
|
|
90
|
+
|
|
91
|
+
written = catalogue.map do |locale, records|
|
|
92
|
+
Writers::Index.call(
|
|
93
|
+
locale:, records:, root:,
|
|
94
|
+
languages: Navigation.languages(current_locale: locale, by_locale: catalogue)
|
|
95
|
+
)
|
|
89
96
|
end
|
|
97
|
+
|
|
98
|
+
Navigation.install(catalogue, root:)
|
|
99
|
+
written
|
|
90
100
|
end
|
|
91
101
|
|
|
92
102
|
def sort(records)
|
data/lib/howdoc/step.rb
CHANGED
|
@@ -1,21 +1,55 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Howdoc
|
|
4
|
-
# One numbered instruction in a guide:
|
|
5
|
-
# and a flag for the steps that mark the reader's arrival
|
|
4
|
+
# One numbered instruction in a guide: what happened, optionally an
|
|
5
|
+
# illustration of it, and a flag for the steps that mark the reader's arrival
|
|
6
|
+
# on a new page.
|
|
7
|
+
#
|
|
8
|
+
# What happened is kept as the action and the values it was recorded with,
|
|
9
|
+
# not as a finished sentence, because the same step is told in every language
|
|
10
|
+
# the guide is published in.
|
|
6
11
|
class Step
|
|
7
|
-
|
|
12
|
+
# A step as one language tells it: the sentence composed, the illustration
|
|
13
|
+
# named as that language's guide refers to it. This is what a template is
|
|
14
|
+
# handed, so a template never has to know which language it is rendering.
|
|
15
|
+
Narrated = Struct.new(:number, :html, :screenshot, :arrival, keyword_init: true) do
|
|
16
|
+
def empty?
|
|
17
|
+
html.nil? && screenshot.nil?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :number, :action, :payload
|
|
8
22
|
attr_accessor :html, :screenshot, :arrival
|
|
9
23
|
|
|
10
|
-
def initialize(number:, html: nil, screenshot: nil, arrival: false)
|
|
24
|
+
def initialize(number:, action: nil, payload: {}, html: nil, screenshot: nil, arrival: false)
|
|
11
25
|
@number = number
|
|
26
|
+
@action = action
|
|
27
|
+
@payload = payload
|
|
12
28
|
@html = html
|
|
13
29
|
@screenshot = screenshot
|
|
14
30
|
@arrival = arrival
|
|
15
31
|
end
|
|
16
32
|
|
|
17
33
|
def empty?
|
|
18
|
-
html.nil? && screenshot.nil?
|
|
34
|
+
html.nil? && action.nil? && screenshot.nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Markup a caller supplied stands in every language: it was written by the
|
|
38
|
+
# application, which knew what it was doing.
|
|
39
|
+
def html_in(locale)
|
|
40
|
+
return html unless html.nil?
|
|
41
|
+
return nil if action.nil?
|
|
42
|
+
|
|
43
|
+
Narrator.call(action, locale:, **payload)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def narrate(edition)
|
|
47
|
+
Narrated.new(
|
|
48
|
+
number:,
|
|
49
|
+
html: html_in(edition.locale),
|
|
50
|
+
screenshot: (edition.image_filename(number) if screenshot),
|
|
51
|
+
arrival:
|
|
52
|
+
)
|
|
19
53
|
end
|
|
20
54
|
end
|
|
21
55
|
end
|
data/lib/howdoc/version.rb
CHANGED
data/lib/howdoc/writers.rb
CHANGED
|
@@ -28,28 +28,55 @@ module Howdoc
|
|
|
28
28
|
Howdoc.config.formats.map { |format| fetch(format).call(document) }
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
# Writes the illustrated guide a reader actually opens
|
|
31
|
+
# Writes the illustrated guide a reader actually opens -- one page per
|
|
32
|
+
# language the guide is published in.
|
|
32
33
|
class Html
|
|
33
34
|
def self.call(document)
|
|
34
|
-
|
|
35
|
+
document.editions.map { |edition| write(document, edition) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.write(document, edition)
|
|
39
|
+
FileUtils.mkdir_p(edition.dir)
|
|
40
|
+
copy_screenshots(document, edition)
|
|
41
|
+
|
|
35
42
|
markup = Templates.render(
|
|
36
43
|
'document/html/layout.haml',
|
|
37
|
-
document
|
|
44
|
+
document: edition, config: Howdoc.config
|
|
38
45
|
)
|
|
39
|
-
File.write(
|
|
40
|
-
|
|
46
|
+
File.write(edition.path('html'), markup)
|
|
47
|
+
edition.path('html')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A picture of the application is the same picture whatever language the
|
|
51
|
+
# page around it is written in, so it is taken once, while the browser is
|
|
52
|
+
# still on the page, and copied to where each edition looks for it.
|
|
53
|
+
def self.copy_screenshots(document, edition)
|
|
54
|
+
return if edition.equal?(document.primary)
|
|
55
|
+
|
|
56
|
+
document.steps.each do |step|
|
|
57
|
+
next if step.screenshot.nil?
|
|
58
|
+
|
|
59
|
+
copy_screenshot(File.join(document.image_dir, step.screenshot), edition, step.number)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.copy_screenshot(source, edition, number)
|
|
64
|
+
return unless File.file?(source)
|
|
65
|
+
|
|
66
|
+
FileUtils.mkdir_p(edition.image_dir)
|
|
67
|
+
FileUtils.cp(source, File.join(edition.image_dir, edition.image_filename(number)))
|
|
41
68
|
end
|
|
42
69
|
end
|
|
43
70
|
|
|
44
71
|
# Writes one index per locale, from the guides the run just produced.
|
|
45
72
|
class Index
|
|
46
|
-
def self.call(locale:, records:, root: Howdoc.config.root)
|
|
73
|
+
def self.call(locale:, records:, root: Howdoc.config.root, languages: [])
|
|
47
74
|
dir = File.join(root, locale.to_s)
|
|
48
75
|
FileUtils.mkdir_p(dir)
|
|
49
76
|
|
|
50
77
|
markup = Templates.render(
|
|
51
78
|
'index/html/layout.haml',
|
|
52
|
-
locale:, records:, config: Howdoc.config
|
|
79
|
+
locale:, records:, languages:, config: Howdoc.config
|
|
53
80
|
)
|
|
54
81
|
path = File.join(dir, 'index.html')
|
|
55
82
|
File.write(path, markup)
|
data/lib/howdoc.rb
CHANGED
|
@@ -7,11 +7,13 @@ require_relative 'howdoc/version'
|
|
|
7
7
|
require_relative 'howdoc/configuration'
|
|
8
8
|
require_relative 'howdoc/narrator'
|
|
9
9
|
require_relative 'howdoc/step'
|
|
10
|
+
require_relative 'howdoc/edition'
|
|
10
11
|
require_relative 'howdoc/document'
|
|
11
12
|
require_relative 'howdoc/screenshot'
|
|
12
13
|
require_relative 'howdoc/recorder'
|
|
13
14
|
require_relative 'howdoc/templates'
|
|
14
15
|
require_relative 'howdoc/writers'
|
|
16
|
+
require_relative 'howdoc/navigation'
|
|
15
17
|
require_relative 'howdoc/registry'
|
|
16
18
|
|
|
17
19
|
# Generates end-user documentation from Capybara system tests.
|
|
@@ -58,11 +60,36 @@ module Howdoc
|
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
# Begins a guide. Everything recorded until #finish belongs to it.
|
|
61
|
-
|
|
63
|
+
#
|
|
64
|
+
# A test runs in one language; the guide it records is written in every
|
|
65
|
+
# language the application publishes, which is config.locales unless this
|
|
66
|
+
# call names them. Pass +locale:+ for the rare guide that belongs to one
|
|
67
|
+
# language only -- a page describing something only that language's users
|
|
68
|
+
# ever see.
|
|
69
|
+
def start(id: nil, title: nil, locale: nil, locales: nil, permalink: nil, intro: nil)
|
|
62
70
|
return nil unless enabled?
|
|
63
|
-
return nil if title
|
|
71
|
+
return nil if blank_title?(title)
|
|
64
72
|
|
|
65
|
-
Recorder.current = Document.new(
|
|
73
|
+
Recorder.current = Document.new(
|
|
74
|
+
id:, title:, permalink:, intro:,
|
|
75
|
+
locales: document_locales(locale, locales)
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Which languages a guide is written in: what the caller asked for, else
|
|
80
|
+
# what the application publishes, else the language the suite is running
|
|
81
|
+
# in, which is all a single-language application ever needs.
|
|
82
|
+
def document_locales(locale, locales)
|
|
83
|
+
chosen = locales || locale || config.locales
|
|
84
|
+
chosen = I18n.locale if chosen.nil? || Array(chosen).empty?
|
|
85
|
+
|
|
86
|
+
Array(chosen).map(&:to_sym)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def blank_title?(title)
|
|
90
|
+
return title.values.all? { |value| blank_title?(value) } if title.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
title.to_s.strip.empty?
|
|
66
93
|
end
|
|
67
94
|
|
|
68
95
|
# Ends the guide and writes it out. Called even when the test failed, so a
|
|
@@ -27,7 +27,6 @@ body.howdoc {
|
|
|
27
27
|
|
|
28
28
|
.howdoc-document,
|
|
29
29
|
.howdoc-index {
|
|
30
|
-
max-width: 52rem;
|
|
31
30
|
margin: 0 auto;
|
|
32
31
|
padding: 3rem 1.25rem 6rem;
|
|
33
32
|
}
|
|
@@ -57,12 +56,6 @@ body.howdoc {
|
|
|
57
56
|
padding-left: 0.25rem;
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
.howdoc-step.howdoc-arrival {
|
|
61
|
-
border-left: 3px solid var(--howdoc-accent);
|
|
62
|
-
margin-left: -1rem;
|
|
63
|
-
padding-left: 1rem;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
59
|
.howdoc-shot {
|
|
67
60
|
display: block;
|
|
68
61
|
max-width: 100%;
|
|
@@ -103,7 +96,114 @@ body.howdoc {
|
|
|
103
96
|
text-decoration: underline;
|
|
104
97
|
}
|
|
105
98
|
|
|
99
|
+
/* The menu sits beside the page it belongs to, and above it when there is no
|
|
100
|
+
room beside. A guide written before the index pass has an empty menu, and an
|
|
101
|
+
empty menu is no menu at all. */
|
|
102
|
+
.howdoc-nav:empty,
|
|
103
|
+
.howdoc-pager:empty {
|
|
104
|
+
display: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.howdoc-nav {
|
|
108
|
+
padding: 1.5rem 1.25rem;
|
|
109
|
+
border-bottom: 1px solid var(--howdoc-rule);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@media (min-width: 60rem) {
|
|
113
|
+
body.howdoc {
|
|
114
|
+
display: grid;
|
|
115
|
+
grid-template-columns: 19rem minmax(0, 1fr);
|
|
116
|
+
align-items: start;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.howdoc-nav {
|
|
120
|
+
position: sticky;
|
|
121
|
+
top: 0;
|
|
122
|
+
max-height: 100vh;
|
|
123
|
+
overflow-y: auto;
|
|
124
|
+
padding: 2rem 1.25rem 3rem;
|
|
125
|
+
border-right: 1px solid var(--howdoc-rule);
|
|
126
|
+
border-bottom: 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.howdoc-nav-head {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: baseline;
|
|
133
|
+
justify-content: space-between;
|
|
134
|
+
gap: 1rem;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.howdoc-nav-home {
|
|
138
|
+
color: var(--howdoc-fg);
|
|
139
|
+
font-weight: 600;
|
|
140
|
+
text-decoration: none;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.howdoc-languages {
|
|
144
|
+
display: flex;
|
|
145
|
+
gap: 0.5rem;
|
|
146
|
+
margin: 0;
|
|
147
|
+
padding: 0;
|
|
148
|
+
list-style: none;
|
|
149
|
+
font-size: 0.85rem;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.howdoc-language a {
|
|
153
|
+
color: var(--howdoc-accent);
|
|
154
|
+
text-decoration: none;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.howdoc-language.howdoc-current {
|
|
158
|
+
color: var(--howdoc-muted);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.howdoc-toc-entry.howdoc-current > a {
|
|
162
|
+
color: var(--howdoc-fg);
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.howdoc-pager {
|
|
167
|
+
display: flex;
|
|
168
|
+
gap: 1rem;
|
|
169
|
+
margin-top: 3rem;
|
|
170
|
+
padding-top: 1.5rem;
|
|
171
|
+
border-top: 1px solid var(--howdoc-rule);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.howdoc-pager-link {
|
|
175
|
+
display: flex;
|
|
176
|
+
flex: 1 1 0;
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
gap: 0.25rem;
|
|
179
|
+
padding: 0.9rem 1rem;
|
|
180
|
+
border: 1px solid var(--howdoc-rule);
|
|
181
|
+
border-radius: 8px;
|
|
182
|
+
text-decoration: none;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.howdoc-pager-next {
|
|
186
|
+
margin-left: auto;
|
|
187
|
+
text-align: right;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.howdoc-pager-label {
|
|
191
|
+
color: var(--howdoc-muted);
|
|
192
|
+
font-size: 0.8rem;
|
|
193
|
+
letter-spacing: 0.08em;
|
|
194
|
+
text-transform: uppercase;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.howdoc-pager-heading {
|
|
198
|
+
color: var(--howdoc-accent);
|
|
199
|
+
}
|
|
200
|
+
|
|
106
201
|
@media print {
|
|
202
|
+
.howdoc-nav,
|
|
203
|
+
.howdoc-pager {
|
|
204
|
+
display: none;
|
|
205
|
+
}
|
|
206
|
+
|
|
107
207
|
.howdoc-shot {
|
|
108
208
|
box-shadow: none;
|
|
109
209
|
}
|
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
%title= @document.heading
|
|
7
7
|
%link{ rel: 'stylesheet', href: '../assets/howdoc.css' }
|
|
8
8
|
%body.howdoc
|
|
9
|
+
-# Empty on purpose: the index pass writes the menu and the pager in here
|
|
10
|
+
-# once it knows what the other guides are. A page is a whole page either
|
|
11
|
+
-# way, so a guide opened straight from a link is never a dead end.
|
|
12
|
+
%nav.howdoc-nav{ data: { howdoc_nav: true } }
|
|
9
13
|
%main.howdoc-document
|
|
10
14
|
%h1= @document.heading
|
|
11
15
|
- unless @document.intro.to_s.strip.empty?
|
|
12
16
|
.howdoc-intro!= @document.intro
|
|
13
|
-
%p.howdoc-lede=
|
|
17
|
+
%p.howdoc-lede= Howdoc::Narrator.phrase(:document, :follow_steps, locale: @document.locale)
|
|
14
18
|
%ol.howdoc-steps
|
|
15
19
|
- @document.steps.reject(&:empty?).each do |step|
|
|
16
20
|
!= render('document/html/step.haml', step: step)
|
|
21
|
+
%nav.howdoc-pager{ data: { howdoc_pager: true } }
|
|
@@ -3,20 +3,15 @@
|
|
|
3
3
|
%head
|
|
4
4
|
%meta{ charset: 'utf-8' }
|
|
5
5
|
%meta{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
|
|
6
|
-
%title=
|
|
6
|
+
%title= Howdoc::Narrator.phrase(:index, :title, locale: @locale)
|
|
7
7
|
%link{ rel: 'stylesheet', href: '../assets/howdoc.css' }
|
|
8
8
|
%body.howdoc
|
|
9
|
+
-# The index knows every guide already, so it draws the menu itself.
|
|
10
|
+
!= render('shared/html/nav.haml', current: nil)
|
|
9
11
|
%main.howdoc-index
|
|
10
|
-
%h1=
|
|
12
|
+
%h1= Howdoc::Narrator.phrase(:index, :title, locale: @locale)
|
|
13
|
+
- intro = Howdoc::Narrator.phrase(:index, :intro, locale: @locale)
|
|
14
|
+
- if intro
|
|
15
|
+
.howdoc-intro!= intro
|
|
11
16
|
- if @records.empty?
|
|
12
|
-
%p=
|
|
13
|
-
- else
|
|
14
|
-
%ul.howdoc-toc
|
|
15
|
-
- previous_group = nil
|
|
16
|
-
- @records.each do |record|
|
|
17
|
-
- group = @config.group_label.call(record)
|
|
18
|
-
- if group && group != previous_group
|
|
19
|
-
%li.howdoc-toc-group= group
|
|
20
|
-
- previous_group = group
|
|
21
|
-
%li.howdoc-toc-entry
|
|
22
|
-
%a{ href: "./#{record.filename}" }= record.heading
|
|
17
|
+
%p= Howdoc::Narrator.phrase(:index, :empty, locale: @locale)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-# The menu every page carries. A guide cannot draw this when it is written --
|
|
2
|
+
-# no other guide exists yet -- so a guide leaves the empty <nav> below and the
|
|
3
|
+
-# index pass fills it in once every guide is on disk. The index renders it
|
|
4
|
+
-# directly, because by then it knows.
|
|
5
|
+
%nav.howdoc-nav{ data: { howdoc_nav: true } }
|
|
6
|
+
.howdoc-nav-head
|
|
7
|
+
%a.howdoc-nav-home{ href: './index.html' }= Howdoc::Narrator.phrase(:index, :title, locale: @locale)
|
|
8
|
+
- if @languages.size > 1
|
|
9
|
+
%ul.howdoc-languages
|
|
10
|
+
- @languages.each do |language|
|
|
11
|
+
%li.howdoc-language{ class: ('howdoc-current' if language[:current]) }
|
|
12
|
+
- if language[:href]
|
|
13
|
+
%a{ href: language[:href], lang: language[:code] }= language[:label]
|
|
14
|
+
- else
|
|
15
|
+
%span{ lang: language[:code] }= language[:label]
|
|
16
|
+
%ul.howdoc-toc
|
|
17
|
+
- previous_group = nil
|
|
18
|
+
- @records.each do |record|
|
|
19
|
+
- group = @config.group_label.call(record)
|
|
20
|
+
- if group && group != previous_group
|
|
21
|
+
%li.howdoc-toc-group= group
|
|
22
|
+
- previous_group = group
|
|
23
|
+
- current = @current && record.filename == @current.filename
|
|
24
|
+
%li.howdoc-toc-entry{ class: ('howdoc-current' if current) }
|
|
25
|
+
%a{ href: "./#{record.filename}", 'aria-current': (current ? 'page' : nil) }= record.heading
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-# Step-by-step guides are read in order, so each one says what comes next.
|
|
2
|
+
-# Written by the same pass as the menu, for the same reason.
|
|
3
|
+
%nav.howdoc-pager{ data: { howdoc_pager: true } }
|
|
4
|
+
- if @previous
|
|
5
|
+
%a.howdoc-pager-link.howdoc-pager-previous{ href: "./#{@previous.filename}", rel: 'prev' }
|
|
6
|
+
%span.howdoc-pager-label= Howdoc::Narrator.phrase(:pager, :previous, locale: @locale)
|
|
7
|
+
%span.howdoc-pager-heading= @previous.heading
|
|
8
|
+
- if @next
|
|
9
|
+
%a.howdoc-pager-link.howdoc-pager-next{ href: "./#{@next.filename}", rel: 'next' }
|
|
10
|
+
%span.howdoc-pager-label= Howdoc::Narrator.phrase(:pager, :next, locale: @locale)
|
|
11
|
+
%span.howdoc-pager-heading= @next.heading
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: howdoc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Priit Tark
|
|
@@ -58,8 +58,10 @@ files:
|
|
|
58
58
|
- lib/howdoc/capybara_actions.rb
|
|
59
59
|
- lib/howdoc/configuration.rb
|
|
60
60
|
- lib/howdoc/document.rb
|
|
61
|
+
- lib/howdoc/edition.rb
|
|
61
62
|
- lib/howdoc/minitest.rb
|
|
62
63
|
- lib/howdoc/narrator.rb
|
|
64
|
+
- lib/howdoc/navigation.rb
|
|
63
65
|
- lib/howdoc/recorder.rb
|
|
64
66
|
- lib/howdoc/registry.rb
|
|
65
67
|
- lib/howdoc/screenshot.rb
|
|
@@ -72,6 +74,8 @@ files:
|
|
|
72
74
|
- templates/default/document/html/layout.haml
|
|
73
75
|
- templates/default/document/html/step.haml
|
|
74
76
|
- templates/default/index/html/layout.haml
|
|
77
|
+
- templates/default/shared/html/nav.haml
|
|
78
|
+
- templates/default/shared/html/pager.haml
|
|
75
79
|
homepage: https://github.com/priit/howdoc
|
|
76
80
|
licenses:
|
|
77
81
|
- MIT
|