journeybook 0.1.6.pre.alpha

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 (134) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +354 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/images/journeybook/.keep +0 -0
  7. data/app/assets/images/journeybook/logo.svg +5 -0
  8. data/app/assets/javascripts/journeybook/sortable.js +96 -0
  9. data/app/assets/stylesheets/journeybook/application.css +1556 -0
  10. data/app/controllers/concerns/.keep +0 -0
  11. data/app/controllers/concerns/journeybook/public_pages.rb +31 -0
  12. data/app/controllers/journeybook/admin/ai_pages_controller.rb +65 -0
  13. data/app/controllers/journeybook/admin/ai_style_prompts_controller.rb +51 -0
  14. data/app/controllers/journeybook/admin/components_controller.rb +57 -0
  15. data/app/controllers/journeybook/admin/dashboard_controller.rb +13 -0
  16. data/app/controllers/journeybook/admin/media_assets_controller.rb +59 -0
  17. data/app/controllers/journeybook/admin/navigation_items_controller.rb +91 -0
  18. data/app/controllers/journeybook/admin/navigation_menus_controller.rb +59 -0
  19. data/app/controllers/journeybook/admin/pages_controller.rb +304 -0
  20. data/app/controllers/journeybook/admin/redirects_controller.rb +51 -0
  21. data/app/controllers/journeybook/admin/sites_controller.rb +51 -0
  22. data/app/controllers/journeybook/application_controller.rb +52 -0
  23. data/app/controllers/journeybook/form_submissions_controller.rb +55 -0
  24. data/app/helpers/journeybook/application_helper.rb +4 -0
  25. data/app/helpers/journeybook/navigation_helper.rb +11 -0
  26. data/app/javascript/journeybook/application.js +5 -0
  27. data/app/javascript/journeybook/component_search_controller.js +13 -0
  28. data/app/jobs/journeybook/application_job.rb +4 -0
  29. data/app/models/concerns/.keep +0 -0
  30. data/app/models/journeybook/ai_style_prompt.rb +6 -0
  31. data/app/models/journeybook/application_record.rb +5 -0
  32. data/app/models/journeybook/media_asset.rb +26 -0
  33. data/app/models/journeybook/navigation_item.rb +45 -0
  34. data/app/models/journeybook/navigation_menu.rb +23 -0
  35. data/app/models/journeybook/page.rb +96 -0
  36. data/app/models/journeybook/redirect.rb +31 -0
  37. data/app/models/journeybook/site.rb +62 -0
  38. data/app/services/journeybook/ai/markdown_generator.rb +15 -0
  39. data/app/services/journeybook/ai/openai_client.rb +54 -0
  40. data/app/services/journeybook/ai/prompt_builder.rb +154 -0
  41. data/app/services/journeybook/ai/template_validator.rb +17 -0
  42. data/app/services/journeybook/component_parser.rb +53 -0
  43. data/app/services/journeybook/component_renderer.rb +108 -0
  44. data/app/services/journeybook/component_tag_updater.rb +132 -0
  45. data/app/services/journeybook/component_validator.rb +111 -0
  46. data/app/services/journeybook/form_mailer.rb +69 -0
  47. data/app/services/journeybook/html_sanitizer.rb +46 -0
  48. data/app/services/journeybook/letter_opener_client.rb +14 -0
  49. data/app/services/journeybook/markdown_renderer.rb +29 -0
  50. data/app/services/journeybook/page_renderer.rb +58 -0
  51. data/app/services/journeybook/page_resolver.rb +55 -0
  52. data/app/services/journeybook/postmark_client.rb +48 -0
  53. data/app/services/journeybook/storage/s3_client.rb +96 -0
  54. data/app/views/journeybook/admin/ai_pages/_draft_form.html.erb +73 -0
  55. data/app/views/journeybook/admin/ai_pages/new.html.erb +43 -0
  56. data/app/views/journeybook/admin/ai_style_prompts/_form.html.erb +16 -0
  57. data/app/views/journeybook/admin/ai_style_prompts/edit.html.erb +2 -0
  58. data/app/views/journeybook/admin/ai_style_prompts/index.html.erb +26 -0
  59. data/app/views/journeybook/admin/ai_style_prompts/new.html.erb +2 -0
  60. data/app/views/journeybook/admin/components/_component.html.erb +17 -0
  61. data/app/views/journeybook/admin/components/_reference.html.erb +9 -0
  62. data/app/views/journeybook/admin/components/index.html.erb +46 -0
  63. data/app/views/journeybook/admin/components/preview.html.erb +13 -0
  64. data/app/views/journeybook/admin/dashboard/show.html.erb +34 -0
  65. data/app/views/journeybook/admin/media_assets/_reference.html.erb +16 -0
  66. data/app/views/journeybook/admin/media_assets/index.html.erb +43 -0
  67. data/app/views/journeybook/admin/media_assets/new.html.erb +41 -0
  68. data/app/views/journeybook/admin/navigation_items/_form.html.erb +46 -0
  69. data/app/views/journeybook/admin/navigation_items/edit.html.erb +2 -0
  70. data/app/views/journeybook/admin/navigation_items/new.html.erb +2 -0
  71. data/app/views/journeybook/admin/navigation_menus/_form.html.erb +10 -0
  72. data/app/views/journeybook/admin/navigation_menus/edit.html.erb +38 -0
  73. data/app/views/journeybook/admin/navigation_menus/index.html.erb +28 -0
  74. data/app/views/journeybook/admin/navigation_menus/new.html.erb +2 -0
  75. data/app/views/journeybook/admin/pages/_collection_field.html.erb +58 -0
  76. data/app/views/journeybook/admin/pages/_form.html.erb +77 -0
  77. data/app/views/journeybook/admin/pages/_image_picker_modal.html.erb +23 -0
  78. data/app/views/journeybook/admin/pages/_table.html.erb +37 -0
  79. data/app/views/journeybook/admin/pages/_url_field.html.erb +25 -0
  80. data/app/views/journeybook/admin/pages/edit.html.erb +547 -0
  81. data/app/views/journeybook/admin/pages/errors.html.erb +25 -0
  82. data/app/views/journeybook/admin/pages/index.html.erb +52 -0
  83. data/app/views/journeybook/admin/pages/new.html.erb +27 -0
  84. data/app/views/journeybook/admin/pages/new_component.html.erb +70 -0
  85. data/app/views/journeybook/admin/pages/public_preview.html.erb +27 -0
  86. data/app/views/journeybook/admin/pages/source.html.erb +23 -0
  87. data/app/views/journeybook/admin/redirects/_form.html.erb +9 -0
  88. data/app/views/journeybook/admin/redirects/edit.html.erb +2 -0
  89. data/app/views/journeybook/admin/redirects/index.html.erb +15 -0
  90. data/app/views/journeybook/admin/redirects/new.html.erb +2 -0
  91. data/app/views/journeybook/admin/shared/_drag_handle.html.erb +3 -0
  92. data/app/views/journeybook/admin/shared/_errors.html.erb +10 -0
  93. data/app/views/journeybook/admin/sites/_form.html.erb +12 -0
  94. data/app/views/journeybook/admin/sites/edit.html.erb +2 -0
  95. data/app/views/journeybook/admin/sites/index.html.erb +26 -0
  96. data/app/views/journeybook/admin/sites/new.html.erb +2 -0
  97. data/app/views/layouts/journeybook/application.html.erb +57 -0
  98. data/config/importmap.rb +3 -0
  99. data/config/routes.rb +39 -0
  100. data/db/migrate/20260627000001_create_journeybook_sites.rb +13 -0
  101. data/db/migrate/20260627000002_create_journeybook_pages.rb +23 -0
  102. data/db/migrate/20260627000003_create_journeybook_redirects.rb +13 -0
  103. data/db/migrate/20260628000004_add_context_fields_to_journeybook_sites.rb +38 -0
  104. data/db/migrate/20260628000005_create_journeybook_media_assets.rb +24 -0
  105. data/db/migrate/20260629000006_create_journeybook_navigation.rb +28 -0
  106. data/db/migrate/20260731000007_add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
  107. data/db/migrate/20260731000008_create_journeybook_ai_style_prompts.rb +12 -0
  108. data/db/migrate/20260801000009_add_blog_fields_to_journeybook_pages.rb +9 -0
  109. data/db/migrate/20260801000010_add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
  110. data/db/migrate/20260801000011_add_position_to_journeybook_pages.rb +6 -0
  111. data/lib/generators/journeybook/install/install_generator.rb +72 -0
  112. data/lib/generators/journeybook/install/templates/add_blog_fields_to_journeybook_pages.rb +9 -0
  113. data/lib/generators/journeybook/install/templates/add_context_fields_to_journeybook_sites.rb +38 -0
  114. data/lib/generators/journeybook/install/templates/add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
  115. data/lib/generators/journeybook/install/templates/add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
  116. data/lib/generators/journeybook/install/templates/add_position_to_journeybook_pages.rb +6 -0
  117. data/lib/generators/journeybook/install/templates/create_journeybook_ai_style_prompts.rb +12 -0
  118. data/lib/generators/journeybook/install/templates/create_journeybook_media_assets.rb +24 -0
  119. data/lib/generators/journeybook/install/templates/create_journeybook_navigation.rb +28 -0
  120. data/lib/generators/journeybook/install/templates/create_journeybook_pages.rb +33 -0
  121. data/lib/generators/journeybook/install/templates/create_journeybook_redirects.rb +13 -0
  122. data/lib/generators/journeybook/install/templates/create_journeybook_sites.rb +13 -0
  123. data/lib/generators/journeybook/install/templates/journeybook.rb +42 -0
  124. data/lib/generators/journeybook/install/templates/pages_controller.rb +3 -0
  125. data/lib/generators/journeybook/install/templates/show.html.erb +10 -0
  126. data/lib/journeybook/component_registry.rb +136 -0
  127. data/lib/journeybook/configuration.rb +55 -0
  128. data/lib/journeybook/engine.rb +19 -0
  129. data/lib/journeybook/page_template_registry.rb +67 -0
  130. data/lib/journeybook/public_page_constraint.rb +23 -0
  131. data/lib/journeybook/version.rb +3 -0
  132. data/lib/journeybook.rb +62 -0
  133. data/lib/tasks/journeybook_tasks.rake +8 -0
  134. metadata +266 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f97196aa51bf528b293c97d43e0b4c9ef95821b0cce0e94b6db591892a62d4aa
4
+ data.tar.gz: dba603d13ec8b959fff8c21204c9a603e6d1a27264039ea031da5149b0d5ec7a
5
+ SHA512:
6
+ metadata.gz: a44844e4f07e56eca00236cad81a17ccc31802285af382f683e551905820754d308b0f33305d982df293a72d6d6320fc614f281ce3e9631176af373e5868d535
7
+ data.tar.gz: f750446cff27d84651b81dc30b8353bae9dea4f93ed9b7e1af353fa089e80eb38d97f7c77ae099ed4716b980996ce52b65c6005d86c6d2965a3ef4b4afcb0cdc
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ All notable changes to Journeybook are documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-08-03
8
+
9
+ - Initial public release of the Journeybook Rails engine.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 talent journey
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,354 @@
1
+ # Journeybook
2
+
3
+ Journeybook is a mountable Rails 8 engine for Markdown-first CMS content. It keeps layouts, routing, and design in the host application while providing reusable page models, component registration, and a rendering pipeline.
4
+
5
+ ## Principles
6
+
7
+ - Content is written in Markdown.
8
+ - Components are defined in Ruby code, not stored in the database.
9
+ - Public routing belongs to the host application.
10
+ - No JavaScript dependency is required beyond Rails defaults such as Turbo.
11
+ - The engine exposes small, Zeitwerk-compatible Rails services.
12
+
13
+ ## Installation
14
+
15
+ Install the released gem from RubyGems:
16
+
17
+ ```ruby
18
+ gem "journeybook", "~> 0.1"
19
+ ```
20
+
21
+ Then run:
22
+
23
+ ```bash
24
+ bundle install
25
+ ```
26
+
27
+ For local engine development only, a host application can temporarily use a relative `path:` dependency. Do not deploy a `path:` dependency.
28
+
29
+ ## Initial host installation
30
+
31
+ Run these steps once for each Rails host application:
32
+
33
+ ```bash
34
+ bundle install
35
+ bin/rails journeybook:install
36
+ bin/rails db:migrate
37
+ ```
38
+
39
+ Commit the files generated by `journeybook:install`, including the copied migrations, initializer, controller, view, and routes. The installer copies Journeybook migrations and a reload-safe initializer, then adds a minimal public page controller, default page view, and host-owned routes:
40
+
41
+ ```ruby
42
+ root "pages#show"
43
+
44
+ constraints Journeybook::PublicPageConstraint.new do
45
+ get "*path" => "pages#show", as: :journeybook_page
46
+ end
47
+ ```
48
+
49
+ The generated controller includes `Journeybook::PublicPages`, which resolves published pages by request host and path, renders them through `Journeybook::PageRenderer`, and leaves layout/design in the host app.
50
+
51
+ The admin routes are mounted by the installer:
52
+
53
+ ```ruby
54
+ mount Journeybook::Engine => "/journeybook"
55
+ ```
56
+
57
+ Open `/journeybook` in the host app to manage sites, pages, components, the image library, previews, and AI-generated Markdown drafts.
58
+
59
+ ## Updating Journeybook
60
+
61
+ 1. Update the version constraint in the host application's Gemfile when necessary.
62
+ 2. Run `bundle update journeybook` and commit the resulting `Gemfile.lock`.
63
+ 3. If the release includes database migrations, run `bin/rails journeybook:install` and commit the newly copied migrations.
64
+ 4. Run `bin/rails db:migrate` locally and in the deployment release step.
65
+ 5. Deploy the host application's commit.
66
+
67
+ Do not rerun the installer for a release without new migrations or generated setup changes. Do not edit migrations that have already been deployed; ship a new engine migration instead.
68
+
69
+ ## Coolify deployment
70
+
71
+ For a Rails application deployed from `main` with Coolify, RubyGems requires no additional GitHub SSH key. Coolify runs `bundle install` as part of the normal build and resolves the version locked in `Gemfile.lock`.
72
+
73
+ Configure the production release command to run `bin/rails db:migrate`. Store Journeybook admin credentials, S3 credentials, and optional Postmark/OpenAI credentials as production secrets. Use persistent S3-compatible storage for the Journeybook image library; do not rely on the container filesystem for uploaded media.
74
+
75
+ ## Releasing
76
+
77
+ RubyGems releases are immutable. Use semantic versioning and update both `lib/journeybook/version.rb` and `CHANGELOG.md` before releasing.
78
+
79
+ ```bash
80
+ bin/rails db:test:prepare && bin/rails test
81
+ bin/rubocop -f github
82
+ bundle exec rake build
83
+ gem check pkg/journeybook-*.gem
84
+ ```
85
+
86
+ Run the Release workflow manually from GitHub Actions after the version bump and changelog commit are merged. It runs tests and linting, then `rubygems/release-gem` creates the matching Git tag and publishes the gem.
87
+
88
+ For RubyGems Trusted Publishing, configure the `journeybook` gem with this GitHub Actions publisher after the first release:
89
+
90
+ ```text
91
+ Owner: talent-journey
92
+ Repository: journeybook
93
+ Workflow: .github/workflows/release.yml
94
+ ```
95
+
96
+ Trusted Publishing uses GitHub OIDC, so it needs no `RUBYGEMS_API_KEY` and no one-time MFA code. The first release must be published manually with an MFA code before a gem-specific publisher can be configured.
97
+
98
+ ## Credentials
99
+
100
+ Journeybook expects secrets below one `journeybook` namespace in the host app credentials. Non-secret form settings such as sender and recipient addresses can stay in the initializer.
101
+
102
+ ```yaml
103
+ journeybook:
104
+ admin:
105
+ username: ...
106
+ password: ...
107
+
108
+ openai:
109
+ api_key: ...
110
+ model: gpt-5.4-mini
111
+
112
+ postmark:
113
+ api_key: ...
114
+
115
+ s3:
116
+ default:
117
+ region: eu-central-1
118
+
119
+ development:
120
+ bucket: journeybook-assets-development
121
+ access_key_id: ...
122
+ secret_access_key: ...
123
+ prefix: journeybook/development
124
+
125
+ staging:
126
+ bucket: journeybook-assets-staging
127
+ access_key_id: ...
128
+ secret_access_key: ...
129
+ prefix: journeybook/staging
130
+
131
+ production:
132
+ bucket: journeybook-assets-production
133
+ access_key_id: ...
134
+ secret_access_key: ...
135
+ prefix: journeybook/production
136
+ ```
137
+
138
+ Journeybook denies admin access by default. Configure `admin_authenticator` in the host application. For a small, single-admin installation, HTTP Basic authentication with the credentials above is sufficient; production applications with their own account system should delegate to that system instead.
139
+
140
+ ```ruby
141
+ Journeybook.configure do |config|
142
+ config.admin_basic_auth username: journeybook_credentials.dig(:admin, :username), password: journeybook_credentials.dig(:admin, :password)
143
+ end
144
+ ```
145
+
146
+ Configure non-secret form delivery settings in the host initializer:
147
+
148
+ ```ruby
149
+ Journeybook.configure do |config|
150
+ config.form_from = "noreply@example.com"
151
+ config.form_to = "team@example.com"
152
+ config.form_delivery_method = :postmark
153
+ end
154
+ ```
155
+
156
+ The generated initializer reads `journeybook.s3.default` and merges it with the current `Rails.env` block. Use separate buckets per environment for production safety. If you intentionally use one bucket, restrict each environment's IAM credentials to its own prefix.
157
+
158
+ Journeybook builds public S3 URLs from `bucket` and `region` automatically. Set `public_base_url` only when images should be served through CloudFront, a CDN, or another custom public host:
159
+
160
+ ```yaml
161
+ journeybook:
162
+ s3:
163
+ production:
164
+ public_base_url: https://cdn.example.com
165
+ ```
166
+
167
+ ## Image Library
168
+
169
+ Journeybook includes a global public image library for component props and AI-generated drafts. Uploaded images are stored in S3 and metadata is stored in Journeybook. Deleting an image from the library also deletes the S3 object. The admin UI accepts PNG, JPEG, WebP, GIF, and SVG files.
170
+
171
+ By default Journeybook stores objects below an environment-specific prefix such as `journeybook/development` or `journeybook/production`. Production should use a separate bucket or IAM credentials restricted to the production prefix, so local development cannot delete production objects.
172
+
173
+ ## AI Page Builder
174
+
175
+ Journeybook can generate editable Markdown drafts with OpenAI. Store the API key under `journeybook.openai.api_key` in Rails credentials:
176
+
177
+ ```yaml
178
+ journeybook:
179
+ openai:
180
+ api_key: ...
181
+ model: gpt-5.4-mini
182
+ ```
183
+
184
+ You can also use `OPENAI_API_KEY` as a local development fallback:
185
+
186
+ ```bash
187
+ OPENAI_API_KEY=... bin/dev
188
+ ```
189
+
190
+ The AI builder sends the user's prompt plus the registered component schema to OpenAI and returns one Markdown document. It uses `gpt-5.4-mini` by default. Selecting a page template restricts generation to that template's component recipe and preserves its order. Journeybook validates the generated components before review; the generated Markdown is never published directly, and users must review and save it as a draft through the normal page form.
191
+
192
+ ## Configuration
193
+
194
+ ```ruby
195
+ journeybook_credentials = Rails.application.credentials.fetch(:journeybook, {})
196
+ journeybook_s3_default = journeybook_credentials.dig(:s3, :default) || {}
197
+ journeybook_s3_env = journeybook_credentials.dig(:s3, Rails.env.to_sym) || {}
198
+ journeybook_s3 = journeybook_s3_default.merge(journeybook_s3_env)
199
+
200
+ Journeybook.configure do |config|
201
+ config.default_layout = "application"
202
+ config.openai_api_key = journeybook_credentials.dig(:openai, :api_key)
203
+ config.openai_model = journeybook_credentials.dig(:openai, :model) || config.openai_model
204
+ config.postmark_api_key = journeybook_credentials.dig(:postmark, :api_key)
205
+ config.form_from = "noreply@example.com"
206
+ config.form_to = "team@example.com"
207
+ config.form_delivery_method = Rails.env.development? ? :letter_opener : :postmark
208
+ config.s3_bucket = journeybook_s3[:bucket]
209
+ config.s3_region = journeybook_s3[:region]
210
+ config.s3_access_key_id = journeybook_s3[:access_key_id]
211
+ config.s3_secret_access_key = journeybook_s3[:secret_access_key]
212
+ config.s3_prefix = journeybook_s3[:prefix] || "journeybook/#{Rails.env}"
213
+ # Optional: use CloudFront, a CDN, or another custom public host instead of the default S3 URL.
214
+ config.s3_public_base_url = journeybook_s3[:public_base_url]
215
+ end
216
+ ```
217
+
218
+ ## Page templates
219
+
220
+ Page templates give non-technical editors an approved component recipe instead of a blank canvas. Register them in the host application:
221
+
222
+ ```ruby
223
+ Journeybook.register_template :blog_post,
224
+ name: "Blog post",
225
+ description: "Article header and longform content.",
226
+ page_type: "blog_post",
227
+ path_prefix: "/blog",
228
+ path_hint: "/blog/your-post-title",
229
+ sites: %i[marketing],
230
+ markdown: <<~MARKDOWN
231
+ {{ article_hero }}
232
+
233
+ {{ text text="Start writing your article here." }}
234
+ MARKDOWN
235
+ ```
236
+
237
+ Editors choose a template when creating a page. The template supplies its component Markdown and page type. If `path_prefix` is present and the editor leaves the path empty, Journeybook derives a path from the title, such as `/blog/your-post-title`.
238
+
239
+ Use `page_type: "kb_article"` with an optional `parent_id` to build a Help Center hierarchy. Use `page_type: "glossary_term"` and `topic: "HR"` (or another topic) for glossary content and topic-specific index components.
240
+
241
+ ## Components
242
+
243
+ Register components in the host application:
244
+
245
+ ```ruby
246
+ Journeybook.configure do |config|
247
+ config.component :hero, Website::HeroComponent
248
+ end
249
+ ```
250
+
251
+ Then use them in Markdown:
252
+
253
+ ```markdown
254
+ # Hello
255
+
256
+ {{ hero title="Journeybook" subtitle="Markdown-first CMS" }}
257
+ ```
258
+
259
+ Registered component classes can expose either `.render(**props)` or instances with `#render`.
260
+
261
+ ### Repeatable items
262
+
263
+ Use a `collection` prop for a component with a configurable number of homogeneous items. Define the item schema and an explicit visual limit:
264
+
265
+ ```ruby
266
+ Journeybook.configure do |config|
267
+ config.component :feature_grid, Website::FeatureGridComponent,
268
+ props: {
269
+ items: {
270
+ type: :collection,
271
+ min: 1,
272
+ max: 4,
273
+ item: {
274
+ title: "Feature title",
275
+ description: { type: :long_text },
276
+ href: { type: :url, required: false },
277
+ color: { type: :select, options: %w[sunset forest stone] }
278
+ }
279
+ }
280
+ }
281
+ end
282
+ ```
283
+
284
+ Collection data is JSON wrapped in single quotes, which keeps Markdown readable and gives the AI a deterministic format:
285
+
286
+ ```markdown
287
+ {{ feature_grid items='[{"title":"Onboarding","description":"New hires start with confidence.","href":"/onboarding"}]' }}
288
+ ```
289
+
290
+ The page editor renders collection props as a repeater with add, remove, and drag-to-reorder controls. Nested arbitrary components are intentionally not supported.
291
+
292
+ ## Navigation
293
+
294
+ Journeybook stores site-scoped navigation menus and ordered items. An item links to either a published Journeybook page or an external URL/path. The host application keeps ownership of the markup and renders menus by key:
295
+
296
+ ```erb
297
+ <% (journeybook_menu("footer_product")&.visible_items || []).each do |item| %>
298
+ <a href="<%= item.href %>"><%= item.label %></a>
299
+ <% end %>
300
+ ```
301
+
302
+ Create and edit menus in the admin under **Navigation**. Seed menu keys such as `header_learn` or `footer_product` from the host application; their meaning is defined by that host's layout.
303
+
304
+ ## Form Emails
305
+
306
+ Journeybook includes a public form endpoint for components that need to send notification emails through Postmark. Store the Postmark API key under `journeybook.postmark.api_key`, then configure the non-secret form addresses in the host app initializer:
307
+
308
+ ```ruby
309
+ Journeybook.configure do |config|
310
+ config.postmark_api_key = Rails.application.credentials.dig(:journeybook, :postmark, :api_key)
311
+ config.form_from = "noreply@example.com"
312
+ config.form_to = "team@example.com"
313
+ config.form_delivery_method = :postmark
314
+ end
315
+ ```
316
+
317
+ For local development, add `letter_opener` to the host app's `development` group and switch the delivery method there. Journeybook does not include `letter_opener` as a runtime dependency because it should not be installed in production apps unless the host app explicitly wants it.
318
+
319
+ ```ruby
320
+ # Gemfile
321
+ group :development do
322
+ gem "letter_opener"
323
+ end
324
+
325
+ # config/initializers/journeybook.rb
326
+ Journeybook.configure do |config|
327
+ config.form_delivery_method = Rails.env.development? ? :letter_opener : :postmark
328
+ end
329
+ ```
330
+
331
+ Components can render forms that post to the configured action:
332
+
333
+ ```ruby
334
+ class ContactFormComponent
335
+ def self.render
336
+ <<~HTML
337
+ <form action="#{Journeybook.configuration.form_action}" method="post">
338
+ <input name="name" required>
339
+ <input name="email" type="email" required>
340
+ <textarea name="message" required></textarea>
341
+ <button type="submit">Send</button>
342
+ </form>
343
+ HTML
344
+ end
345
+ end
346
+ ```
347
+
348
+ The recipient address is configuration-only. Submitted `email` is used as the Postmark `ReplyTo` address when present. If you mount Journeybook somewhere other than `/journeybook`, set `config.form_action` to the matching form endpoint path.
349
+
350
+ ## Contributing
351
+ Bug reports and pull requests are welcome at https://github.com/talent-journey/journeybook.
352
+
353
+ ## License
354
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
File without changes
@@ -0,0 +1,5 @@
1
+ <svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
2
+ <title id="title">Journeybook</title>
3
+ <rect width="160" height="160" rx="36" fill="#0F172A"/>
4
+ <path d="M52 30H118V95C118 118.196 99.196 137 76 137H69C47.461 137 30 119.539 30 98V91H59V98C59 103.523 63.477 108 69 108H76C83.18 108 89 102.18 89 95V59H52V30Z" fill="#F8FAFC"/>
5
+ </svg>
@@ -0,0 +1,96 @@
1
+ (() => {
2
+ const initSortableLists = () => {
3
+ document.querySelectorAll("[data-sortable-list]").forEach((list) => {
4
+ if (list.dataset.sortableReady === "true") return
5
+ list.dataset.sortableReady = "true"
6
+
7
+ let draggedItem = null
8
+ let changed = false
9
+
10
+ list.addEventListener("dragstart", (event) => {
11
+ const item = event.target.closest("[data-sortable-item]")
12
+ if (!item || !list.contains(item)) return
13
+
14
+ draggedItem = item
15
+ changed = false
16
+ item.classList.add("is-dragging")
17
+ event.dataTransfer.effectAllowed = "move"
18
+ event.dataTransfer.setData("text/plain", item.dataset.sortableId || "")
19
+ })
20
+
21
+ list.addEventListener("dragover", (event) => {
22
+ if (!draggedItem) return
23
+
24
+ event.preventDefault()
25
+ event.dataTransfer.dropEffect = "move"
26
+
27
+ const items = [ ...list.querySelectorAll(":scope > [data-sortable-item]") ]
28
+ const target = items.find((item) => {
29
+ if (item === draggedItem) return false
30
+ const rect = item.getBoundingClientRect()
31
+ return event.clientY < rect.top + rect.height / 2
32
+ })
33
+
34
+ if (target) {
35
+ list.insertBefore(draggedItem, target)
36
+ } else {
37
+ list.appendChild(draggedItem)
38
+ }
39
+ changed = true
40
+ })
41
+
42
+ list.addEventListener("drop", (event) => event.preventDefault())
43
+
44
+ list.addEventListener("dragend", async () => {
45
+ if (!draggedItem) return
46
+
47
+ draggedItem.classList.remove("is-dragging")
48
+ const shouldSave = changed
49
+ draggedItem = null
50
+ changed = false
51
+ if (!shouldSave) return
52
+
53
+ const params = new URLSearchParams()
54
+ list.querySelectorAll(":scope > [data-sortable-item]").forEach((item) => {
55
+ params.append(`${list.dataset.sortableParam}[]`, item.dataset.sortableId)
56
+ })
57
+
58
+ try {
59
+ await fetch(list.dataset.sortableUrl, {
60
+ method: "PATCH",
61
+ credentials: "same-origin",
62
+ headers: {
63
+ "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content || "",
64
+ "Accept": "text/html",
65
+ "Content-Type": "application/x-www-form-urlencoded"
66
+ },
67
+ body: params
68
+ })
69
+ } finally {
70
+ window.location.reload()
71
+ }
72
+ })
73
+ })
74
+ }
75
+
76
+ const initFlashes = () => {
77
+ document.querySelectorAll("[data-flash]").forEach((flash) => {
78
+ if (flash.dataset.dismissReady === "true") return
79
+ flash.dataset.dismissReady = "true"
80
+ window.setTimeout(() => flash.remove(), 2000)
81
+ })
82
+ }
83
+
84
+ const init = () => {
85
+ initSortableLists()
86
+ initFlashes()
87
+ }
88
+
89
+ if (document.readyState === "loading") {
90
+ document.addEventListener("DOMContentLoaded", init)
91
+ } else {
92
+ init()
93
+ }
94
+
95
+ document.addEventListener("turbo:load", init)
96
+ })()