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.
@@ -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
@@ -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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Howdoc
4
+ VERSION = '0.1.0'
5
+ 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