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,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