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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
class Page < ApplicationRecord
|
|
3
|
+
STATUSES = %w[draft published archived].freeze
|
|
4
|
+
PAGE_TYPES = %w[page blog_post kb_article glossary_term].freeze
|
|
5
|
+
|
|
6
|
+
belongs_to :site
|
|
7
|
+
belongs_to :parent, class_name: "Page", optional: true
|
|
8
|
+
has_many :children, class_name: "Page", foreign_key: :parent_id, inverse_of: :parent, dependent: :nullify
|
|
9
|
+
has_many :navigation_items, class_name: "NavigationItem", dependent: :nullify
|
|
10
|
+
|
|
11
|
+
before_validation :generate_slug
|
|
12
|
+
before_validation :normalize_path
|
|
13
|
+
|
|
14
|
+
validates :title, :slug, :path, :markdown, :status, :locale, :page_type, presence: true
|
|
15
|
+
validates :status, inclusion: { in: STATUSES }
|
|
16
|
+
validates :page_type, inclusion: { in: PAGE_TYPES }
|
|
17
|
+
validates :slug, uniqueness: { scope: %i[site_id locale] }
|
|
18
|
+
validates :path, uniqueness: { scope: %i[site_id locale] }
|
|
19
|
+
validate :parent_is_valid_knowledge_base_article
|
|
20
|
+
validate :published_components_are_valid
|
|
21
|
+
|
|
22
|
+
scope :published, -> { where(status: "published").where("published_at IS NULL OR published_at <= ?", Time.current) }
|
|
23
|
+
scope :draft, -> { where(status: "draft") }
|
|
24
|
+
scope :by_slug, ->(slug) { where(slug: slug) }
|
|
25
|
+
scope :by_path, ->(path) { where(path: path) }
|
|
26
|
+
scope :by_page_type, ->(type) { where(page_type: type) }
|
|
27
|
+
scope :blog_posts, -> { by_page_type("blog_post") }
|
|
28
|
+
scope :kb_articles, -> { by_page_type("kb_article") }
|
|
29
|
+
scope :glossary_terms, -> { by_page_type("glossary_term") }
|
|
30
|
+
scope :root_kb_articles, -> { kb_articles.where(parent_id: nil) }
|
|
31
|
+
scope :ordered, -> { order(:position, :title) }
|
|
32
|
+
|
|
33
|
+
def published?
|
|
34
|
+
status == "published" && (published_at.blank? || published_at <= Time.current)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def blog_post?
|
|
38
|
+
page_type == "blog_post"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def kb_article?
|
|
42
|
+
page_type == "kb_article"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def glossary_term?
|
|
46
|
+
page_type == "glossary_term"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def layout_name
|
|
50
|
+
layout.presence || site.layout_name
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def component_validation_errors
|
|
54
|
+
ComponentValidator.new.errors_for(self)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def published_components_are_valid
|
|
60
|
+
return unless status == "published"
|
|
61
|
+
|
|
62
|
+
component_validation_errors.each { |message| errors.add(:markdown, message) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parent_is_valid_knowledge_base_article
|
|
66
|
+
return unless parent
|
|
67
|
+
|
|
68
|
+
errors.add(:parent, "cannot be the page itself") if parent == self
|
|
69
|
+
errors.add(:parent, "must belong to the same site") if parent.site_id != site_id
|
|
70
|
+
errors.add(:parent, "can only be set for knowledge base articles") unless kb_article?
|
|
71
|
+
errors.add(:parent, "must be a knowledge base article") unless parent.kb_article?
|
|
72
|
+
errors.add(:parent, "cannot create a circular hierarchy") if parent_ancestor?(self)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parent_ancestor?(candidate)
|
|
76
|
+
ancestor = parent
|
|
77
|
+
while ancestor
|
|
78
|
+
return true if ancestor == candidate
|
|
79
|
+
|
|
80
|
+
ancestor = ancestor.parent
|
|
81
|
+
end
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def generate_slug
|
|
86
|
+
self.slug = title.to_s.parameterize if slug.blank?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_path
|
|
90
|
+
self.path = "/" if path.blank?
|
|
91
|
+
self.path = "/#{path}" unless path.start_with?("/")
|
|
92
|
+
self.path = path.squeeze("/")
|
|
93
|
+
self.path = path.delete_suffix("/") unless path == "/"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
class Redirect < ApplicationRecord
|
|
3
|
+
VALID_STATUS_CODES = [ 301, 302, 307, 308 ].freeze
|
|
4
|
+
|
|
5
|
+
before_validation :normalize_paths
|
|
6
|
+
|
|
7
|
+
validates :from, :to, :status_code, presence: true
|
|
8
|
+
validates :from, uniqueness: true
|
|
9
|
+
validates :from, :to, format: { with: %r{\A/(?!/)} }
|
|
10
|
+
validates :status_code, inclusion: { in: VALID_STATUS_CODES }
|
|
11
|
+
|
|
12
|
+
def self.for_path(path)
|
|
13
|
+
find_by(from: normalize_path(path))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.normalize_path(path)
|
|
17
|
+
value = path.to_s.strip
|
|
18
|
+
value = "/#{value}" unless value.start_with?("/")
|
|
19
|
+
value = value.squeeze("/")
|
|
20
|
+
value = value.delete_suffix("/") unless value == "/"
|
|
21
|
+
value.presence || "/"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def normalize_paths
|
|
27
|
+
self.from = self.class.normalize_path(from)
|
|
28
|
+
self.to = self.class.normalize_path(to)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
class Site < ApplicationRecord
|
|
3
|
+
has_many :pages, dependent: :destroy
|
|
4
|
+
has_many :navigation_menus, dependent: :destroy
|
|
5
|
+
|
|
6
|
+
before_validation :normalize_context_fields
|
|
7
|
+
|
|
8
|
+
validates :name, :key, :host, :path_prefix, presence: true
|
|
9
|
+
validates :key, uniqueness: true
|
|
10
|
+
validates :path_prefix, uniqueness: { scope: :host }
|
|
11
|
+
validates :default_locale, presence: true
|
|
12
|
+
|
|
13
|
+
scope :for_host, ->(host) { where(host: host) }
|
|
14
|
+
scope :by_longest_prefix, -> { order(Arel.sql("LENGTH(path_prefix) DESC")) }
|
|
15
|
+
|
|
16
|
+
def layout_name
|
|
17
|
+
layout.presence || Journeybook.configuration.default_layout
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def relative_path_for(request_path)
|
|
21
|
+
path = normalize_path(request_path)
|
|
22
|
+
prefix = normalize_path(path_prefix)
|
|
23
|
+
|
|
24
|
+
return path if prefix == "/"
|
|
25
|
+
return unless path == prefix || path.start_with?("#{prefix}/")
|
|
26
|
+
|
|
27
|
+
relative = path.delete_prefix(prefix)
|
|
28
|
+
relative.presence || "/"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def matches_path?(request_path)
|
|
32
|
+
relative_path_for(request_path).present?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def public_path_for(page)
|
|
36
|
+
return unless page
|
|
37
|
+
|
|
38
|
+
prefix = normalize_path(path_prefix)
|
|
39
|
+
path = normalize_path(page.path)
|
|
40
|
+
return path if prefix == "/"
|
|
41
|
+
return prefix if path == "/"
|
|
42
|
+
|
|
43
|
+
"#{prefix}#{path}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normalize_context_fields
|
|
49
|
+
self.key = key.presence || name.to_s.parameterize(separator: "_")
|
|
50
|
+
self.key = key.to_s.strip.underscore
|
|
51
|
+
self.path_prefix = normalize_path(path_prefix.presence || "/")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def normalize_path(value)
|
|
55
|
+
path = value.to_s.strip
|
|
56
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
57
|
+
path = path.squeeze("/")
|
|
58
|
+
path = path.delete_suffix("/") unless path == "/"
|
|
59
|
+
path.presence || "/"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
module Ai
|
|
3
|
+
class MarkdownGenerator
|
|
4
|
+
def initialize(site: nil, style_prompt: nil, template: nil, prompt_builder: nil, client: OpenaiClient.new)
|
|
5
|
+
prompt_builder ||= PromptBuilder.new(components: Journeybook.components.for_site(site), site: site, style_prompt: style_prompt, template: template)
|
|
6
|
+
@prompt_builder = prompt_builder
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def generate(prompt:)
|
|
11
|
+
@client.complete(@prompt_builder.build(user_prompt: prompt))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
|
|
4
|
+
module Journeybook
|
|
5
|
+
module Ai
|
|
6
|
+
class OpenaiClient
|
|
7
|
+
class Error < Journeybook::Error; end
|
|
8
|
+
|
|
9
|
+
ENDPOINT = URI("https://api.openai.com/v1/responses")
|
|
10
|
+
|
|
11
|
+
def initialize(api_key: Journeybook.configuration.openai_api_key.presence || ENV["OPENAI_API_KEY"], model: Journeybook.configuration.openai_model)
|
|
12
|
+
@api_key = api_key
|
|
13
|
+
@model = model
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def complete(prompt)
|
|
17
|
+
raise Error, "OpenAI API key is missing. Set config.openai_api_key or OPENAI_API_KEY." if @api_key.blank?
|
|
18
|
+
|
|
19
|
+
response = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true) do |http|
|
|
20
|
+
http.request(build_request(prompt))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
body = JSON.parse(response.body)
|
|
24
|
+
raise Error, openai_error(body, response) unless response.is_a?(Net::HTTPSuccess)
|
|
25
|
+
|
|
26
|
+
extract_text(body)
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
raise Error, "OpenAI returned an invalid JSON response."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def build_request(prompt)
|
|
34
|
+
request = Net::HTTP::Post.new(ENDPOINT)
|
|
35
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
36
|
+
request["Content-Type"] = "application/json"
|
|
37
|
+
request.body = JSON.dump({ model: @model, input: prompt })
|
|
38
|
+
request
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_text(body)
|
|
42
|
+
text = body["output_text"].presence || body.fetch("output", []).flat_map { |item| item.fetch("content", []) }.filter_map { |content| content["text"] }.join("\n")
|
|
43
|
+
text = text.to_s.strip
|
|
44
|
+
raise Error, "OpenAI returned an empty response." if text.blank?
|
|
45
|
+
|
|
46
|
+
text
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def openai_error(body, response)
|
|
50
|
+
body.dig("error", "message").presence || "OpenAI request failed with HTTP #{response.code}."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Journeybook
|
|
4
|
+
module Ai
|
|
5
|
+
class PromptBuilder
|
|
6
|
+
def initialize(components: Journeybook.components, site: nil, style_prompt: nil, template: nil, media_assets: MediaAsset.order(created_at: :desc).limit(25))
|
|
7
|
+
@components = components
|
|
8
|
+
@site = site
|
|
9
|
+
@style_prompt = style_prompt
|
|
10
|
+
@template = template
|
|
11
|
+
@media_assets = media_assets
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build(user_prompt:)
|
|
15
|
+
<<~PROMPT
|
|
16
|
+
You are designing a Journeybook CMS page for a Rails website.
|
|
17
|
+
|
|
18
|
+
Return exactly one complete Markdown document. Do not wrap it in code fences. Do not explain the result.
|
|
19
|
+
|
|
20
|
+
User request:
|
|
21
|
+
#{user_prompt}
|
|
22
|
+
|
|
23
|
+
Style instructions:
|
|
24
|
+
#{style_instructions}
|
|
25
|
+
|
|
26
|
+
Current Journeybook context:
|
|
27
|
+
#{site_context}
|
|
28
|
+
|
|
29
|
+
Template instructions:
|
|
30
|
+
#{template_instructions}
|
|
31
|
+
|
|
32
|
+
Available Journeybook components:
|
|
33
|
+
#{component_reference}
|
|
34
|
+
|
|
35
|
+
Available image library:
|
|
36
|
+
#{image_library_reference}
|
|
37
|
+
|
|
38
|
+
Rules:
|
|
39
|
+
- Use component tags only for page content.
|
|
40
|
+
- Do not write Markdown headings, paragraphs, lists, links, raw HTML, or standalone text outside component tags.
|
|
41
|
+
- The only allowed non-component content is frontmatter, if frontmatter is needed.
|
|
42
|
+
- Copy/text may appear inside component prop values.
|
|
43
|
+
- Use only the registered components listed above.
|
|
44
|
+
- Component syntax is: {{ component_name prop="value" other_prop="value" }}
|
|
45
|
+
- Do not invent unsupported component names.
|
|
46
|
+
- Use image library URLs only when a registered component has a suitable image prop.
|
|
47
|
+
- Prefer matching alt text from the image library.
|
|
48
|
+
- Do not invent image URLs.
|
|
49
|
+
- Do not output raw Markdown image syntax.
|
|
50
|
+
- Fill component props with concise production-ready copy.
|
|
51
|
+
- Keep the page coherent and ready for a public website.
|
|
52
|
+
PROMPT
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def style_instructions
|
|
58
|
+
@style_prompt.presence || "No additional style instructions."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def site_context
|
|
62
|
+
return "No site context selected." unless @site
|
|
63
|
+
|
|
64
|
+
<<~TEXT
|
|
65
|
+
- Name: #{@site.name}
|
|
66
|
+
- Key: #{@site.key}
|
|
67
|
+
- Path prefix: #{@site.path_prefix}
|
|
68
|
+
- Layout: #{@site.layout_name}
|
|
69
|
+
TEXT
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def component_reference
|
|
73
|
+
components = available_components
|
|
74
|
+
return "No components are registered. Return only frontmatter or an empty document." if components.none?
|
|
75
|
+
|
|
76
|
+
components.map do |component|
|
|
77
|
+
props = component.normalized_props
|
|
78
|
+
prop_lines = props.map do |name, prop|
|
|
79
|
+
collection_reference(name, prop) || " - #{name} (#{prop_type_reference(prop)}): #{prop[:description].presence || prop[:label]}"
|
|
80
|
+
end.join("\n")
|
|
81
|
+
|
|
82
|
+
<<~TEXT
|
|
83
|
+
- #{component.name}
|
|
84
|
+
Description: #{component.metadata.fetch(:description, "No description provided")}
|
|
85
|
+
Props:
|
|
86
|
+
#{prop_lines.presence || " - none"}
|
|
87
|
+
Example: {{ #{component.name} #{props.map { |name, prop| prop_example(name, prop) }.join(" ")} }}
|
|
88
|
+
TEXT
|
|
89
|
+
end.join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def template_instructions
|
|
93
|
+
return "No template selected. Choose the component layout that best fits the request." unless @template
|
|
94
|
+
|
|
95
|
+
<<~TEXT
|
|
96
|
+
- Template: #{@template.name}
|
|
97
|
+
- Editorial purpose: #{@template.description}
|
|
98
|
+
- Page type: #{@template.page_type}
|
|
99
|
+
- Use only the template components, in exactly this order, and include each one exactly once.
|
|
100
|
+
- Replace starter copy with production-ready content that answers the user request.
|
|
101
|
+
- Do not add, remove, or reorder components.
|
|
102
|
+
Template layout:
|
|
103
|
+
#{@template.markdown}
|
|
104
|
+
TEXT
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def available_components
|
|
108
|
+
return @components unless @template
|
|
109
|
+
|
|
110
|
+
names = ComponentParser.new.parse(@template.markdown).map(&:name).uniq
|
|
111
|
+
@components.select { |component| component.name.in?(names) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def collection_reference(name, prop)
|
|
115
|
+
return unless prop[:type] == :collection
|
|
116
|
+
|
|
117
|
+
item_props = prop[:item].map do |item_name, item_prop|
|
|
118
|
+
"#{item_name} (#{prop_type_reference(item_prop)})"
|
|
119
|
+
end.join(", ")
|
|
120
|
+
count = prop[:max] ? "#{prop[:min]}-#{prop[:max]}" : "at least #{prop[:min]}"
|
|
121
|
+
" - #{name} (collection, #{count} items): #{item_props}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def prop_example(name, prop)
|
|
125
|
+
return %(#{name}="#{prop[:options]&.first || "..."}") unless prop[:type] == :collection
|
|
126
|
+
|
|
127
|
+
item = prop[:item].each_with_object({}) do |(item_name, item_prop), values|
|
|
128
|
+
values[item_name] = item_prop[:options]&.first || "..."
|
|
129
|
+
end
|
|
130
|
+
%(#{name}='#{JSON.generate([ item ])}')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def prop_type_reference(prop)
|
|
134
|
+
return prop[:type].to_s unless prop[:type] == :select
|
|
135
|
+
|
|
136
|
+
"select: #{prop[:options].join(", ")}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def image_library_reference
|
|
140
|
+
assets = @media_assets.to_a
|
|
141
|
+
return "No images are available." if assets.none?
|
|
142
|
+
|
|
143
|
+
assets.map do |asset|
|
|
144
|
+
<<~TEXT
|
|
145
|
+
- Title: #{asset.title}
|
|
146
|
+
Alt text: #{asset.alt_text.presence || "No alt text provided"}
|
|
147
|
+
URL: #{asset.public_url}
|
|
148
|
+
Tags: #{asset.tags.presence || "none"}
|
|
149
|
+
TEXT
|
|
150
|
+
end.join("\n")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
module Ai
|
|
3
|
+
class TemplateValidator
|
|
4
|
+
def initialize(parser: ComponentParser.new)
|
|
5
|
+
@parser = parser
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def errors_for(markdown, template)
|
|
9
|
+
expected = @parser.parse(template.markdown).map(&:name)
|
|
10
|
+
actual = @parser.parse(markdown).map(&:name)
|
|
11
|
+
return [] if actual == expected
|
|
12
|
+
|
|
13
|
+
[ "AI draft must use the #{template.name} template components in the approved order." ]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
class ComponentParser
|
|
3
|
+
HIDDEN_PROP = :journeybook_hidden
|
|
4
|
+
|
|
5
|
+
ParsedComponent = Data.define(:name, :props, :source, :token) do
|
|
6
|
+
def hidden?
|
|
7
|
+
props[ComponentParser::HIDDEN_PROP] == "true"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
COMPONENT_PATTERN = /\{\{\s*(.*?)\s*\}\}/m
|
|
12
|
+
NAME_PATTERN = /\A([a-zA-Z][\w-]*)/
|
|
13
|
+
PROP_PATTERN = /([a-zA-Z_]\w*)\s*=\s*(?:"([^"]*)"|'([^']*)')/
|
|
14
|
+
|
|
15
|
+
# @param markdown [String]
|
|
16
|
+
# @return [Array<ParsedComponent>]
|
|
17
|
+
def parse(markdown)
|
|
18
|
+
markdown.to_s.scan(COMPONENT_PATTERN).each_with_index.filter_map do |(body), index|
|
|
19
|
+
build_component(body, index)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param markdown [String]
|
|
24
|
+
# @return [Array(String, Array<ParsedComponent>)]
|
|
25
|
+
def extract(markdown)
|
|
26
|
+
components = []
|
|
27
|
+
extracted = markdown.to_s.gsub(COMPONENT_PATTERN) do |source|
|
|
28
|
+
component = build_component(Regexp.last_match(1), components.length, source: source)
|
|
29
|
+
next source unless component
|
|
30
|
+
|
|
31
|
+
components << component
|
|
32
|
+
component.token
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
[ extracted, components ]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_component(body, index, source: nil)
|
|
41
|
+
match = body.match(NAME_PATTERN)
|
|
42
|
+
return unless match
|
|
43
|
+
|
|
44
|
+
name = match[1].tr("-", "_").to_sym
|
|
45
|
+
props = body.scan(PROP_PATTERN).each_with_object({}) do |(name, double_quoted, single_quoted), parsed|
|
|
46
|
+
parsed[name.to_sym] = double_quoted || single_quoted
|
|
47
|
+
end
|
|
48
|
+
token = "JOURNEYBOOKCOMPONENT#{index}END"
|
|
49
|
+
|
|
50
|
+
ParsedComponent.new(name, props, source || "{{#{body}}}", token)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Journeybook
|
|
4
|
+
class ComponentRenderer
|
|
5
|
+
# @param registry [Journeybook::ComponentRegistry]
|
|
6
|
+
def initialize(registry: Journeybook.components)
|
|
7
|
+
@registry = registry
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# @param component [Journeybook::ComponentParser::ParsedComponent]
|
|
11
|
+
# @return [String]
|
|
12
|
+
def render(component, page: nil)
|
|
13
|
+
return "" if component.hidden?
|
|
14
|
+
|
|
15
|
+
registration = @registry.registered(component.name)
|
|
16
|
+
component_class = @registry.fetch(component.name)
|
|
17
|
+
schema = registration.normalized_props
|
|
18
|
+
props = coerce_props(component.props.except(ComponentParser::HIDDEN_PROP), schema, page)
|
|
19
|
+
link_targets = link_targets(component.props, schema, page)
|
|
20
|
+
|
|
21
|
+
html = if component_class.respond_to?(:render)
|
|
22
|
+
if accepts_keyword?(component_class.method(:render), :page)
|
|
23
|
+
component_class.render(**props, page: page).to_s
|
|
24
|
+
else
|
|
25
|
+
component_class.render(**props).to_s
|
|
26
|
+
end
|
|
27
|
+
elsif accepts_keyword?(component_class.instance_method(:initialize), :page)
|
|
28
|
+
component_class.new(**props, page: page).render.to_s
|
|
29
|
+
else
|
|
30
|
+
component_class.new(**props).render.to_s
|
|
31
|
+
end
|
|
32
|
+
apply_link_targets(html, link_targets)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def coerce_props(props, schema, page)
|
|
38
|
+
props.each_with_object({}) do |(name, value), coerced|
|
|
39
|
+
next if target_prop?(name, schema)
|
|
40
|
+
|
|
41
|
+
config = schema[name]
|
|
42
|
+
coerced[name] = if config&.fetch(:type) == :collection
|
|
43
|
+
collection_value(value, config[:item], page)
|
|
44
|
+
elsif config&.fetch(:type) == :url
|
|
45
|
+
resolve_url(value, page)
|
|
46
|
+
else
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def collection_value(value, item_schema, page)
|
|
53
|
+
JSON.parse(value).map do |item|
|
|
54
|
+
item.deep_symbolize_keys.each_with_object({}) do |(name, item_value), props|
|
|
55
|
+
props[name] = item_schema[name]&.fetch(:type) == :url ? resolve_url(item_value, page) : item_value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
rescue JSON::ParserError, NoMethodError
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def link_targets(props, schema, page)
|
|
63
|
+
props.each_with_object({}) do |(name, value), targets|
|
|
64
|
+
config = schema[name]
|
|
65
|
+
if config&.fetch(:type) == :url && props["#{name}_target".to_sym] == "open_new_window"
|
|
66
|
+
targets[resolve_url(value, page)] = "_blank"
|
|
67
|
+
elsif config&.fetch(:type) == :collection
|
|
68
|
+
collection_value(value, config[:item], page).each do |item|
|
|
69
|
+
target = item[:href_target]
|
|
70
|
+
targets[item[:href]] = "_blank" if target == "open_new_window" && item[:href].present?
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply_link_targets(html, targets)
|
|
77
|
+
return html if targets.empty?
|
|
78
|
+
|
|
79
|
+
fragment = Nokogiri::HTML5.fragment(html)
|
|
80
|
+
fragment.css("a[href]").each do |link|
|
|
81
|
+
next unless targets[link["href"]] == "_blank"
|
|
82
|
+
|
|
83
|
+
link["target"] = "_blank"
|
|
84
|
+
link["rel"] = "noopener noreferrer"
|
|
85
|
+
end
|
|
86
|
+
fragment.to_html
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def resolve_url(value, page)
|
|
90
|
+
match = value.to_s.match(%r{\Ajourneybook://page/(\d+)\z})
|
|
91
|
+
return value unless match && page
|
|
92
|
+
|
|
93
|
+
page.site.pages.find_by(id: match[1])&.path || value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def target_prop?(name, schema)
|
|
97
|
+
name.to_s.end_with?("_target") && schema.key?(name.to_s.delete_suffix("_target").to_sym)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def accepts_keyword?(method, name)
|
|
101
|
+
return false unless method.respond_to?(:parameters)
|
|
102
|
+
|
|
103
|
+
method.parameters.any? do |type, param|
|
|
104
|
+
param == name && %i[keyreq key keyrest].include?(type)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|