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
@@ -0,0 +1,8 @@
1
+ class AddKnowledgeBaseFieldsToJourneybookPages < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_reference :journeybook_pages, :parent, foreign_key: { to_table: :journeybook_pages }
4
+ add_column :journeybook_pages, :topic, :string
5
+
6
+ add_index :journeybook_pages, %i[site_id page_type topic]
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ class AddOpenInNewWindowToJourneybookNavigationItems < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :journeybook_navigation_items, :open_in_new_window, :boolean, default: false, null: false
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class AddPositionToJourneybookPages < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :journeybook_pages, :position, :integer, null: false, default: 0
4
+ add_index :journeybook_pages, %i[site_id page_type parent_id position]
5
+ end
6
+ end
@@ -0,0 +1,12 @@
1
+ class CreateJourneybookAiStylePrompts < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_ai_style_prompts do |t|
4
+ t.string :name, null: false
5
+ t.text :prompt, null: false
6
+
7
+ t.timestamps
8
+ end
9
+
10
+ add_index :journeybook_ai_style_prompts, :name, unique: true
11
+ end
12
+ end
@@ -0,0 +1,24 @@
1
+ class CreateJourneybookMediaAssets < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_media_assets do |t|
4
+ t.string :title, null: false
5
+ t.string :alt_text
6
+ t.text :description
7
+ t.string :tags
8
+ t.string :storage_key, null: false
9
+ t.string :original_filename, null: false
10
+ t.string :content_type, null: false
11
+ t.integer :byte_size, null: false
12
+ t.string :checksum
13
+ t.integer :width
14
+ t.integer :height
15
+ t.json :metadata
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :journeybook_media_assets, :storage_key, unique: true
21
+ add_index :journeybook_media_assets, :title
22
+ add_index :journeybook_media_assets, :content_type
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ class CreateJourneybookNavigation < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_navigation_menus do |t|
4
+ t.references :site, null: false, foreign_key: { to_table: :journeybook_sites }
5
+ t.string :key, null: false
6
+ t.string :title, null: false
7
+ t.integer :position, null: false, default: 0
8
+
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :journeybook_navigation_menus, %i[site_id key], unique: true
13
+
14
+ create_table :journeybook_navigation_items do |t|
15
+ t.references :navigation_menu, null: false, foreign_key: { to_table: :journeybook_navigation_menus }
16
+ t.references :page, foreign_key: { to_table: :journeybook_pages }
17
+ t.string :label, null: false
18
+ t.text :description
19
+ t.string :external_url
20
+ t.integer :position, null: false, default: 0
21
+ t.boolean :visible, null: false, default: true
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :journeybook_navigation_items, %i[navigation_menu_id position]
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ class CreateJourneybookPages < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_pages do |t|
4
+ t.references :site, null: false, foreign_key: { to_table: :journeybook_sites }
5
+ t.string :title, null: false
6
+ t.string :slug, null: false
7
+ t.string :path, null: false
8
+ t.text :markdown, null: false
9
+ t.string :meta_title
10
+ t.text :meta_description
11
+ t.string :status, null: false, default: "draft"
12
+ t.datetime :published_at
13
+ t.string :layout
14
+ t.string :locale, null: false, default: "en"
15
+
16
+ t.string :page_type, null: false, default: "page"
17
+ t.text :excerpt
18
+ t.string :author_name
19
+ t.references :parent, foreign_key: { to_table: :journeybook_pages }
20
+ t.string :topic
21
+ t.integer :position, null: false, default: 0
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :journeybook_pages, %i[site_id locale slug], unique: true
27
+ add_index :journeybook_pages, %i[site_id locale path], unique: true
28
+ add_index :journeybook_pages, :status
29
+ add_index :journeybook_pages, :page_type
30
+ add_index :journeybook_pages, %i[site_id page_type topic]
31
+ add_index :journeybook_pages, %i[site_id page_type parent_id position]
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ class CreateJourneybookRedirects < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_redirects do |t|
4
+ t.string :from, null: false
5
+ t.string :to, null: false
6
+ t.integer :status_code, null: false, default: 301
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :journeybook_redirects, :from, unique: true
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ class CreateJourneybookSites < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :journeybook_sites do |t|
4
+ t.string :name, null: false
5
+ t.string :host, null: false
6
+ t.string :default_locale, null: false, default: "en"
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :journeybook_sites, :host, unique: true
12
+ end
13
+ end
@@ -0,0 +1,42 @@
1
+ journeybook_credentials = Rails.application.credentials.fetch(:journeybook, {})
2
+ journeybook_s3_default = journeybook_credentials.dig(:s3, :default) || {}
3
+ journeybook_s3_env = journeybook_credentials.dig(:s3, Rails.env.to_sym) || {}
4
+ journeybook_s3 = journeybook_s3_default.merge(journeybook_s3_env)
5
+
6
+ Journeybook.configure do |config|
7
+ config.default_layout = "application"
8
+
9
+ # Journeybook denies admin access until the host application configures it.
10
+ # config.admin_basic_auth username: journeybook_credentials.dig(:admin, :username), password: journeybook_credentials.dig(:admin, :password)
11
+
12
+ config.openai_api_key = journeybook_credentials.dig(:openai, :api_key)
13
+ config.openai_model = journeybook_credentials.dig(:openai, :model) || config.openai_model
14
+
15
+ # Required when using Journeybook form components that send email via Postmark.
16
+ config.postmark_api_key = journeybook_credentials.dig(:postmark, :api_key)
17
+ # config.form_from = "noreply@example.com"
18
+ # config.form_to = "team@example.com"
19
+ # config.form_action = "/journeybook/form"
20
+ # config.form_delivery_method = Rails.env.development? ? :letter_opener : :postmark
21
+
22
+ # Required when using the Journeybook image library.
23
+ config.s3_bucket = journeybook_s3[:bucket]
24
+ config.s3_region = journeybook_s3[:region]
25
+ config.s3_access_key_id = journeybook_s3[:access_key_id]
26
+ config.s3_secret_access_key = journeybook_s3[:secret_access_key]
27
+ config.s3_prefix = journeybook_s3[:prefix] || "journeybook/#{Rails.env}"
28
+ # Optional: use CloudFront, a CDN, or another custom public host instead of the default S3 URL.
29
+ config.s3_public_base_url = journeybook_s3[:public_base_url]
30
+ end
31
+
32
+ # Register host application components here. `to_prepare` keeps registrations
33
+ # compatible with development reloading and eager loading.
34
+ # Rails.application.config.to_prepare do
35
+ # Journeybook.register :hero, Website::HeroComponent,
36
+ # description: "Large hero section",
37
+ # props: {
38
+ # title: "Main headline",
39
+ # body: { type: :long_text, description: "Supporting copy" },
40
+ # image_url: { type: :image, label: "Image", description: "Image from the Journeybook library" }
41
+ # }
42
+ # end
@@ -0,0 +1,3 @@
1
+ class PagesController < ApplicationController
2
+ include Journeybook::PublicPages
3
+ end
@@ -0,0 +1,10 @@
1
+ <% content_for :title, @journeybook_page.meta_title.presence || @journeybook_page.title %>
2
+ <% content_for :head do %>
3
+ <% if @journeybook_page.meta_description.present? %>
4
+ <meta name="description" content="<%= @journeybook_page.meta_description %>">
5
+ <% end %>
6
+ <% end %>
7
+
8
+ <article class="journeybook-page">
9
+ <%= @journeybook_html.html_safe %>
10
+ </article>
@@ -0,0 +1,136 @@
1
+ module Journeybook
2
+ class ComponentRegistry
3
+ Component = Data.define(:name, :component_class, :metadata) do
4
+ DEFAULT_PROP_TYPE = :short_text
5
+
6
+ def schema
7
+ {
8
+ "name" => name.to_s,
9
+ "description" => metadata.fetch(:description, nil),
10
+ "props" => normalized_props.transform_keys(&:to_s).transform_values do |prop|
11
+ {
12
+ "type" => prop[:type].to_s,
13
+ "label" => prop[:label],
14
+ "description" => prop[:description],
15
+ "required" => prop[:required]
16
+ }.tap do |schema|
17
+ next unless prop[:type] == :collection
18
+
19
+ schema["min"] = prop[:min]
20
+ schema["max"] = prop[:max]
21
+ schema["item"] = prop[:item].transform_keys(&:to_s)
22
+ end.tap do |schema|
23
+ next unless prop[:type] == :select
24
+
25
+ schema["options"] = prop[:options]
26
+ end
27
+ end
28
+ }
29
+ end
30
+
31
+ def normalized_props
32
+ metadata.fetch(:props, {}).each_with_object({}) do |(prop_name, config), props|
33
+ props[prop_name.to_sym] = normalize_prop(prop_name, config)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def normalize_prop(prop_name, config)
40
+ config_hash = config.is_a?(Hash) ? config.symbolize_keys : { description: config.to_s }
41
+
42
+ {
43
+ type: (config_hash[:type].presence || DEFAULT_PROP_TYPE).to_sym,
44
+ label: config_hash[:label].presence || prop_name.to_s.humanize,
45
+ description: config_hash[:description].to_s,
46
+ required: config_hash.fetch(:required, true)
47
+ }.tap do |prop|
48
+ if prop[:type] == :image && config_hash[:icons]
49
+ icon_config = config_hash[:icons].is_a?(Hash) ? config_hash[:icons].symbolize_keys : {}
50
+ prop[:icons] = {
51
+ styles: Array(icon_config[:styles].presence || %w[classic duotone]).map(&:to_s),
52
+ colors: Array(icon_config[:colors]).map(&:to_s)
53
+ }
54
+ end
55
+ if prop[:type] == :collection
56
+ prop[:min] = config_hash[:min].to_i
57
+ prop[:max] = config_hash[:max].to_i if config_hash[:max].present?
58
+ prop[:item] = normalize_item_props(config_hash[:item])
59
+ elsif prop[:type] == :select
60
+ prop[:options] = Array(config_hash[:options]).map(&:to_s)
61
+ end
62
+ end
63
+ end
64
+
65
+ def normalize_item_props(item)
66
+ item.to_h.each_with_object({}) do |(prop_name, config), props|
67
+ props[prop_name.to_sym] = normalize_prop(prop_name, config)
68
+ end
69
+ end
70
+ end
71
+
72
+ include Enumerable
73
+
74
+ def initialize
75
+ @components = {}
76
+ end
77
+
78
+ # @param name [String, Symbol]
79
+ # @param component_class [Class]
80
+ # @param metadata [Hash]
81
+ # @return [Component]
82
+ def register(name, component_class, **metadata)
83
+ normalized_name = normalize_name(name)
84
+ @components[normalized_name] = Component.new(normalized_name, component_class, metadata)
85
+ end
86
+
87
+ # @param name [String, Symbol]
88
+ # @return [Object]
89
+ # @raise [Journeybook::UnknownComponentError]
90
+ def fetch(name)
91
+ registered = @components.fetch(normalize_name(name)) do
92
+ raise UnknownComponentError, "Unknown Journeybook component: #{name}"
93
+ end
94
+
95
+ registered.component_class
96
+ end
97
+
98
+ # @param name [String, Symbol]
99
+ # @return [Component, nil]
100
+ def registered(name)
101
+ @components[normalize_name(name)]
102
+ end
103
+
104
+ # @return [Array<Hash>]
105
+ def schema
106
+ @components.values.map(&:schema)
107
+ end
108
+
109
+ def for_site(site)
110
+ return self unless site
111
+
112
+ filtered = self.class.new
113
+ @components.values.each do |component|
114
+ next unless component_available_for_site?(component, site)
115
+
116
+ filtered.register(component.name, component.component_class, **component.metadata)
117
+ end
118
+ filtered
119
+ end
120
+
121
+ def each(&block)
122
+ @components.values.each(&block)
123
+ end
124
+
125
+ private
126
+
127
+ def normalize_name(name)
128
+ name.to_s.strip.underscore.to_sym
129
+ end
130
+
131
+ def component_available_for_site?(component, site)
132
+ sites = Array(component.metadata[:sites]).map { |value| normalize_name(value) }
133
+ sites.empty? || sites.include?(normalize_name(site.key))
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,55 @@
1
+ module Journeybook
2
+ class Configuration
3
+ attr_accessor :default_layout, :admin_authenticator, :ai_provider, :openai_api_key, :openai_model,
4
+ :postmark_api_key, :form_from, :form_to, :form_action, :form_delivery_method,
5
+ :s3_bucket, :s3_region, :s3_access_key_id, :s3_secret_access_key, :s3_prefix, :s3_public_base_url
6
+ attr_reader :components, :templates
7
+
8
+ def initialize
9
+ @default_layout = "application"
10
+ @admin_authenticator = ->(_controller) { false }
11
+ @ai_provider = :openai
12
+ @openai_api_key = nil
13
+ @openai_model = "gpt-5.4-mini"
14
+ @postmark_api_key = nil
15
+ @form_from = nil
16
+ @form_to = nil
17
+ @form_action = "/journeybook/form"
18
+ @form_delivery_method = :postmark
19
+ @s3_bucket = nil
20
+ @s3_region = nil
21
+ @s3_access_key_id = nil
22
+ @s3_secret_access_key = nil
23
+ @s3_prefix = "journeybook/#{Rails.env}"
24
+ @s3_public_base_url = nil
25
+ @components = ComponentRegistry.new
26
+ @templates = PageTemplateRegistry.new
27
+ end
28
+
29
+ # @param name [String, Symbol]
30
+ # @param component_class [Class]
31
+ # @param metadata [Hash]
32
+ # @return [Object]
33
+ def component(name, component_class, **metadata)
34
+ components.register(name, component_class, **metadata)
35
+ end
36
+
37
+ def admin_basic_auth(username:, password:, realm: "Journeybook")
38
+ @admin_authenticator = lambda do |controller|
39
+ expected_username = username.to_s
40
+ expected_password = password.to_s
41
+
42
+ controller.authenticate_or_request_with_http_basic(realm) do |provided_username, provided_password|
43
+ expected_username.present? && expected_password.present? &&
44
+ secure_compare(provided_username, expected_username) && secure_compare(provided_password, expected_password)
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def secure_compare(provided, expected)
52
+ ActiveSupport::SecurityUtils.secure_compare(Digest::SHA256.hexdigest(provided), Digest::SHA256.hexdigest(expected))
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,19 @@
1
+ require "importmap-rails"
2
+ require "stimulus-rails"
3
+
4
+ module Journeybook
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Journeybook
7
+
8
+ config.autoload_paths << root.join("app/services")
9
+ config.eager_load_paths << root.join("app/services")
10
+
11
+ initializer "journeybook.importmap", after: "importmap" do |app|
12
+ path = root.join("config/importmap.rb")
13
+ app.config.importmap.paths << path
14
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
15
+ app.config.assets.paths << root.join("app/javascript")
16
+ app.importmap.draw(path)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,67 @@
1
+ module Journeybook
2
+ class PageTemplateRegistry
3
+ Template = Data.define(:key, :metadata) do
4
+ def name
5
+ metadata.fetch(:name, key.to_s.humanize)
6
+ end
7
+
8
+ def description
9
+ metadata.fetch(:description, "")
10
+ end
11
+
12
+ def page_type
13
+ metadata.fetch(:page_type, "page").to_s
14
+ end
15
+
16
+ def markdown
17
+ metadata.fetch(:markdown, "").to_s
18
+ end
19
+
20
+ def path_prefix
21
+ metadata[:path_prefix].to_s.presence
22
+ end
23
+
24
+ def path_hint
25
+ metadata[:path_hint].to_s.presence
26
+ end
27
+
28
+ def available_for_site?(site)
29
+ sites = Array(metadata[:sites]).map(&:to_s)
30
+ sites.empty? || site && site.key.in?(sites)
31
+ end
32
+ end
33
+
34
+ include Enumerable
35
+
36
+ def initialize
37
+ @templates = {}
38
+ end
39
+
40
+ def register(key, **metadata)
41
+ normalized_key = normalize_key(key)
42
+ @templates[normalized_key] = Template.new(normalized_key, metadata)
43
+ end
44
+
45
+ def fetch(key)
46
+ @templates.fetch(normalize_key(key))
47
+ end
48
+
49
+ def registered(key)
50
+ @templates[normalize_key(key)]
51
+ end
52
+
53
+ def for_site(site)
54
+ @templates.values.select { |template| template.available_for_site?(site) }
55
+ end
56
+
57
+ def each(&)
58
+ @templates.values.each(&)
59
+ end
60
+
61
+ private
62
+
63
+ def normalize_key(key)
64
+ key.to_s.parameterize(separator: "_").underscore.to_sym
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,23 @@
1
+ module Journeybook
2
+ class PublicPageConstraint
3
+ RESERVED_PATHS = %w[
4
+ /assets/
5
+ /journeybook/
6
+ /rails/
7
+ /recede_historical_location
8
+ /resume_historical_location
9
+ /refresh_historical_location
10
+ /up
11
+ ].freeze
12
+
13
+ def matches?(request)
14
+ request.get? && html_request?(request) && RESERVED_PATHS.none? { |path| request.path.start_with?(path) }
15
+ end
16
+
17
+ private
18
+
19
+ def html_request?(request)
20
+ request.format.html? || request.format == Mime[:all]
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module Journeybook
2
+ VERSION = "0.1.6-alpha"
3
+ end
@@ -0,0 +1,62 @@
1
+ require "journeybook/version"
2
+ require "journeybook/component_registry"
3
+ require "journeybook/page_template_registry"
4
+ require "journeybook/configuration"
5
+ require "journeybook/public_page_constraint"
6
+ require "journeybook/engine"
7
+
8
+ module Journeybook
9
+ class Error < StandardError; end
10
+ class UnknownComponentError < Error; end
11
+
12
+ class << self
13
+ # @return [Journeybook::Configuration]
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ # @yieldparam config [Journeybook::Configuration]
19
+ # @return [Journeybook::Configuration]
20
+ def configure
21
+ yield configuration
22
+ configuration
23
+ end
24
+
25
+ # @return [Journeybook::ComponentRegistry]
26
+ def components
27
+ configuration.components
28
+ end
29
+
30
+ # @param name [String, Symbol]
31
+ # @return [Object]
32
+ def component(name)
33
+ components.fetch(name)
34
+ end
35
+
36
+ # @param name [String, Symbol]
37
+ # @param component_class [Class]
38
+ # @param metadata [Hash]
39
+ # @return [Object]
40
+ def register(name, component_class, **metadata)
41
+ components.register(name, component_class, **metadata)
42
+ end
43
+
44
+ # @return [Journeybook::PageTemplateRegistry]
45
+ def templates
46
+ configuration.templates
47
+ end
48
+
49
+ # @param key [String, Symbol]
50
+ # @return [Journeybook::PageTemplateRegistry::Template]
51
+ def template(key)
52
+ templates.fetch(key)
53
+ end
54
+
55
+ # @param key [String, Symbol]
56
+ # @param metadata [Hash]
57
+ # @return [Journeybook::PageTemplateRegistry::Template]
58
+ def register_template(key, **metadata)
59
+ templates.register(key, **metadata)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,8 @@
1
+ namespace :journeybook do
2
+ desc "Install Journeybook migrations, initializer, and host component directories"
3
+ task install: :environment do
4
+ require "rails/generators"
5
+
6
+ Rails::Generators.invoke("journeybook:install", [], destination_root: Rails.root)
7
+ end
8
+ end