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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +221 -0
- data/config/locales/howdoc.en.yml +24 -0
- data/config/locales/howdoc.et.yml +24 -0
- data/lib/howdoc/capybara_actions.rb +155 -0
- data/lib/howdoc/configuration.rb +106 -0
- data/lib/howdoc/document.rb +83 -0
- data/lib/howdoc/minitest.rb +22 -0
- data/lib/howdoc/narrator.rb +75 -0
- data/lib/howdoc/recorder.rb +78 -0
- data/lib/howdoc/registry.rb +116 -0
- data/lib/howdoc/screenshot.rb +26 -0
- data/lib/howdoc/step.rb +21 -0
- data/lib/howdoc/tasks.rb +18 -0
- data/lib/howdoc/templates.rb +75 -0
- data/lib/howdoc/version.rb +5 -0
- data/lib/howdoc/writers.rb +60 -0
- data/lib/howdoc.rb +120 -0
- data/templates/default/assets/howdoc.css +110 -0
- data/templates/default/document/html/layout.haml +16 -0
- data/templates/default/document/html/step.haml +7 -0
- data/templates/default/index/html/layout.haml +22 -0
- metadata +99 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module Howdoc
|
|
6
|
+
# Turns an action and its payload into a sentence. The engine never contains a
|
|
7
|
+
# sentence itself -- every one of them is an I18n key, so the vocabulary is
|
|
8
|
+
# translatable and an application can reword any instruction without touching
|
|
9
|
+
# the code that records it.
|
|
10
|
+
module Narrator
|
|
11
|
+
SCOPE = 'howdoc.actions'
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# 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.
|
|
16
|
+
def missing_keys
|
|
17
|
+
@missing_keys ||= []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def reset_missing_keys
|
|
21
|
+
@missing_keys = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(action, locale:, **payload)
|
|
25
|
+
key = "#{SCOPE}.#{action}"
|
|
26
|
+
text = I18n.t(key, locale:, default: nil, **escape(payload))
|
|
27
|
+
|
|
28
|
+
if text.nil?
|
|
29
|
+
missing_keys << key unless missing_keys.include?(key)
|
|
30
|
+
return nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
text
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The label a reader would recognise for a form field.
|
|
37
|
+
#
|
|
38
|
+
# A translation under howdoc.fields wins, which is how a field whose
|
|
39
|
+
# generated name reads badly gets a proper name without any code knowing
|
|
40
|
+
# about it. Otherwise the configured convention applies.
|
|
41
|
+
def field_label(locator, locale:)
|
|
42
|
+
return '' if locator.nil?
|
|
43
|
+
|
|
44
|
+
translated = I18n.t("howdoc.fields.#{locator}", locale:, default: nil)
|
|
45
|
+
return translated if translated
|
|
46
|
+
|
|
47
|
+
Howdoc.config.field_label.call(locator)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Rails names fields after the model that owns them, which is noise to
|
|
51
|
+
# anyone who does not know the schema, so the leading segment is dropped.
|
|
52
|
+
def humanize_locator(locator)
|
|
53
|
+
string = locator.to_s
|
|
54
|
+
|
|
55
|
+
if string.include?('[')
|
|
56
|
+
string.split('[').last.tr(']', '').tr('_', ' ').strip
|
|
57
|
+
elsif string.include?('_')
|
|
58
|
+
string.split('_').drop(1).join(' ')
|
|
59
|
+
else
|
|
60
|
+
string
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Translations carry the markup, values do not. Escaping here means a
|
|
67
|
+
# label containing an angle bracket cannot break the page it lands on.
|
|
68
|
+
def escape(payload)
|
|
69
|
+
payload.transform_values do |value|
|
|
70
|
+
value.is_a?(String) || value.is_a?(Symbol) ? ERB::Util.html_escape(value.to_s) : value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Howdoc
|
|
4
|
+
# The public recording API. Anything that wants to appear in a guide goes
|
|
5
|
+
# through here -- the Capybara wrappers the gem ships with, and equally an
|
|
6
|
+
# application's own helpers, which the engine cannot and should not know
|
|
7
|
+
# about. Every method is a no-op when documentation is switched off, so
|
|
8
|
+
# call sites need no guards of their own.
|
|
9
|
+
module Recorder
|
|
10
|
+
# Option keys the recorder consumes. They are removed from a Capybara
|
|
11
|
+
# option hash before it reaches Capybara, which would reject them.
|
|
12
|
+
OPTION_KEYS = %i[nodoc no_screenshot full_page screenshot].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def current
|
|
17
|
+
Thread.current[:howdoc_document]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def current=(document)
|
|
21
|
+
Thread.current[:howdoc_document] = document
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def recording?
|
|
25
|
+
Howdoc.config.enabled? && !current.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Removes the gem's own options from a Capybara option hash, mutating it,
|
|
29
|
+
# and returns them. Capybara raises on unknown options, so this has to
|
|
30
|
+
# happen before the wrapped call.
|
|
31
|
+
def extract_options!(options)
|
|
32
|
+
return {} unless options.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
OPTION_KEYS.each_with_object({}) do |key, extracted|
|
|
35
|
+
extracted[key] = options.delete(key) if options.key?(key)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Records one instruction. +action+ names an I18n key under howdoc.actions;
|
|
40
|
+
# the remaining keyword arguments are interpolated into it. Pass
|
|
41
|
+
# <tt>html:</tt> instead to supply already-rendered markup, for the rare
|
|
42
|
+
# step no sentence describes.
|
|
43
|
+
def record(action = nil, html: nil, arrival: false, nodoc: false, **payload)
|
|
44
|
+
return nil if nodoc || !recording?
|
|
45
|
+
|
|
46
|
+
markup = html || Narrator.call(action, locale: current.locale, **payload)
|
|
47
|
+
return nil if markup.nil?
|
|
48
|
+
|
|
49
|
+
current.new_step(html: markup, arrival:)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Illustrates the step recorded most recently. Separate from +record+
|
|
53
|
+
# because a screenshot has to be taken while the browser is still on the
|
|
54
|
+
# page, whereas the sentence can be composed at any time.
|
|
55
|
+
def capture(page, full_page: false, no_screenshot: false)
|
|
56
|
+
return nil if no_screenshot || !recording?
|
|
57
|
+
|
|
58
|
+
step = current.last_step || current.new_step
|
|
59
|
+
filename = current.image_filename(step.number)
|
|
60
|
+
|
|
61
|
+
Screenshot.capture(
|
|
62
|
+
page,
|
|
63
|
+
path: File.join(current.image_dir, filename),
|
|
64
|
+
height: Howdoc.config.screenshot_height,
|
|
65
|
+
full_page:
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
step.screenshot = filename
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Convenience for the common shape: say what happens, then show it.
|
|
72
|
+
def record_and_capture(action, page:, options: {}, **payload)
|
|
73
|
+
step = record(action, nodoc: options[:nodoc], **payload)
|
|
74
|
+
capture(page, full_page: options[:full_page], no_screenshot: options[:no_screenshot]) if step
|
|
75
|
+
step
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Howdoc
|
|
7
|
+
# The index, assembled at the end of a run from the guides themselves.
|
|
8
|
+
#
|
|
9
|
+
# Tests run in forked workers, so nothing held in memory survives a suite and
|
|
10
|
+
# the index has to be built from what is on disk. What is on disk is the
|
|
11
|
+
# guides -- and their markup is not a foreign format to be parsed
|
|
12
|
+
# defensively, because this gem wrote it. One element carries everything the
|
|
13
|
+
# index needs.
|
|
14
|
+
#
|
|
15
|
+
# That is the entire contract with an overriding template: keep the document
|
|
16
|
+
# heading in <title>. Everything else the index shows comes from where a file
|
|
17
|
+
# sits rather than from what is inside it, so there is nothing else to break.
|
|
18
|
+
module Registry
|
|
19
|
+
INDEX_FILENAME = 'index.html'
|
|
20
|
+
|
|
21
|
+
TITLE = %r{<title[^>]*>(.*?)</title>}mi
|
|
22
|
+
|
|
23
|
+
# A heading opens with an identifier when it reads like "1.3. Some title".
|
|
24
|
+
# The identifier has to carry a digit, so an ordinary sentence beginning
|
|
25
|
+
# "Mr. Smith" is not mistaken for one.
|
|
26
|
+
HEADING = /\A([\w.]*\d[\w.]*)\.\s+(.+)\z/m
|
|
27
|
+
|
|
28
|
+
# A guide as it appears in the index. It answers to the same readers a live
|
|
29
|
+
# Howdoc::Document does, so configuration hooks work with either.
|
|
30
|
+
Record = Struct.new(:locale, :permalink, :heading, :id, :title, keyword_init: true) do
|
|
31
|
+
def filename
|
|
32
|
+
"#{permalink}.html"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def records(root = Howdoc.config.root)
|
|
39
|
+
Dir.glob(File.join(root, '*', '*.html')).filter_map { |path| read(path) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def read(path)
|
|
43
|
+
basename = File.basename(path)
|
|
44
|
+
return nil if generated_page?(basename)
|
|
45
|
+
|
|
46
|
+
heading = title_of(File.read(path))
|
|
47
|
+
return nil if heading.nil? || heading.empty?
|
|
48
|
+
|
|
49
|
+
id, title = split_heading(heading)
|
|
50
|
+
|
|
51
|
+
Record.new(
|
|
52
|
+
locale: File.basename(File.dirname(path)),
|
|
53
|
+
permalink: File.basename(basename, '.html'),
|
|
54
|
+
heading:, id:, title:
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The index is not a guide, and neither is a landing page somebody
|
|
59
|
+
# maintains by hand.
|
|
60
|
+
def generated_page?(basename)
|
|
61
|
+
basename == INDEX_FILENAME || Howdoc.config.preserved_files.include?(basename)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def title_of(markup)
|
|
65
|
+
match = markup.match(TITLE)
|
|
66
|
+
return nil if match.nil?
|
|
67
|
+
|
|
68
|
+
CGI.unescapeHTML(match[1]).strip
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def split_heading(heading)
|
|
72
|
+
match = heading.match(HEADING)
|
|
73
|
+
match ? [match[1], match[2]] : [nil, heading]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def by_locale(root = Howdoc.config.root)
|
|
77
|
+
found = records(root).group_by(&:locale)
|
|
78
|
+
return found if Howdoc.config.locales.nil?
|
|
79
|
+
|
|
80
|
+
Howdoc.config.locales.to_h { |locale| [locale.to_s, found.fetch(locale.to_s, [])] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Builds the index for every locale that produced at least one guide.
|
|
84
|
+
def write_indexes(root = Howdoc.config.root)
|
|
85
|
+
Templates.install_assets(File.join(root, 'assets')) if Howdoc.config.install_assets
|
|
86
|
+
|
|
87
|
+
by_locale(root).map do |locale, records|
|
|
88
|
+
Writers::Index.call(locale:, records: sort(records), root:)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def sort(records)
|
|
93
|
+
records.sort_by { |record| Howdoc.config.sort_key.call(record).to_s }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Removes generated guides while leaving hand-maintained pages in place, so
|
|
97
|
+
# a run never inherits a guide whose test has since been deleted.
|
|
98
|
+
#
|
|
99
|
+
# Only what this gem writes is removed. An output directory usually holds
|
|
100
|
+
# other things -- a landing page, a stylesheet an application maintains by
|
|
101
|
+
# hand -- and a cleaner that swept the whole directory would eat them.
|
|
102
|
+
def clean(root = Howdoc.config.root)
|
|
103
|
+
Dir.glob(File.join(root, '*')).each do |dir|
|
|
104
|
+
next unless File.directory?(dir)
|
|
105
|
+
|
|
106
|
+
FileUtils.rm_rf(File.join(dir, 'images'))
|
|
107
|
+
|
|
108
|
+
Dir.glob(File.join(dir, '*.html')).each do |entry|
|
|
109
|
+
next if Howdoc.config.preserved_files.include?(File.basename(entry))
|
|
110
|
+
|
|
111
|
+
FileUtils.rm_f(entry)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Howdoc
|
|
4
|
+
# Guides are illustrated, and an illustration is only useful if every picture
|
|
5
|
+
# in the set has the same proportions. The browser window is therefore sized
|
|
6
|
+
# deliberately before each capture rather than left wherever the test put it.
|
|
7
|
+
module Screenshot
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def capture(page, path:, height:, full_page: false)
|
|
11
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
12
|
+
|
|
13
|
+
width, current_height = page.current_window.size
|
|
14
|
+
target = full_page ? full_page_height(page) : height
|
|
15
|
+
|
|
16
|
+
page.current_window.resize_to(width, target) unless target == current_height
|
|
17
|
+
|
|
18
|
+
page.save_screenshot(path, full: true)
|
|
19
|
+
path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def full_page_height(page)
|
|
23
|
+
page.execute_script('return document.body.scrollHeight;').to_i + 30
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/howdoc/step.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Howdoc
|
|
4
|
+
# One numbered instruction in a guide: a sentence, optionally an illustration,
|
|
5
|
+
# and a flag for the steps that mark the reader's arrival on a new page.
|
|
6
|
+
class Step
|
|
7
|
+
attr_reader :number
|
|
8
|
+
attr_accessor :html, :screenshot, :arrival
|
|
9
|
+
|
|
10
|
+
def initialize(number:, html: nil, screenshot: nil, arrival: false)
|
|
11
|
+
@number = number
|
|
12
|
+
@html = html
|
|
13
|
+
@screenshot = screenshot
|
|
14
|
+
@arrival = arrival
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def empty?
|
|
18
|
+
html.nil? && screenshot.nil?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/howdoc/tasks.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rake'
|
|
4
|
+
require 'howdoc'
|
|
5
|
+
|
|
6
|
+
namespace :howdoc do
|
|
7
|
+
desc 'Remove generated guides, leaving hand-maintained pages in place'
|
|
8
|
+
task :clean do
|
|
9
|
+
Howdoc.clean
|
|
10
|
+
puts "Howdoc: cleaned #{Howdoc.config.root}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc 'Rebuild the index from the manifests already on disk'
|
|
14
|
+
task :index do
|
|
15
|
+
written = Howdoc::Registry.write_indexes
|
|
16
|
+
puts "Howdoc: wrote #{written.size} index file(s)"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'haml'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Howdoc
|
|
7
|
+
# Page chrome lives here, never in the code that records steps. Directories
|
|
8
|
+
# are searched most-recently-registered first and the gem's own set is always
|
|
9
|
+
# searched last, so an application overrides one file by mirroring its path
|
|
10
|
+
# and leaves the rest alone. The layout is borrowed from YARD, which has been
|
|
11
|
+
# proving it works for well over a decade.
|
|
12
|
+
module Templates
|
|
13
|
+
DEFAULT_ROOT = File.expand_path('../../templates', __dir__)
|
|
14
|
+
DEFAULT_NAME = 'default'
|
|
15
|
+
|
|
16
|
+
# Renders a template against a hash of assigns, exposed to the template as
|
|
17
|
+
# instance variables.
|
|
18
|
+
class Context
|
|
19
|
+
def initialize(assigns)
|
|
20
|
+
@assigns = assigns
|
|
21
|
+
assigns.each { |name, value| instance_variable_set(:"@#{name}", value) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Renders another template with the same assigns plus any extras, for
|
|
25
|
+
# partials that need a single item out of a collection.
|
|
26
|
+
def render(relative, extra = {})
|
|
27
|
+
Templates.render(relative, @assigns.merge(extra))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class MissingTemplate < StandardError; end
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def search_paths
|
|
36
|
+
Howdoc.config.template_paths + [DEFAULT_ROOT]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Finds +relative+ (for example "document/html/layout.haml") in the first
|
|
40
|
+
# search path that has it, falling back to the default template name so a
|
|
41
|
+
# partial theme only has to carry the files it actually changes.
|
|
42
|
+
def find(relative, template: DEFAULT_NAME)
|
|
43
|
+
candidates = search_paths.flat_map do |root|
|
|
44
|
+
[File.join(root, template, relative), File.join(root, DEFAULT_NAME, relative)]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
candidates.uniq.find { |path| File.file?(path) } ||
|
|
48
|
+
raise(MissingTemplate, "no template found for #{relative.inspect} in #{search_paths.inspect}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render(relative, assigns = {})
|
|
52
|
+
compiled(find(relative)).render(Context.new(assigns))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Haml compiles a template into Ruby once and then renders it; a suite that
|
|
56
|
+
# writes a hundred guides should pay for that compilation once.
|
|
57
|
+
def compiled(path)
|
|
58
|
+
@compiled ||= {}
|
|
59
|
+
@compiled[path] ||= Haml::Template.new(path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Copies everything under an "assets" directory of every search path into
|
|
63
|
+
# the output directory, later paths first so an application's file wins.
|
|
64
|
+
def install_assets(into)
|
|
65
|
+
FileUtils.mkdir_p(into)
|
|
66
|
+
|
|
67
|
+
search_paths.reverse_each do |root|
|
|
68
|
+
assets = File.join(root, DEFAULT_NAME, 'assets')
|
|
69
|
+
next unless File.directory?(assets)
|
|
70
|
+
|
|
71
|
+
Dir.glob(File.join(assets, '*')).each { |file| FileUtils.cp(file, into) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Howdoc
|
|
6
|
+
# One recorded document, several possible outputs. Adding a format is adding a
|
|
7
|
+
# class here, not rewriting the engine -- the same separation rspec_api_
|
|
8
|
+
# documentation used to turn one test run into HTML and half a dozen API
|
|
9
|
+
# description languages.
|
|
10
|
+
module Writers
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def registry
|
|
14
|
+
@registry ||= { html: Html }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register(name, writer)
|
|
18
|
+
registry[name.to_sym] = writer
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch(name)
|
|
22
|
+
registry.fetch(name.to_sym) do
|
|
23
|
+
raise ArgumentError, "unknown Howdoc format #{name.inspect}, known: #{registry.keys.inspect}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write(document)
|
|
28
|
+
Howdoc.config.formats.map { |format| fetch(format).call(document) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Writes the illustrated guide a reader actually opens.
|
|
32
|
+
class Html
|
|
33
|
+
def self.call(document)
|
|
34
|
+
FileUtils.mkdir_p(document.dir)
|
|
35
|
+
markup = Templates.render(
|
|
36
|
+
'document/html/layout.haml',
|
|
37
|
+
document:, config: Howdoc.config
|
|
38
|
+
)
|
|
39
|
+
File.write(document.path('html'), markup)
|
|
40
|
+
document.path('html')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Writes one index per locale, from the guides the run just produced.
|
|
45
|
+
class Index
|
|
46
|
+
def self.call(locale:, records:, root: Howdoc.config.root)
|
|
47
|
+
dir = File.join(root, locale.to_s)
|
|
48
|
+
FileUtils.mkdir_p(dir)
|
|
49
|
+
|
|
50
|
+
markup = Templates.render(
|
|
51
|
+
'index/html/layout.haml',
|
|
52
|
+
locale:, records:, config: Howdoc.config
|
|
53
|
+
)
|
|
54
|
+
path = File.join(dir, 'index.html')
|
|
55
|
+
File.write(path, markup)
|
|
56
|
+
path
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/howdoc.rb
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'i18n'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
require_relative 'howdoc/version'
|
|
7
|
+
require_relative 'howdoc/configuration'
|
|
8
|
+
require_relative 'howdoc/narrator'
|
|
9
|
+
require_relative 'howdoc/step'
|
|
10
|
+
require_relative 'howdoc/document'
|
|
11
|
+
require_relative 'howdoc/screenshot'
|
|
12
|
+
require_relative 'howdoc/recorder'
|
|
13
|
+
require_relative 'howdoc/templates'
|
|
14
|
+
require_relative 'howdoc/writers'
|
|
15
|
+
require_relative 'howdoc/registry'
|
|
16
|
+
|
|
17
|
+
# Generates end-user documentation from Capybara system tests.
|
|
18
|
+
#
|
|
19
|
+
# A test that already drives the browser knows every step a person would take.
|
|
20
|
+
# Howdoc listens to those steps, narrates them, photographs the result and
|
|
21
|
+
# writes an illustrated guide. Because the guide comes out of a test that has
|
|
22
|
+
# to pass, it cannot quietly drift away from the application it describes.
|
|
23
|
+
module Howdoc
|
|
24
|
+
LOCALES_ROOT = File.expand_path('../config/locales', __dir__)
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def config
|
|
28
|
+
@config ||= Configuration.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configure
|
|
32
|
+
yield(config)
|
|
33
|
+
install_locales
|
|
34
|
+
config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reset!
|
|
38
|
+
@config = nil
|
|
39
|
+
Recorder.current = nil
|
|
40
|
+
Narrator.reset_missing_keys
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def enabled?
|
|
44
|
+
config.enabled?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Adds the gem's own English and then any translations the application
|
|
48
|
+
# registered, so an application only has to translate what it wants to say
|
|
49
|
+
# differently.
|
|
50
|
+
def install_locales
|
|
51
|
+
globs = [File.join(LOCALES_ROOT, '*.yml')] + config.locale_paths
|
|
52
|
+
added = globs.flat_map { |glob| Dir.glob(glob) } - I18n.load_path
|
|
53
|
+
|
|
54
|
+
return if added.empty?
|
|
55
|
+
|
|
56
|
+
I18n.load_path.concat(added)
|
|
57
|
+
I18n.backend.reload! if I18n.backend.respond_to?(:reload!)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Begins a guide. Everything recorded until #finish belongs to it.
|
|
61
|
+
def start(id: nil, title: nil, locale: I18n.locale, permalink: nil, intro: nil)
|
|
62
|
+
return nil unless enabled?
|
|
63
|
+
return nil if title.to_s.strip.empty?
|
|
64
|
+
|
|
65
|
+
Recorder.current = Document.new(id:, title:, locale:, permalink:, intro:)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Ends the guide and writes it out. Called even when the test failed, so a
|
|
69
|
+
# broken step leaves visible evidence instead of no page at all.
|
|
70
|
+
def finish
|
|
71
|
+
document = Recorder.current
|
|
72
|
+
Recorder.current = nil
|
|
73
|
+
return nil if document.nil? || document.steps.empty?
|
|
74
|
+
|
|
75
|
+
Writers.write(document)
|
|
76
|
+
document
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def current
|
|
80
|
+
Recorder.current
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def record(...)
|
|
84
|
+
Recorder.record(...)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def capture(...)
|
|
88
|
+
Recorder.capture(...)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def record_and_capture(...)
|
|
92
|
+
Recorder.record_and_capture(...)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extract_options!(...)
|
|
96
|
+
Recorder.extract_options!(...)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Assembles the index from the manifests. Run once, after the whole suite.
|
|
100
|
+
def finalize
|
|
101
|
+
return [] unless enabled?
|
|
102
|
+
|
|
103
|
+
Registry.write_indexes
|
|
104
|
+
ensure
|
|
105
|
+
warn_about_missing_translations
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def clean
|
|
109
|
+
Registry.clean
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def warn_about_missing_translations
|
|
113
|
+
keys = Narrator.missing_keys
|
|
114
|
+
return if keys.empty?
|
|
115
|
+
|
|
116
|
+
warn "\nHowdoc: #{keys.size} untranslated step(s), the guides are missing those instructions:"
|
|
117
|
+
keys.sort.each { |key| warn " #{key}" }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|