develoz-rails 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.
Files changed (172) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +31 -0
  3. data/.github/workflows/release.yml +459 -0
  4. data/.rubocop.yml +52 -0
  5. data/.sisyphus/notepads/develoz-rails-template/learnings.md +855 -0
  6. data/.tool-versions +2 -0
  7. data/CHANGELOG.md +28 -0
  8. data/CONTRIBUTING.md +120 -0
  9. data/README.md +76 -0
  10. data/Rakefile +18 -0
  11. data/config/generators.yml +105 -0
  12. data/docs/guide/installation.md +574 -0
  13. data/exe/develoz +6 -0
  14. data/install +98 -0
  15. data/lib/develoz/canonical_fetcher.rb +56 -0
  16. data/lib/develoz/cli.rb +84 -0
  17. data/lib/develoz/generators/base.rb +134 -0
  18. data/lib/develoz/manifest.rb +44 -0
  19. data/lib/develoz/options.rb +55 -0
  20. data/lib/develoz/version.rb +5 -0
  21. data/lib/develoz/version_resolver.rb +103 -0
  22. data/lib/develoz.rb +15 -0
  23. data/lib/generators/develoz/active_resource/active_resource_generator.rb +25 -0
  24. data/lib/generators/develoz/active_resource/templates/application_resource.rb.tt +7 -0
  25. data/lib/generators/develoz/active_resource/templates/example_resource.rb.tt +5 -0
  26. data/lib/generators/develoz/admin/admin_generator.rb +44 -0
  27. data/lib/generators/develoz/admin/templates/admin_base_controller.rb.tt +7 -0
  28. data/lib/generators/develoz/admin/templates/admin_layout.html.erb.tt +38 -0
  29. data/lib/generators/develoz/admin/templates/dashboard_controller.rb.tt +7 -0
  30. data/lib/generators/develoz/admin/templates/dashboard_index.html.erb.tt +2 -0
  31. data/lib/generators/develoz/admin/templates/routes.rb.tt +3 -0
  32. data/lib/generators/develoz/agents_docs/agents_docs_generator.rb +50 -0
  33. data/lib/generators/develoz/agents_docs/templates/.github/pull_request_template.md.tt +13 -0
  34. data/lib/generators/develoz/agents_docs/templates/AGENTS.md.tt +36 -0
  35. data/lib/generators/develoz/agents_docs/templates/docs/development.md.tt +121 -0
  36. data/lib/generators/develoz/agents_docs/templates/docs/performance.md.tt +46 -0
  37. data/lib/generators/develoz/agents_docs/templates/docs/testing.md.tt +92 -0
  38. data/lib/generators/develoz/agents_docs/templates/spec/cassettes/Example_API/fetches_data_from_an_external_API.yml.tt +23 -0
  39. data/lib/generators/develoz/agents_docs/templates/spec/requests/example_api_spec.rb.tt +14 -0
  40. data/lib/generators/develoz/agents_docs/templates/spec/support/faraday.rb.tt +19 -0
  41. data/lib/generators/develoz/agents_docs/templates/spec/support/vcr.rb.tt +11 -0
  42. data/lib/generators/develoz/api/api_generator.rb +55 -0
  43. data/lib/generators/develoz/api/templates/app/blueprints/example_blueprint.rb.tt +6 -0
  44. data/lib/generators/develoz/api/templates/app/controllers/api/v1/base_controller.rb.tt +96 -0
  45. data/lib/generators/develoz/api/templates/config/initializers/blueprinter.rb.tt +16 -0
  46. data/lib/generators/develoz/api/templates/config/initializers/rswag_api.rb.tt +15 -0
  47. data/lib/generators/develoz/api/templates/config/initializers/rswag_ui.rb.tt +17 -0
  48. data/lib/generators/develoz/api/templates/spec/requests/api/v1/examples_spec.rb.tt +25 -0
  49. data/lib/generators/develoz/auth/auth_generator.rb +82 -0
  50. data/lib/generators/develoz/auth/templates/app/controllers/concerns/authentication.rb.tt +53 -0
  51. data/lib/generators/develoz/auth/templates/app/controllers/passwords_controller.rb.tt +35 -0
  52. data/lib/generators/develoz/auth/templates/app/controllers/sessions_controller.rb.tt +22 -0
  53. data/lib/generators/develoz/auth/templates/app/mailers/passwords_mailer.rb.tt +8 -0
  54. data/lib/generators/develoz/auth/templates/app/models/current.rb.tt +5 -0
  55. data/lib/generators/develoz/auth/templates/app/models/user.rb.tt +7 -0
  56. data/lib/generators/develoz/auth/templates/app/views/passwords/edit.html.erb.tt +9 -0
  57. data/lib/generators/develoz/auth/templates/app/views/passwords_mailer/reset.html.erb.tt +6 -0
  58. data/lib/generators/develoz/auth/templates/app/views/sessions/new.html.erb.tt +11 -0
  59. data/lib/generators/develoz/auth/templates/db/migrate/create_users.rb.tt +13 -0
  60. data/lib/generators/develoz/auth/templates/spec/requests/passwords_spec.rb.tt +60 -0
  61. data/lib/generators/develoz/auth/templates/spec/requests/sessions_spec.rb.tt +37 -0
  62. data/lib/generators/develoz/ci/ci_generator.rb +66 -0
  63. data/lib/generators/develoz/ci/templates/.github/workflows/ci.yml.tt +18 -0
  64. data/lib/generators/develoz/ci/templates/.haml-lint.yml.tt +12 -0
  65. data/lib/generators/develoz/ci/templates/.markdownlint.json.tt +10 -0
  66. data/lib/generators/develoz/ci/templates/.reek.yml.tt +28 -0
  67. data/lib/generators/develoz/ci/templates/.rubocop.yml.tt +22 -0
  68. data/lib/generators/develoz/ci/templates/.stylelintrc.json.tt +4 -0
  69. data/lib/generators/develoz/ci/templates/.yamllint.tt +14 -0
  70. data/lib/generators/develoz/ci/templates/Rakefile.tt +73 -0
  71. data/lib/generators/develoz/ci/templates/bin/ci.tt +6 -0
  72. data/lib/generators/develoz/ci/templates/biome.json.tt +41 -0
  73. data/lib/generators/develoz/ci/templates/config/ci.rb.tt +23 -0
  74. data/lib/generators/develoz/concerns/concerns_generator.rb +48 -0
  75. data/lib/generators/develoz/concerns/templates/app/models/concerns/configurable.rb.tt +120 -0
  76. data/lib/generators/develoz/concerns/templates/app/models/concerns/optimized_finders.rb.tt +93 -0
  77. data/lib/generators/develoz/concerns/templates/app/models/concerns/searchable_concern.rb.tt +31 -0
  78. data/lib/generators/develoz/concerns/templates/app/models/concerns/transitionable.rb.tt +144 -0
  79. data/lib/generators/develoz/concerns/templates/app/models/configuration.rb.tt +104 -0
  80. data/lib/generators/develoz/concerns/templates/db/migrate/add_status_transitions.rb.tt +30 -0
  81. data/lib/generators/develoz/concerns/templates/db/migrate/create_configurations.rb.tt +25 -0
  82. data/lib/generators/develoz/concerns/templates/spec/models/concerns/configurable_spec.rb.tt +172 -0
  83. data/lib/generators/develoz/concerns/templates/spec/models/concerns/optimized_finders_spec.rb.tt +200 -0
  84. data/lib/generators/develoz/concerns/templates/spec/models/concerns/searchable_concern_spec.rb.tt +84 -0
  85. data/lib/generators/develoz/concerns/templates/spec/models/concerns/transitionable_spec.rb.tt +234 -0
  86. data/lib/generators/develoz/concerns/templates/spec/models/configuration_spec.rb.tt +223 -0
  87. data/lib/generators/develoz/database/database_generator.rb +44 -0
  88. data/lib/generators/develoz/database/templates/config/database.yml.tt +53 -0
  89. data/lib/generators/develoz/database/templates/config/initializers/pg_search.rb.tt +5 -0
  90. data/lib/generators/develoz/database/templates/db/migrate/create_pg_extensions.rb.tt +13 -0
  91. data/lib/generators/develoz/db_backup/db_backup_generator.rb +47 -0
  92. data/lib/generators/develoz/db_backup/templates/backup_rake.tt +48 -0
  93. data/lib/generators/develoz/db_backup/templates/bin_db_backup.tt +25 -0
  94. data/lib/generators/develoz/db_backup/templates/compose_service.tt +14 -0
  95. data/lib/generators/develoz/doc_specs/doc_specs_generator.rb +33 -0
  96. data/lib/generators/develoz/doc_specs/templates/bin/generate-docs.tt +392 -0
  97. data/lib/generators/develoz/doc_specs/templates/lib/tasks/docs_check.rake.tt +8 -0
  98. data/lib/generators/develoz/doc_specs/templates/spec/support/doc_screenshot_helper.rb.tt +17 -0
  99. data/lib/generators/develoz/doc_specs/templates/spec/system/example_doc_spec.rb.tt +38 -0
  100. data/lib/generators/develoz/docker/docker_generator.rb +54 -0
  101. data/lib/generators/develoz/docker/templates/bin_dev.tt +5 -0
  102. data/lib/generators/develoz/docker/templates/bin_docker_entrypoint.tt +8 -0
  103. data/lib/generators/develoz/docker/templates/bin_run.tt +152 -0
  104. data/lib/generators/develoz/docker/templates/bin_setup.tt +15 -0
  105. data/lib/generators/develoz/docker/templates/docker-compose.yml.tt +59 -0
  106. data/lib/generators/develoz/docker/templates/dockerfile_dev.tt +24 -0
  107. data/lib/generators/develoz/docs_render/docs_render_generator.rb +46 -0
  108. data/lib/generators/develoz/docs_render/templates/app/assets/stylesheets/documentation.scss.tt +145 -0
  109. data/lib/generators/develoz/docs_render/templates/app/controllers/docs_controller.rb.tt +7 -0
  110. data/lib/generators/develoz/docs_render/templates/app/javascript/docs.js.tt +15 -0
  111. data/lib/generators/develoz/docs_render/templates/app/models/document.rb.tt +189 -0
  112. data/lib/generators/develoz/docs_render/templates/app/views/docs/show.html.erb.tt +7 -0
  113. data/lib/generators/develoz/docs_render/templates/config/initializers/redcarpet_rouge.rb.tt +16 -0
  114. data/lib/generators/develoz/frontend_core/frontend_core_generator.rb +40 -0
  115. data/lib/generators/develoz/frontend_core/templates/.annotaterb.yml.tt +16 -0
  116. data/lib/generators/develoz/frontend_core/templates/config/importmap.rb.tt +12 -0
  117. data/lib/generators/develoz/frontend_core/templates/config/initializers/pagy.rb.tt +10 -0
  118. data/lib/generators/develoz/install/install_generator.rb +51 -0
  119. data/lib/generators/develoz/kamal/kamal_generator.rb +59 -0
  120. data/lib/generators/develoz/kamal/templates/accessories_postgres.yml.tt +14 -0
  121. data/lib/generators/develoz/kamal/templates/deploy.yml.tt +43 -0
  122. data/lib/generators/develoz/kamal/templates/dockerfile_prod.tt +54 -0
  123. data/lib/generators/develoz/kamal/templates/kamal_secrets.tt +22 -0
  124. data/lib/generators/develoz/maintenance/maintenance_generator.rb +29 -0
  125. data/lib/generators/develoz/maintenance/templates/app/tasks/maintenance/example_task.rb.tt +20 -0
  126. data/lib/generators/develoz/maintenance/templates/lib/tasks/maintenance_counters.rake.tt +27 -0
  127. data/lib/generators/develoz/push/push_generator.rb +61 -0
  128. data/lib/generators/develoz/push/templates/app/javascript/pwa/subscription.js.tt +57 -0
  129. data/lib/generators/develoz/push/templates/app/models/push_subscription.rb.tt +14 -0
  130. data/lib/generators/develoz/push/templates/app/services/push_notification_service.rb.tt +36 -0
  131. data/lib/generators/develoz/push/templates/app/views/pwa/sw_push_handlers.js.tt +49 -0
  132. data/lib/generators/develoz/push/templates/constants_env.tt +5 -0
  133. data/lib/generators/develoz/push/templates/db/migrate/create_push_subscriptions.rb.tt +15 -0
  134. data/lib/generators/develoz/pwa/pwa_generator.rb +46 -0
  135. data/lib/generators/develoz/pwa/templates/app/controllers/pwa_controller.rb.tt +17 -0
  136. data/lib/generators/develoz/pwa/templates/app/javascript/pwa/registration.js.tt +25 -0
  137. data/lib/generators/develoz/pwa/templates/app/views/pwa/manifest.json.erb.tt +24 -0
  138. data/lib/generators/develoz/pwa/templates/app/views/pwa/offline.html.erb.tt +51 -0
  139. data/lib/generators/develoz/pwa/templates/app/views/pwa/service-worker.js.tt +109 -0
  140. data/lib/generators/develoz/pwa/templates/routes.rb.tt +3 -0
  141. data/lib/generators/develoz/solid/solid_generator.rb +52 -0
  142. data/lib/generators/develoz/solid/templates/app/jobs/application_job.rb.tt +7 -0
  143. data/lib/generators/develoz/solid/templates/config/cable.yml.tt +10 -0
  144. data/lib/generators/develoz/solid/templates/config/cache.yml.tt +16 -0
  145. data/lib/generators/develoz/solid/templates/config/initializers/mission_control.rb.tt +8 -0
  146. data/lib/generators/develoz/solid/templates/config/initializers/solid.rb.tt +7 -0
  147. data/lib/generators/develoz/solid/templates/config/queue.yml.tt +18 -0
  148. data/lib/generators/develoz/solid/templates/config/recurring.yml.tt +20 -0
  149. data/lib/generators/develoz/strict_loading/strict_loading_generator.rb +17 -0
  150. data/lib/generators/develoz/strict_loading/templates/config/initializers/strict_loading.rb.tt +13 -0
  151. data/lib/generators/develoz/testing/templates/rspec.tt +2 -0
  152. data/lib/generators/develoz/testing/templates/rspec_parallel.tt +2 -0
  153. data/lib/generators/develoz/testing/templates/spec/rails_helper.rb.tt +32 -0
  154. data/lib/generators/develoz/testing/templates/spec/spec_helper.rb.tt +31 -0
  155. data/lib/generators/develoz/testing/testing_generator.rb +39 -0
  156. data/lib/generators/develoz/tooling/templates/constants.rb.tt +17 -0
  157. data/lib/generators/develoz/tooling/templates/env.example.tt +4 -0
  158. data/lib/generators/develoz/tooling/templates/vscode/extensions.json.tt +8 -0
  159. data/lib/generators/develoz/tooling/templates/vscode/settings.json.tt +26 -0
  160. data/lib/generators/develoz/tooling/templates/vscode/tasks.json.tt +26 -0
  161. data/lib/generators/develoz/tooling/tooling_generator.rb +33 -0
  162. data/lib/generators/develoz/ui/templates/bin/setup_develoz_ui.tt +20 -0
  163. data/lib/generators/develoz/ui/ui_generator.rb +57 -0
  164. data/lib/generators/develoz/versioning/templates/app/helpers/application_helper.rb.tt +7 -0
  165. data/lib/generators/develoz/versioning/templates/app/views/shared/_app_version.html.erb.tt +3 -0
  166. data/lib/generators/develoz/versioning/templates/spec/helpers/application_helper_spec.rb.tt +11 -0
  167. data/lib/generators/develoz/versioning/versioning_generator.rb +48 -0
  168. data/lib/tasks/canonical.rake +27 -0
  169. data/templates/.keep +0 -0
  170. data/templates/CANONICAL_SOURCES.md +93 -0
  171. data/templates/pull_request_template.md.tt +13 -0
  172. metadata +383 -0
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'fileutils'
5
+
6
+ # Script to generate markdown documentation from system specs with doc comments
7
+ #
8
+ # Usage:
9
+ # bin/generate-docs # Generate docs for all system specs with doc comments
10
+ # bin/generate-docs spec/path/to/file # Generate docs for a specific spec file
11
+ # bin/generate-docs --check # Verify committed docs match their specs (no spec run); exit 1 if stale
12
+
13
+ class DocGenerator
14
+ DOCS_DIR = 'docs/platform-guide'
15
+ SCREENSHOTS_DIR = 'docs/screenshots'
16
+
17
+ # Fallbacks for specs that omit the directives below. Specs with no
18
+ # `@category` collect under DEFAULT_CATEGORY; specs with no `@order` sort
19
+ # after numbered ones. Both defaults push such docs to the end of the TOC.
20
+ DEFAULT_CATEGORY = 'Other'
21
+ DEFAULT_ORDER = 999_999
22
+
23
+ # Metadata directives a spec places in its doc comments to control where it
24
+ # appears in the table of contents. They are parsed out of the comment stream
25
+ # and never rendered into the generated doc body, e.g.
26
+ #
27
+ # # @category Audits & Reports
28
+ # # @order 510
29
+ #
30
+ # # = Feature Name
31
+ #
32
+ # `@order` is a single global ordering value across all guides. The README
33
+ # groups guides by `@category` and lists categories in ascending order of the
34
+ # smallest `@order` they contain, so the entire structure is driven by the
35
+ # specs themselves — there is no category list to keep in sync here.
36
+ CATEGORY_DIRECTIVE = /^\s*#\s*@category\s+(.+?)\s*$/
37
+ ORDER_DIRECTIVE = /^\s*#\s*@order\s+(\d+)\s*$/
38
+ DIRECTIVE = /^\s*#\s*@(?:category|order)\b/
39
+
40
+ def initialize(spec_files = nil)
41
+ @spec_files = spec_files || find_documented_specs
42
+ end
43
+
44
+ def generate(run_specs: true)
45
+ if @spec_files.empty?
46
+ puts 'No system specs with documentation comments found.'
47
+ return
48
+ end
49
+
50
+ @spec_files.each do |spec_file|
51
+ puts "\nGenerating documentation for #{spec_file}..."
52
+ process_spec(spec_file, run_specs:)
53
+ end
54
+
55
+ generate_readme
56
+
57
+ puts "\n✓ Documentation generation complete!"
58
+ puts " Generated #{@spec_files.size} documentation file(s) in #{DOCS_DIR}/"
59
+ puts " Generated README.md with table of contents"
60
+ end
61
+
62
+ # Verify the committed platform-guide docs are in sync with their documented
63
+ # specs, without running the specs. Re-renders the doc body (headings, prose,
64
+ # screenshot references) from each spec and compares it against the committed
65
+ # markdown, ignoring the generation timestamp and the screenshot binaries
66
+ # themselves. Returns true when everything is up to date.
67
+ def check
68
+ stale = find_documented_specs.select { |spec_file| doc_stale?(spec_file) }
69
+
70
+ if stale.empty?
71
+ puts '✓ Platform-guide docs are up to date.'
72
+ return true
73
+ end
74
+
75
+ warn "✗ Platform-guide docs are out of date for #{stale.size} spec(s):"
76
+ stale.each { |spec_file| warn " - #{spec_file} -> #{generate_output_filename(spec_file)}" }
77
+ warn ''
78
+ warn 'Regenerate the affected guide(s) and commit the result:'
79
+ stale.each { |spec_file| warn " bin/run bin/generate-docs #{spec_file}" }
80
+ false
81
+ end
82
+
83
+ # Rebuild only the README table of contents from the committed docs and their
84
+ # spec `@category`/`@order` directives, without running any specs. Used after
85
+ # changing directives or category ordering.
86
+ def regenerate_readme
87
+ generate_readme
88
+ puts '✓ Regenerated README.md table of contents.'
89
+ end
90
+
91
+ private
92
+
93
+ def doc_stale?(spec_file)
94
+ output_file = generate_output_filename(spec_file)
95
+ return true unless File.exist?(output_file)
96
+
97
+ expected = "#{render_doc_body(extract_doc_comments(spec_file), extract_screenshot_calls(spec_file)).rstrip}\n"
98
+ committed = strip_timestamp_header(File.read(output_file, encoding: 'UTF-8'))
99
+ expected != committed
100
+ end
101
+
102
+ def strip_timestamp_header(content)
103
+ content.sub(/\A<!--\nGenerated: [^\n]*\n-->\n\n/, '')
104
+ end
105
+
106
+ def find_documented_specs
107
+ Dir.glob('spec/system/**/*_spec.rb').select do |file|
108
+ File.read(file, encoding: 'UTF-8').match?(/^#\s*=/)
109
+ end
110
+ end
111
+
112
+ def process_spec(spec_file, run_specs: true)
113
+ # Extract documentation comments and screenshot calls
114
+ doc_comments = extract_doc_comments(spec_file)
115
+ screenshot_calls = extract_screenshot_calls(spec_file)
116
+
117
+ if run_specs
118
+ # Run the spec with screenshots enabled
119
+ puts " Running spec with SCREENSHOTS=true..."
120
+ run_spec_with_screenshots(spec_file)
121
+ else
122
+ puts " Re-rendering from doc comments (using existing screenshots)..."
123
+ end
124
+
125
+ # Generate markdown file
126
+ output_file = generate_output_filename(spec_file)
127
+ generate_markdown(output_file, doc_comments, screenshot_calls)
128
+
129
+ puts " ✓ Generated #{output_file}"
130
+ end
131
+
132
+ def extract_doc_comments(spec_file)
133
+ content = File.read(spec_file, encoding: 'UTF-8')
134
+ lines = content.split("\n")
135
+ comments = []
136
+ current_block = { level: 0, title: '', content: [], line_number: 0 }
137
+
138
+ lines.each_with_index do |line, index|
139
+ if line =~ DIRECTIVE
140
+ # @category / @order are TOC metadata, not body content — skip them.
141
+ next
142
+ elsif line =~ /^(\s*)#\s*(=+)\s*(.+)/
143
+ # Save previous block if it exists
144
+ comments << current_block.dup unless current_block[:title].empty?
145
+
146
+ # Start new section
147
+ level = Regexp.last_match(2).length
148
+ title = Regexp.last_match(3).strip
149
+ current_block = { level:, title:, content: [], line_number: index + 1 }
150
+ elsif line =~ /^(\s*)#\s*(.*)/ && !current_block[:title].empty?
151
+ # Add to current block
152
+ content_line = Regexp.last_match(2).strip
153
+ current_block[:content] << content_line
154
+ elsif !current_block[:title].empty? && line !~ /^(\s*)#/
155
+ # End of comment block
156
+ comments << current_block.dup
157
+ current_block = { level: 0, title: '', content: [], line_number: 0 }
158
+ end
159
+ end
160
+
161
+ # Save last block if needed
162
+ comments << current_block unless current_block[:title].empty?
163
+
164
+ comments
165
+ end
166
+
167
+ def extract_screenshot_calls(spec_file)
168
+ content = File.read(spec_file, encoding: 'UTF-8')
169
+ lines = content.split("\n")
170
+ screenshots = []
171
+
172
+ lines.each_with_index do |line, index|
173
+ if line =~ /doc_screenshot\(['"]([\w\-\/]+)['"]\)/
174
+ screenshots << { path: Regexp.last_match(1), line_number: index + 1 }
175
+ end
176
+ end
177
+
178
+ screenshots
179
+ end
180
+
181
+ def run_spec_with_screenshots(spec_file)
182
+ # Ensure screenshots directory exists
183
+ FileUtils.mkdir_p(SCREENSHOTS_DIR)
184
+
185
+ # Run the spec
186
+ command = "SCREENSHOTS=true bundle exec rspec --format documentation #{spec_file}"
187
+ system(command, err: File::NULL)
188
+ end
189
+
190
+ def generate_output_filename(spec_file)
191
+ basename = File.basename(spec_file, '_spec.rb')
192
+ output_name = basename.upcase.gsub('_', '-') + '.md'
193
+ File.join(DOCS_DIR, output_name)
194
+ end
195
+
196
+ def generate_markdown(output_file, doc_comments, screenshot_calls)
197
+ FileUtils.mkdir_p(DOCS_DIR)
198
+ File.write(output_file, "#{timestamp_header}#{render_doc_body(doc_comments, screenshot_calls).rstrip}\n")
199
+ end
200
+
201
+ def timestamp_header
202
+ "<!--\nGenerated: #{Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')}\n-->\n\n"
203
+ end
204
+
205
+ # Render the markdown body (everything after the timestamp header) from a
206
+ # spec's doc comments and screenshot calls. Shared by generation and --check
207
+ # so both produce byte-identical output for the same spec.
208
+ def render_doc_body(doc_comments, screenshot_calls)
209
+ body = +''
210
+
211
+ doc_comments.each_with_index do |comment, index|
212
+ heading_tag = "h#{comment[:level]}"
213
+ heading_id = slugify(comment[:title])
214
+ body << "<#{heading_tag} id=\"#{heading_id}\">#{comment[:title]}</#{heading_tag}>\n\n"
215
+
216
+ comment[:content].each { |line| body << "#{line}\n" }
217
+ body << "\n"
218
+
219
+ next_comment_line = doc_comments[index + 1]&.dig(:line_number) || Float::INFINITY
220
+ screenshots = find_related_screenshots(comment[:line_number], next_comment_line, screenshot_calls)
221
+ screenshots.each do |screenshot|
222
+ rendered = render_screenshot(screenshot[:path])
223
+ body << rendered if rendered
224
+ end
225
+ end
226
+
227
+ body
228
+ end
229
+
230
+ def find_related_screenshots(current_line, next_line, screenshot_calls)
231
+ # Find screenshots that appear after this comment but before the next one
232
+ screenshot_calls.select do |screenshot|
233
+ screenshot[:line_number] > current_line && screenshot[:line_number] < next_line
234
+ end
235
+ end
236
+
237
+ def render_screenshot(screenshot_path)
238
+ # Only embed screenshots whose generated file exists on disk
239
+ return unless find_screenshot_file(screenshot_path)
240
+
241
+ # Use full path from docs/platform-guide to docs/screenshots
242
+ "![#{screenshot_path}](/docs/screenshots/#{screenshot_path}.png)\n\n"
243
+ end
244
+
245
+ def find_screenshot_file(screenshot_path)
246
+ # Screenshots are saved in docs/screenshots during test run
247
+ screenshot_file = File.join(SCREENSHOTS_DIR, "#{screenshot_path}.png")
248
+
249
+ if File.exist?(screenshot_file)
250
+ screenshot_file
251
+ end
252
+ end
253
+
254
+ def slugify(text)
255
+ text.downcase
256
+ .gsub(/[^a-z0-9\s-]/, '')
257
+ .gsub(/\s+/, '-')
258
+ .gsub(/-+/, '-')
259
+ .gsub(/^-|-$/, '')
260
+ end
261
+
262
+ # Writes the README table of contents grouped by `@category` and ordered by
263
+ # `@order`. Called at the end of a full/partial generation run and standalone
264
+ # via `regenerate_readme` (the `--readme-only` mode).
265
+ def generate_readme
266
+ readme_path = File.join(DOCS_DIR, 'README.md')
267
+
268
+ File.open(readme_path, 'w') do |f|
269
+ # Add generation timestamp
270
+ f.puts "<!--"
271
+ f.puts "Generated: #{Time.now.utc.strftime('%Y-%m-%d %H:%M:%S UTC')}"
272
+ f.puts "-->"
273
+ f.puts
274
+
275
+ f.puts '<h1 id="platform-guide">Platform Guide</h1>'
276
+ f.puts
277
+ f.puts 'This guide is the primary, automatically generated reference for operating the'
278
+ f.puts 'platform. It is extracted from the admin system specs, so every page reflects the'
279
+ f.puts 'behaviour that is actually tested in the codebase.'
280
+ f.puts
281
+ f.puts 'Guides are grouped by area below and ordered to follow a typical admin workflow —'
282
+ f.puts 'from onboarding a client through to running audits and imports.'
283
+
284
+ # Build the TOC from every documented spec (not just the ones regenerated
285
+ # in this run) so running for a subset of specs doesn't drop other
286
+ # entries. Group by `@category` and order by `@order`.
287
+ groups = toc_groups
288
+
289
+ f.puts category_workflow_diagram(groups.map(&:first))
290
+ f.puts
291
+ f.puts '<h2 id="table-of-contents">Table of Contents</h2>'
292
+
293
+ groups.each do |category, entries|
294
+ f.puts
295
+ f.puts %(<h3 id="#{slugify(category)}">#{category}</h3>)
296
+ f.puts
297
+ entries.each do |entry|
298
+ f.puts "- [#{entry[:title]}](#{entry[:file]})"
299
+ end
300
+ end
301
+ end
302
+ end
303
+
304
+ # A Mermaid flowchart of the guide categories in workflow order, rendered by
305
+ # the in-app docs viewer. Derived from the same ordered categories as the TOC,
306
+ # so it never drifts from the actual guides.
307
+ def category_workflow_diagram(categories)
308
+ return '' if categories.empty?
309
+
310
+ nodes = categories.each_with_index.map { |category, index| %(c#{index}["#{category}"]) }
311
+ <<~DIAGRAM.chomp
312
+
313
+ ```mermaid
314
+ flowchart LR
315
+ #{nodes.join(' --> ')}
316
+ ```
317
+ DIAGRAM
318
+ end
319
+
320
+ # Returns the table of contents as an ordered list of [category, entries]
321
+ # pairs. Category order is derived entirely from the specs: categories are
322
+ # listed in ascending order of the smallest `@order` they contain (ties broken
323
+ # alphabetically), and entries within a category are ordered by `@order` then
324
+ # title. Only specs whose generated doc actually exists on disk are linked.
325
+ def toc_groups
326
+ entries = find_documented_specs.filter_map do |spec_file|
327
+ file = File.basename(generate_output_filename(spec_file))
328
+ next unless File.exist?(File.join(DOCS_DIR, file))
329
+
330
+ directives = extract_directives(spec_file)
331
+ {
332
+ file:,
333
+ title: spec_doc_title(spec_file),
334
+ category: directives[:category] || DEFAULT_CATEGORY,
335
+ order: directives[:order] || DEFAULT_ORDER
336
+ }
337
+ end
338
+
339
+ entries
340
+ .group_by { |entry| entry[:category] }
341
+ .sort_by { |category, group| [group.map { |entry| entry[:order] }.min, category] }
342
+ .map { |category, group| [category, group.sort_by { |entry| [entry[:order], entry[:title]] }] }
343
+ end
344
+
345
+ # Read the `@category` / `@order` directives from a spec's doc comments. These
346
+ # control TOC placement only and are stripped from the rendered doc body.
347
+ def extract_directives(spec_file)
348
+ category = nil
349
+ order = nil
350
+
351
+ File.foreach(spec_file, encoding: 'UTF-8') do |line|
352
+ category ||= Regexp.last_match(1) if line =~ CATEGORY_DIRECTIVE
353
+ order ||= Regexp.last_match(1).to_i if line =~ ORDER_DIRECTIVE
354
+ end
355
+
356
+ { category:, order: }
357
+ end
358
+
359
+ # The doc title is the first top-level (`# =`, not `# ==`) heading comment.
360
+ def spec_doc_title(spec_file)
361
+ File.foreach(spec_file, encoding: 'UTF-8') do |line|
362
+ return Regexp.last_match(1).strip if line =~ /^\s*#\s*=(?!=)\s*(.+?)\s*$/
363
+ end
364
+
365
+ File.basename(spec_file, '_spec.rb').split('_').map(&:capitalize).join(' ')
366
+ end
367
+ end
368
+
369
+ # Parse command line arguments
370
+ args = ARGV.dup
371
+ check_mode = args.delete('--check')
372
+ readme_only = args.delete('--readme-only')
373
+ render_only = args.delete('--render-only')
374
+ spec_files = args.empty? ? nil : args
375
+
376
+ generator = DocGenerator.new(spec_files)
377
+
378
+ if check_mode
379
+ # Used by the `docs:check` rake task — verify docs without running specs.
380
+ exit(generator.check ? 0 : 1)
381
+ elsif readme_only
382
+ # Rebuild the table of contents from committed docs + spec directives only,
383
+ # without running any specs (fast; use after changing @category/@order).
384
+ generator.regenerate_readme
385
+ elsif render_only
386
+ # Re-render doc bodies from doc comments using the screenshots already on
387
+ # disk, without running any specs. Use after prose-only doc-comment edits
388
+ # (Mermaid diagrams, tables, blockquotes) that don't change screenshots.
389
+ generator.generate(run_specs: false)
390
+ else
391
+ generator.generate
392
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :docs do
4
+ desc "Check platform-guide docs are in sync with their documented system specs"
5
+ task :check do # rubocop:disable Rails/RakeEnvironment
6
+ ruby "bin/generate-docs --check"
7
+ end
8
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helper method for conditional screenshot capture
4
+ # Usage: doc_screenshot('01_page_name')
5
+ # Enable with: SCREENSHOTS=true rspec spec/...
6
+ module DocScreenshotHelper
7
+ def doc_screenshot(filename)
8
+ return unless ENV["SCREENSHOTS"] == "true"
9
+
10
+ sleep 0.5 # Allow time for animations to complete
11
+ save_screenshot(Rails.root.join("docs/screenshots/#{filename}.png")) # rubocop:disable Lint/Debugger
12
+ end
13
+ end
14
+
15
+ RSpec.configure do |config|
16
+ config.include DocScreenshotHelper, type: :system
17
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ #
6
+ # @category Getting Started
7
+ # @order 100
8
+
9
+ # = Example Documentation Spec
10
+ #
11
+ # This spec demonstrates the self-documenting system spec pattern.
12
+ # Doc comments starting with `# =` become headings in the generated markdown.
13
+ # Use `# ==` for sub-headings, `# ===` for sub-sub-headings, etc.
14
+ #
15
+ # The `@category` and `@order` directives control where this guide appears
16
+ # in the table of contents generated by `bin/generate-docs`.
17
+ #
18
+ # == Overview
19
+ #
20
+ # 1. Write doc comments above your system spec using `# = Heading` syntax
21
+ # 2. Call `doc_screenshot('category/screenshot_name')` to capture screenshots
22
+ # 3. Run `bin/generate-docs` to generate markdown documentation
23
+ # 4. Run `rake docs:check` to verify docs are in sync with specs
24
+ #
25
+ describe "Example Documentation" do
26
+ before do
27
+ visit "/docs"
28
+ end
29
+
30
+ it "displays the homepage" do
31
+ expect(page).to have_css("body")
32
+
33
+ # === Homepage Screenshot
34
+ #
35
+ # This screenshot captures the homepage as seen by users.
36
+ doc_screenshot("getting-started/homepage")
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "develoz"
4
+
5
+ module Develoz
6
+ module Generators
7
+ class DockerGenerator < Develoz::Generators::Base
8
+ def self.source_root
9
+ File.expand_path("templates", __dir__)
10
+ end
11
+
12
+ def create_docker_compose
13
+ template "docker-compose.yml.tt", "docker-compose.yml"
14
+ end
15
+
16
+ def create_dockerfile_dev
17
+ template "dockerfile_dev.tt", "Dockerfile.dev"
18
+ end
19
+
20
+ def create_bin_dev
21
+ template "bin_dev.tt", "bin/dev"
22
+ chmod "bin/dev", 0o755
23
+ end
24
+
25
+ def create_bin_setup
26
+ template "bin_setup.tt", "bin/setup"
27
+ chmod "bin/setup", 0o755
28
+ end
29
+
30
+ def create_bin_docker_entrypoint
31
+ template "bin_docker_entrypoint.tt", "bin/docker-entrypoint"
32
+ chmod "bin/docker-entrypoint", 0o755
33
+ end
34
+
35
+ def create_bin_run
36
+ template "bin_run.tt", "bin/run"
37
+ chmod "bin/run", 0o755
38
+ end
39
+
40
+ def wire_env
41
+ append_env "POSTGRES_USER", "postgres"
42
+ append_env "POSTGRES_PASSWORD", "postgres"
43
+ append_env "POSTGRES_DB", "#{app_name}_development"
44
+ append_env "DATABASE_URL", "postgres://postgres:postgres@postgres:5432/#{app_name}_development"
45
+ append_env "SELENIUM_URL", "http://selenium:4444/wd/hub"
46
+ append_env "MAILCATCHER_URL", "http://mailcatcher:1080"
47
+ end
48
+
49
+ def ensure_env_gitignored
50
+ ensure_gitignore(".env")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Start the development environment via Docker Compose
5
+ docker compose up --build
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Remove a potentially pre-existing server PID
5
+ rm -f /rails/tmp/pids/server.pid
6
+
7
+ # Run the Rails server in development
8
+ bin/rails server -b 0.0.0.0 -p 3000
@@ -0,0 +1,152 @@
1
+ #!/bin/bash
2
+
3
+ # Docker-aware command runner
4
+ # Runs commands locally or in Docker containers based on availability
5
+
6
+ set -e
7
+
8
+ docker_available() {
9
+ command -v docker >/dev/null 2>&1
10
+ }
11
+
12
+ docker_accessible() {
13
+ local output
14
+
15
+ if ! output=$(docker compose ps --services 2>/dev/null 2>&1); then
16
+ docker_error="$output"
17
+ return 1
18
+ fi
19
+
20
+ true
21
+ }
22
+
23
+ container_running() {
24
+ local service="$1"
25
+ local output
26
+
27
+ if ! output=$(docker compose ps "$service" --services 2>/dev/null 2>&1); then
28
+ docker_error="$output"
29
+ return 1
30
+ fi
31
+
32
+ echo "$output" | grep -q "$service"
33
+ }
34
+
35
+ container_built() {
36
+ local service="$1"
37
+ local output
38
+
39
+ if ! output=$(docker compose ps -a "$service" 2>/dev/null 2>&1); then
40
+ docker_error="$output"
41
+ return 1
42
+ fi
43
+
44
+ echo "$output" | grep -q "$service"
45
+ }
46
+
47
+ determine_service() {
48
+ local container_override="$1"
49
+
50
+ if [[ -n "$container_override" ]]; then
51
+ echo "$container_override"
52
+ else
53
+ echo "app"
54
+ fi
55
+ }
56
+
57
+ run_locally() {
58
+ local service="$1"
59
+ shift
60
+ echo "Running locally: $*"
61
+
62
+ # Source asdf environment
63
+ if [[ -d "$HOME/.asdf" ]]; then
64
+ export ASDF_DATA_DIR="$HOME/.asdf"
65
+ export PATH="$HOME/.asdf/bin:$HOME/.asdf/shims:$PATH"
66
+ fi
67
+
68
+ # Source rbenv if available
69
+ if [[ -d "$HOME/.rbenv" ]]; then
70
+ export PATH="$HOME/.rbenv/bin:$PATH"
71
+ eval "$(rbenv init -)"
72
+ fi
73
+
74
+ # Source RVM if available
75
+ if [[ -d "$HOME/.rvm" ]]; then
76
+ source "$HOME/.rvm/scripts/rvm"
77
+ fi
78
+
79
+ exec "$@"
80
+ }
81
+
82
+ run_with_docker() {
83
+ local service="$1"
84
+ shift
85
+ local -a mode=()
86
+ while [[ "$1" != "--" ]]; do
87
+ mode+=("$1")
88
+ shift
89
+ done
90
+ shift # consume --
91
+
92
+ echo "Running: docker compose ${mode[*]} $service $*"
93
+ exec docker compose "${mode[@]}" "$service" "$@"
94
+ }
95
+
96
+ if [[ $# -eq 0 ]]; then
97
+ echo "Usage: bin/run [--container <name>] <command>"
98
+ echo "Options:"
99
+ echo " --container <name> Run in a specific container (default: app)"
100
+ echo ""
101
+ echo "Examples:"
102
+ echo " bin/run rspec"
103
+ echo " bin/run rubocop -a"
104
+ echo " bin/run rake db:migrate"
105
+ echo " bin/run --container implementation bin/process"
106
+ exit 1
107
+ fi
108
+
109
+ # Parse --container flag
110
+ container_override=""
111
+ args=()
112
+ while [[ $# -gt 0 ]]; do
113
+ case "$1" in
114
+ --container)
115
+ if [[ -z "$2" || "$2" == --* ]]; then
116
+ echo "Error: --container requires a container name"
117
+ exit 1
118
+ fi
119
+ container_override="$2"
120
+ shift 2
121
+ ;;
122
+ *)
123
+ args+=("$1")
124
+ shift
125
+ ;;
126
+ esac
127
+ done
128
+
129
+ if [[ ${#args[@]} -eq 0 ]]; then
130
+ echo "Error: No command specified"
131
+ exit 1
132
+ fi
133
+
134
+ service=$(determine_service "$container_override")
135
+
136
+ if ! docker_available; then
137
+ echo "Docker not available, running locally"
138
+ run_locally "$service" "${args[@]}"
139
+ elif ! docker_accessible; then
140
+ echo "Docker is installed but not accessible: ${docker_error:-unknown error}"
141
+ echo "Ensure Docker is running and you have permission to access /var/run/docker.sock."
142
+ run_locally "$service" "${args[@]}"
143
+ elif container_running "$service"; then
144
+ echo "Container $service is running"
145
+ run_with_docker "$service" exec -- "${args[@]}"
146
+ elif container_built "$service"; then
147
+ echo "Container $service is built but stopped"
148
+ run_with_docker "$service" run -e SKIP_MIGRATIONS=true --rm -- "${args[@]}"
149
+ else
150
+ echo "Container $service not available, running locally"
151
+ run_locally "$service" "${args[@]}"
152
+ fi
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ echo "==> Setting up development environment..."
5
+
6
+ # Install gems
7
+ bundle install
8
+
9
+ # Create and migrate the database
10
+ if [ -f config/database.yml ]; then
11
+ bin/rails db:create db:migrate
12
+ fi
13
+
14
+ echo "==> Setup complete!"
15
+ echo "Run bin/dev to start the development server."