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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/MIT-LICENSE +20 -0
- data/README.md +354 -0
- data/Rakefile +6 -0
- data/app/assets/images/journeybook/.keep +0 -0
- data/app/assets/images/journeybook/logo.svg +5 -0
- data/app/assets/javascripts/journeybook/sortable.js +96 -0
- data/app/assets/stylesheets/journeybook/application.css +1556 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/concerns/journeybook/public_pages.rb +31 -0
- data/app/controllers/journeybook/admin/ai_pages_controller.rb +65 -0
- data/app/controllers/journeybook/admin/ai_style_prompts_controller.rb +51 -0
- data/app/controllers/journeybook/admin/components_controller.rb +57 -0
- data/app/controllers/journeybook/admin/dashboard_controller.rb +13 -0
- data/app/controllers/journeybook/admin/media_assets_controller.rb +59 -0
- data/app/controllers/journeybook/admin/navigation_items_controller.rb +91 -0
- data/app/controllers/journeybook/admin/navigation_menus_controller.rb +59 -0
- data/app/controllers/journeybook/admin/pages_controller.rb +304 -0
- data/app/controllers/journeybook/admin/redirects_controller.rb +51 -0
- data/app/controllers/journeybook/admin/sites_controller.rb +51 -0
- data/app/controllers/journeybook/application_controller.rb +52 -0
- data/app/controllers/journeybook/form_submissions_controller.rb +55 -0
- data/app/helpers/journeybook/application_helper.rb +4 -0
- data/app/helpers/journeybook/navigation_helper.rb +11 -0
- data/app/javascript/journeybook/application.js +5 -0
- data/app/javascript/journeybook/component_search_controller.js +13 -0
- data/app/jobs/journeybook/application_job.rb +4 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/journeybook/ai_style_prompt.rb +6 -0
- data/app/models/journeybook/application_record.rb +5 -0
- data/app/models/journeybook/media_asset.rb +26 -0
- data/app/models/journeybook/navigation_item.rb +45 -0
- data/app/models/journeybook/navigation_menu.rb +23 -0
- data/app/models/journeybook/page.rb +96 -0
- data/app/models/journeybook/redirect.rb +31 -0
- data/app/models/journeybook/site.rb +62 -0
- data/app/services/journeybook/ai/markdown_generator.rb +15 -0
- data/app/services/journeybook/ai/openai_client.rb +54 -0
- data/app/services/journeybook/ai/prompt_builder.rb +154 -0
- data/app/services/journeybook/ai/template_validator.rb +17 -0
- data/app/services/journeybook/component_parser.rb +53 -0
- data/app/services/journeybook/component_renderer.rb +108 -0
- data/app/services/journeybook/component_tag_updater.rb +132 -0
- data/app/services/journeybook/component_validator.rb +111 -0
- data/app/services/journeybook/form_mailer.rb +69 -0
- data/app/services/journeybook/html_sanitizer.rb +46 -0
- data/app/services/journeybook/letter_opener_client.rb +14 -0
- data/app/services/journeybook/markdown_renderer.rb +29 -0
- data/app/services/journeybook/page_renderer.rb +58 -0
- data/app/services/journeybook/page_resolver.rb +55 -0
- data/app/services/journeybook/postmark_client.rb +48 -0
- data/app/services/journeybook/storage/s3_client.rb +96 -0
- data/app/views/journeybook/admin/ai_pages/_draft_form.html.erb +73 -0
- data/app/views/journeybook/admin/ai_pages/new.html.erb +43 -0
- data/app/views/journeybook/admin/ai_style_prompts/_form.html.erb +16 -0
- data/app/views/journeybook/admin/ai_style_prompts/edit.html.erb +2 -0
- data/app/views/journeybook/admin/ai_style_prompts/index.html.erb +26 -0
- data/app/views/journeybook/admin/ai_style_prompts/new.html.erb +2 -0
- data/app/views/journeybook/admin/components/_component.html.erb +17 -0
- data/app/views/journeybook/admin/components/_reference.html.erb +9 -0
- data/app/views/journeybook/admin/components/index.html.erb +46 -0
- data/app/views/journeybook/admin/components/preview.html.erb +13 -0
- data/app/views/journeybook/admin/dashboard/show.html.erb +34 -0
- data/app/views/journeybook/admin/media_assets/_reference.html.erb +16 -0
- data/app/views/journeybook/admin/media_assets/index.html.erb +43 -0
- data/app/views/journeybook/admin/media_assets/new.html.erb +41 -0
- data/app/views/journeybook/admin/navigation_items/_form.html.erb +46 -0
- data/app/views/journeybook/admin/navigation_items/edit.html.erb +2 -0
- data/app/views/journeybook/admin/navigation_items/new.html.erb +2 -0
- data/app/views/journeybook/admin/navigation_menus/_form.html.erb +10 -0
- data/app/views/journeybook/admin/navigation_menus/edit.html.erb +38 -0
- data/app/views/journeybook/admin/navigation_menus/index.html.erb +28 -0
- data/app/views/journeybook/admin/navigation_menus/new.html.erb +2 -0
- data/app/views/journeybook/admin/pages/_collection_field.html.erb +58 -0
- data/app/views/journeybook/admin/pages/_form.html.erb +77 -0
- data/app/views/journeybook/admin/pages/_image_picker_modal.html.erb +23 -0
- data/app/views/journeybook/admin/pages/_table.html.erb +37 -0
- data/app/views/journeybook/admin/pages/_url_field.html.erb +25 -0
- data/app/views/journeybook/admin/pages/edit.html.erb +547 -0
- data/app/views/journeybook/admin/pages/errors.html.erb +25 -0
- data/app/views/journeybook/admin/pages/index.html.erb +52 -0
- data/app/views/journeybook/admin/pages/new.html.erb +27 -0
- data/app/views/journeybook/admin/pages/new_component.html.erb +70 -0
- data/app/views/journeybook/admin/pages/public_preview.html.erb +27 -0
- data/app/views/journeybook/admin/pages/source.html.erb +23 -0
- data/app/views/journeybook/admin/redirects/_form.html.erb +9 -0
- data/app/views/journeybook/admin/redirects/edit.html.erb +2 -0
- data/app/views/journeybook/admin/redirects/index.html.erb +15 -0
- data/app/views/journeybook/admin/redirects/new.html.erb +2 -0
- data/app/views/journeybook/admin/shared/_drag_handle.html.erb +3 -0
- data/app/views/journeybook/admin/shared/_errors.html.erb +10 -0
- data/app/views/journeybook/admin/sites/_form.html.erb +12 -0
- data/app/views/journeybook/admin/sites/edit.html.erb +2 -0
- data/app/views/journeybook/admin/sites/index.html.erb +26 -0
- data/app/views/journeybook/admin/sites/new.html.erb +2 -0
- data/app/views/layouts/journeybook/application.html.erb +57 -0
- data/config/importmap.rb +3 -0
- data/config/routes.rb +39 -0
- data/db/migrate/20260627000001_create_journeybook_sites.rb +13 -0
- data/db/migrate/20260627000002_create_journeybook_pages.rb +23 -0
- data/db/migrate/20260627000003_create_journeybook_redirects.rb +13 -0
- data/db/migrate/20260628000004_add_context_fields_to_journeybook_sites.rb +38 -0
- data/db/migrate/20260628000005_create_journeybook_media_assets.rb +24 -0
- data/db/migrate/20260629000006_create_journeybook_navigation.rb +28 -0
- data/db/migrate/20260731000007_add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
- data/db/migrate/20260731000008_create_journeybook_ai_style_prompts.rb +12 -0
- data/db/migrate/20260801000009_add_blog_fields_to_journeybook_pages.rb +9 -0
- data/db/migrate/20260801000010_add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
- data/db/migrate/20260801000011_add_position_to_journeybook_pages.rb +6 -0
- data/lib/generators/journeybook/install/install_generator.rb +72 -0
- data/lib/generators/journeybook/install/templates/add_blog_fields_to_journeybook_pages.rb +9 -0
- data/lib/generators/journeybook/install/templates/add_context_fields_to_journeybook_sites.rb +38 -0
- data/lib/generators/journeybook/install/templates/add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
- data/lib/generators/journeybook/install/templates/add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
- data/lib/generators/journeybook/install/templates/add_position_to_journeybook_pages.rb +6 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_ai_style_prompts.rb +12 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_media_assets.rb +24 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_navigation.rb +28 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_pages.rb +33 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_redirects.rb +13 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_sites.rb +13 -0
- data/lib/generators/journeybook/install/templates/journeybook.rb +42 -0
- data/lib/generators/journeybook/install/templates/pages_controller.rb +3 -0
- data/lib/generators/journeybook/install/templates/show.html.erb +10 -0
- data/lib/journeybook/component_registry.rb +136 -0
- data/lib/journeybook/configuration.rb +55 -0
- data/lib/journeybook/engine.rb +19 -0
- data/lib/journeybook/page_template_registry.rb +67 -0
- data/lib/journeybook/public_page_constraint.rb +23 -0
- data/lib/journeybook/version.rb +3 -0
- data/lib/journeybook.rb +62 -0
- data/lib/tasks/journeybook_tasks.rake +8 -0
- metadata +266 -0
data/lib/generators/journeybook/install/templates/add_knowledge_base_fields_to_journeybook_pages.rb
ADDED
|
@@ -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,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,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
|
data/lib/journeybook.rb
ADDED
|
@@ -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
|