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,132 @@
1
+ require "json"
2
+
3
+ module Journeybook
4
+ class ComponentTagUpdater
5
+ def initialize(parser: ComponentParser.new)
6
+ @parser = parser
7
+ end
8
+
9
+ def update(markdown, index, props)
10
+ components = @parser.parse(markdown)
11
+ component = components.fetch(index)
12
+ replace_component(markdown, index, component.name, component.props.merge(normalized_props(props)))
13
+ end
14
+
15
+ def move(markdown, index, direction)
16
+ tags = component_tags(markdown)
17
+ tag = fetch_tag(tags, index)
18
+ target_index = direction.to_s == "up" ? index - 1 : index + 1
19
+ target = fetch_tag(tags, target_index)
20
+
21
+ replace_sources(markdown, {
22
+ tag => target.source,
23
+ target => tag.source
24
+ })
25
+ end
26
+
27
+ def reorder(markdown, indexes)
28
+ tags = component_tags(markdown)
29
+ indexes = Array(indexes).map(&:to_i)
30
+ raise ArgumentError unless indexes.sort == (0...tags.length).to_a
31
+
32
+ replace_sources(markdown, tags.zip(indexes.map { |index| tags.fetch(index).source }).to_h)
33
+ end
34
+
35
+ def delete(markdown, index)
36
+ tag = fetch_tag(component_tags(markdown), index)
37
+ replace_sources(markdown, tag => "").gsub(/\n{3,}/, "\n\n")
38
+ end
39
+
40
+ def toggle_visibility(markdown, index)
41
+ components = @parser.parse(markdown)
42
+ component = components.fetch(index)
43
+ props = component.props.dup
44
+
45
+ if component.hidden?
46
+ props.delete(ComponentParser::HIDDEN_PROP)
47
+ else
48
+ props[ComponentParser::HIDDEN_PROP] = "true"
49
+ end
50
+
51
+ replace_component(markdown, index, component.name, props)
52
+ end
53
+
54
+ def append(markdown, name, props = {})
55
+ source = serialize(name, normalized_props(props))
56
+ body = markdown.to_s.rstrip
57
+
58
+ return source if body.blank?
59
+
60
+ "#{body}\n\n#{source}"
61
+ end
62
+
63
+ private
64
+
65
+ ComponentTag = Data.define(:source, :begin_index, :end_index)
66
+
67
+ def fetch_tag(tags, index)
68
+ raise IndexError if index.negative? || index >= tags.length
69
+
70
+ tags.fetch(index)
71
+ end
72
+
73
+ def component_tags(markdown)
74
+ markdown.to_s.enum_for(:scan, ComponentParser::COMPONENT_PATTERN).map do
75
+ match = Regexp.last_match
76
+ ComponentTag.new(match[0], match.begin(0), match.end(0))
77
+ end
78
+ end
79
+
80
+ def replace_component(markdown, index, name, props)
81
+ updated_source = serialize(name, props)
82
+ current_index = -1
83
+
84
+ markdown.to_s.gsub(ComponentParser::COMPONENT_PATTERN) do |source|
85
+ current_index += 1
86
+ current_index == index ? updated_source : source
87
+ end
88
+ end
89
+
90
+ def replace_sources(markdown, replacements)
91
+ replacements.sort_by { |tag, _source| tag.begin_index }.reverse_each.reduce(markdown.to_s.dup) do |updated, (tag, source)|
92
+ updated[tag.begin_index...tag.end_index] = source
93
+ updated
94
+ end
95
+ end
96
+
97
+ def normalized_props(props)
98
+ props.to_h.each_with_object({}) do |(name, value), normalized|
99
+ normalized[name.to_sym] = parsed_collection(value) || value.to_s
100
+ end
101
+ end
102
+
103
+ def serialize(name, props)
104
+ serialized_props = props.map do |key, value|
105
+ %(#{key}=#{serialize_value(value)})
106
+ end
107
+
108
+ "{{ #{([ name ] + serialized_props).join(" ")} }}"
109
+ end
110
+
111
+ def escape_value(value)
112
+ value.to_s.gsub('"', "'")
113
+ end
114
+
115
+ def serialize_value(value)
116
+ collection = parsed_collection(value)
117
+ return %('#{JSON.generate(collection).gsub("'", "\\u0027")}') if collection
118
+
119
+ %("#{escape_value(value)}")
120
+ end
121
+
122
+ def parsed_collection(value)
123
+ return value if value.is_a?(Array) || value.is_a?(Hash)
124
+ return unless value.to_s.strip.start_with?("[", "{")
125
+
126
+ parsed = JSON.parse(value)
127
+ parsed if parsed.is_a?(Array) || parsed.is_a?(Hash)
128
+ rescue JSON::ParserError
129
+ nil
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,111 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ module Journeybook
5
+ class ComponentValidator
6
+ def initialize(parser: ComponentParser.new, registry: Journeybook.components)
7
+ @parser = parser
8
+ @registry = registry
9
+ end
10
+
11
+ def errors_for(page)
12
+ components = @registry.for_site(page.site)
13
+
14
+ @parser.parse(page.markdown).flat_map.with_index do |component, index|
15
+ validate_component(component, index, components.registered(component.name))
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def validate_component(component, index, registration)
22
+ label = "Component #{index + 1} (#{component.name})"
23
+ return [ "#{label} is not registered for this site." ] unless registration
24
+
25
+ schema = registration.normalized_props
26
+ props = component.props.except(ComponentParser::HIDDEN_PROP)
27
+ errors = props.keys.filter_map do |name|
28
+ "#{label} has an unknown prop: #{name}." unless schema.key?(name) || target_prop?(name, schema)
29
+ end
30
+
31
+ schema.each do |name, config|
32
+ value = props[name].to_s
33
+ errors << "#{label} is missing required prop: #{name}." if config[:required] && value.blank?
34
+ errors.concat(validate_collection(label, name, value, config)) if value.present? && config[:type] == :collection
35
+ errors << "#{label} has an invalid #{config[:type]} prop: #{name}." if value.present? && invalid_value?(value, config)
36
+ end
37
+
38
+ errors
39
+ end
40
+
41
+ def validate_collection(label, name, value, config)
42
+ items = JSON.parse(value)
43
+ return [ "#{label} has an invalid collection prop: #{name}." ] unless items.is_a?(Array)
44
+
45
+ errors = []
46
+ errors << "#{label} needs at least #{config[:min]} #{name} item(s)." if items.length < config[:min]
47
+ errors << "#{label} allows at most #{config[:max]} #{name} item(s)." if config[:max] && items.length > config[:max]
48
+
49
+ items.each_with_index do |item, index|
50
+ errors.concat(validate_collection_item(label, name, item, index, config[:item]))
51
+ end
52
+ errors
53
+ rescue JSON::ParserError
54
+ [ "#{label} has an invalid collection prop: #{name}." ]
55
+ end
56
+
57
+ def validate_collection_item(label, name, item, index, schema)
58
+ item_label = "#{label} #{name} item #{index + 1}"
59
+ return [ "#{item_label} must be an object." ] unless item.is_a?(Hash)
60
+
61
+ props = item.deep_symbolize_keys
62
+ errors = props.keys.filter_map do |key|
63
+ "#{item_label} has an unknown prop: #{key}." unless schema.key?(key) || target_prop?(key, schema)
64
+ end
65
+
66
+ schema.each do |key, config|
67
+ value = props[key].to_s
68
+ errors << "#{item_label} is missing required prop: #{key}." if config[:required] && value.blank?
69
+ errors << "#{item_label} has an invalid #{config[:type]} prop: #{key}." if value.present? && invalid_value?(value, config)
70
+ end
71
+ errors
72
+ end
73
+
74
+ def invalid_value?(value, config)
75
+ case config[:type]
76
+ when :select
77
+ !config[:options].include?(value)
78
+ when :image, :video
79
+ !http_url?(value) && !(config[:icons] && icon_reference?(value, config[:icons]))
80
+ when :url
81
+ !relative_or_http_url?(value) && !page_reference?(value)
82
+ else
83
+ false
84
+ end
85
+ end
86
+
87
+ def http_url?(value)
88
+ uri = URI.parse(value)
89
+ uri.is_a?(URI::HTTP) && uri.host.present?
90
+ rescue URI::InvalidURIError
91
+ false
92
+ end
93
+
94
+ def relative_or_http_url?(value)
95
+ value.start_with?("/", "#") || http_url?(value)
96
+ end
97
+
98
+ def page_reference?(value)
99
+ value.match?(%r{\Ajourneybook://page/\d+\z})
100
+ end
101
+
102
+ def icon_reference?(value, icon_config)
103
+ style, color, identifier = value.to_s.match(%r{\Aicon:([a-z0-9-]+):([a-z0-9-]+):([a-z0-9-]+)\z})&.captures
104
+ style.in?(icon_config[:styles]) && color.in?(icon_config[:colors]) && identifier.present?
105
+ end
106
+
107
+ def target_prop?(name, schema)
108
+ name.to_s.end_with?("_target") && schema.key?(name.to_s.delete_suffix("_target").to_sym)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,69 @@
1
+ module Journeybook
2
+ class FormMailer
3
+ class ConfigurationError < StandardError; end
4
+
5
+ DEFAULT_SUBJECT = "New Journeybook form submission".freeze
6
+
7
+ def initialize(client: nil, configuration: Journeybook.configuration)
8
+ @client = client
9
+ @configuration = configuration
10
+ end
11
+
12
+ def deliver(fields:)
13
+ validate_configuration!
14
+
15
+ client.send_email(
16
+ from: @configuration.form_from,
17
+ to: @configuration.form_to,
18
+ subject: fields.delete("subject").to_s.truncate(200).presence || DEFAULT_SUBJECT,
19
+ text_body: body_for(fields),
20
+ reply_to: reply_to_for(fields)
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ def client
27
+ @client ||= delivery_client
28
+ end
29
+
30
+ def delivery_client
31
+ case delivery_method
32
+ when "postmark"
33
+ PostmarkClient.new(api_key: @configuration.postmark_api_key)
34
+ when "letter_opener"
35
+ LetterOpenerClient.new
36
+ else
37
+ raise ConfigurationError, "Unsupported Journeybook form delivery method: #{delivery_method}"
38
+ end
39
+ end
40
+
41
+ def delivery_method
42
+ @configuration.form_delivery_method.to_s
43
+ end
44
+
45
+ def validate_configuration!
46
+ missing = []
47
+ missing << "postmark_api_key" if delivery_method == "postmark" && @configuration.postmark_api_key.blank?
48
+ missing << "form_from" if @configuration.form_from.blank?
49
+ missing << "form_to" if @configuration.form_to.blank?
50
+ return if missing.empty?
51
+
52
+ raise ConfigurationError, "Missing Journeybook email configuration: #{missing.join(", ")}"
53
+ end
54
+
55
+ def body_for(fields)
56
+ fields.map do |name, value|
57
+ "#{name.to_s.humanize}: #{format_value(value)}"
58
+ end.join("\n\n")
59
+ end
60
+
61
+ def format_value(value)
62
+ Array(value).join(", ")
63
+ end
64
+
65
+ def reply_to_for(fields)
66
+ fields["email"].presence
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,46 @@
1
+ module Journeybook
2
+ class HtmlSanitizer
3
+ ALLOWED_TAGS = %w[
4
+ a abbr article blockquote br button code dd div dl dt em figcaption figure form h1 h2 h3 h4 h5 h6 i
5
+ hr iframe img input kbd label li ol p pre span strong table tbody td textarea th thead tr ul
6
+ section
7
+ ].freeze
8
+
9
+ ALLOWED_ATTRIBUTES = %w[
10
+ action allow allowfullscreen alt autocomplete class height href id loading method name placeholder referrerpolicy rel required rows src target title type value width
11
+ ].freeze
12
+
13
+ ALLOWED_IFRAME_HOSTS = %w[
14
+ www.youtube.com youtube.com www.youtube-nocookie.com youtube-nocookie.com player.vimeo.com
15
+ ].freeze
16
+
17
+ # @param html [String]
18
+ # @return [String]
19
+ def sanitize(html)
20
+ remove_unsafe_iframes(sanitizer.sanitize(html.to_s, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES).to_s)
21
+ end
22
+
23
+ private
24
+
25
+ def remove_unsafe_iframes(html)
26
+ fragment = Nokogiri::HTML5.fragment(html)
27
+
28
+ fragment.css("iframe").each do |iframe|
29
+ iframe.remove unless safe_iframe_src?(iframe["src"])
30
+ end
31
+
32
+ fragment.to_html
33
+ end
34
+
35
+ def safe_iframe_src?(src)
36
+ uri = URI.parse(src.to_s)
37
+ uri.is_a?(URI::HTTPS) && ALLOWED_IFRAME_HOSTS.include?(uri.host.to_s.downcase)
38
+ rescue URI::InvalidURIError
39
+ false
40
+ end
41
+
42
+ def sanitizer
43
+ @sanitizer ||= Rails::HTML5::SafeListSanitizer.new
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,14 @@
1
+ module Journeybook
2
+ class LetterOpenerClient
3
+ def send_email(from:, to:, subject:, text_body:, reply_to: nil)
4
+ require "letter_opener"
5
+
6
+ message = Mail.new(from:, to:, subject:, body: text_body)
7
+ message.reply_to = reply_to if reply_to.present?
8
+ message.delivery_method(LetterOpener::DeliveryMethod, location: Rails.root.join("tmp/letter_opener"))
9
+ message.deliver!
10
+ rescue LoadError
11
+ raise FormMailer::ConfigurationError, "Install the letter_opener gem to use Journeybook form_delivery_method = :letter_opener"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ require "commonmarker"
2
+
3
+ module Journeybook
4
+ class MarkdownRenderer
5
+ # @param markdown [String]
6
+ # @return [String]
7
+ def render(markdown)
8
+ Commonmarker.to_html(markdown.to_s, options: commonmarker_options)
9
+ rescue ArgumentError
10
+ Commonmarker.to_html(markdown.to_s)
11
+ end
12
+
13
+ private
14
+
15
+ def commonmarker_options
16
+ {
17
+ extension: {
18
+ table: true,
19
+ strikethrough: true,
20
+ autolink: true,
21
+ tasklist: true
22
+ },
23
+ render: {
24
+ unsafe: true
25
+ }
26
+ }
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,58 @@
1
+ module Journeybook
2
+ class PageRenderer
3
+ def initialize(
4
+ parser: ComponentParser.new,
5
+ markdown_renderer: MarkdownRenderer.new,
6
+ component_renderer: ComponentRenderer.new,
7
+ sanitizer: HtmlSanitizer.new
8
+ )
9
+ @parser = parser
10
+ @markdown_renderer = markdown_renderer
11
+ @component_renderer = component_renderer
12
+ @sanitizer = sanitizer
13
+ end
14
+
15
+ # @param page_or_markdown [Journeybook::Page, String]
16
+ # @return [String]
17
+ def render(page_or_markdown, form_authenticity_token: nil, component_index: nil, component_anchors: false)
18
+ @component_anchors = component_anchors
19
+ markdown = page_or_markdown.respond_to?(:markdown) ? page_or_markdown.markdown : page_or_markdown
20
+ extracted_markdown, components = @parser.extract(markdown)
21
+ return render_component(components[component_index], form_authenticity_token, page_or_markdown) if component_index
22
+
23
+ page = page_or_markdown if page_or_markdown.respond_to?(:markdown)
24
+ html = @markdown_renderer.render(extracted_markdown)
25
+
26
+ components.each_with_index do |component, index|
27
+ html = html.gsub(%r{<p>\s*#{component.token}\s*</p>}, component.token)
28
+ html = html.gsub(component.token, "#{component_anchor(index)}#{@component_renderer.render(component, page:)}")
29
+ end
30
+
31
+ @sanitizer.sanitize(inject_form_token(html, form_authenticity_token))
32
+ end
33
+
34
+ private
35
+
36
+ def render_component(component, form_authenticity_token, page)
37
+ return "" unless component
38
+
39
+ @sanitizer.sanitize(inject_form_token(@component_renderer.render(component, page:), form_authenticity_token))
40
+ end
41
+
42
+ def component_anchor(index)
43
+ return "" unless @component_anchors
44
+
45
+ %(<span id="journeybook-preview-component-#{index}" data-journeybook-preview-component="#{index}"></span>)
46
+ end
47
+
48
+ def inject_form_token(html, token)
49
+ return html if token.blank?
50
+
51
+ action = Regexp.escape(Journeybook.configuration.form_action.to_s)
52
+ form = %r{(<form\b(?=[^>]*\baction=(["'])#{action}\2)[^>]*>)}
53
+ token_input = %(<input type="hidden" name="authenticity_token" value="#{ERB::Util.html_escape(token)}">)
54
+
55
+ html.gsub(form) { "#{Regexp.last_match(1)}#{token_input}" }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,55 @@
1
+ module Journeybook
2
+ class PageResolver
3
+ def self.from_request(request, path: nil, locale: nil)
4
+ new(host: request.host, path: path, locale: locale)
5
+ end
6
+
7
+ def initialize(host:, path:, locale: nil)
8
+ @host = host
9
+ @path = path
10
+ @locale = locale
11
+ end
12
+
13
+ def call
14
+ matched_site = site
15
+ return unless matched_site
16
+
17
+ pages = matched_site.pages.published.by_path(matched_site.relative_path_for(normalized_path))
18
+ pages = pages.where(locale: @locale) if @locale.present?
19
+ pages.first
20
+ end
21
+
22
+ def site
23
+ @site ||= find_site
24
+ end
25
+
26
+ private
27
+
28
+ def find_site
29
+ sites = matching_host_sites.presence || local_sites
30
+ sites.by_longest_prefix.detect { |site| site.matches_path?(normalized_path) }
31
+ end
32
+
33
+ def matching_host_sites
34
+ Site.for_host(@host)
35
+ end
36
+
37
+ def local_sites
38
+ return Site.none unless local_host?
39
+
40
+ Site.where(host: %w[localhost 127.0.0.1])
41
+ end
42
+
43
+ def local_host?
44
+ @host.in?(%w[localhost 127.0.0.1 0.0.0.0])
45
+ end
46
+
47
+ def normalized_path
48
+ path = @path.to_s.strip
49
+ path = "/#{path}" unless path.start_with?("/")
50
+ path = path.squeeze("/")
51
+ path = path.delete_suffix("/") unless path == "/"
52
+ path.presence || "/"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,48 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Journeybook
6
+ class PostmarkClient
7
+ class Error < StandardError; end
8
+
9
+ ENDPOINT = URI("https://api.postmarkapp.com/email")
10
+
11
+ def initialize(api_key: Journeybook.configuration.postmark_api_key, endpoint: ENDPOINT)
12
+ @api_key = api_key
13
+ @endpoint = endpoint
14
+ end
15
+
16
+ def send_email(from:, to:, subject:, text_body:, reply_to: nil)
17
+ response = Net::HTTP.start(@endpoint.host, @endpoint.port, use_ssl: @endpoint.scheme == "https") do |http|
18
+ http.request(request(from:, to:, subject:, text_body:, reply_to:))
19
+ end
20
+
21
+ return response if response.is_a?(Net::HTTPSuccess)
22
+
23
+ raise Error, "Postmark delivery failed: #{response.code} #{response.body}"
24
+ end
25
+
26
+ private
27
+
28
+ def request(from:, to:, subject:, text_body:, reply_to:)
29
+ request = Net::HTTP::Post.new(@endpoint)
30
+ request["Accept"] = "application/json"
31
+ request["Content-Type"] = "application/json"
32
+ request["X-Postmark-Server-Token"] = @api_key.to_s
33
+ request.body = payload(from:, to:, subject:, text_body:, reply_to:).to_json
34
+ request
35
+ end
36
+
37
+ def payload(from:, to:, subject:, text_body:, reply_to:)
38
+ {
39
+ From: from,
40
+ To: to,
41
+ Subject: subject,
42
+ TextBody: text_body
43
+ }.tap do |attributes|
44
+ attributes[:ReplyTo] = reply_to if reply_to.present?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,96 @@
1
+ require "aws-sdk-s3"
2
+ require "digest"
3
+ require "securerandom"
4
+
5
+ module Journeybook
6
+ module Storage
7
+ class S3Client
8
+ class Error < Journeybook::Error; end
9
+
10
+ Upload = Data.define(:storage_key, :original_filename, :content_type, :byte_size, :checksum)
11
+
12
+ def initialize(configuration: Journeybook.configuration, client: nil)
13
+ @configuration = configuration
14
+ @client = client
15
+ end
16
+
17
+ def upload(file)
18
+ raise Error, "Image file is required." unless file.respond_to?(:read)
19
+
20
+ content = file.read
21
+ file.rewind if file.respond_to?(:rewind)
22
+ original_filename = File.basename(file.original_filename.to_s)
23
+ content_type = file.content_type.to_s
24
+ storage_key = build_storage_key(original_filename)
25
+
26
+ s3_client.put_object(
27
+ bucket: bucket,
28
+ key: storage_key,
29
+ body: content,
30
+ content_type: content_type
31
+ )
32
+
33
+ Upload.new(
34
+ storage_key: storage_key,
35
+ original_filename: original_filename,
36
+ content_type: content_type,
37
+ byte_size: content.bytesize,
38
+ checksum: Digest::SHA256.hexdigest(content)
39
+ )
40
+ end
41
+
42
+ def delete(storage_key)
43
+ return if storage_key.blank?
44
+
45
+ s3_client.delete_object(bucket: bucket, key: storage_key)
46
+ end
47
+
48
+ def public_url(storage_key)
49
+ base_url = @configuration.s3_public_base_url.to_s.delete_suffix("/")
50
+ return "#{base_url}/#{storage_key}" if base_url.present?
51
+
52
+ raise Error, "Journeybook S3 bucket and region are required." if bucket.blank? || region.blank?
53
+
54
+ "https://#{bucket}.s3.#{region}.amazonaws.com/#{storage_key}"
55
+ end
56
+
57
+ private
58
+
59
+ def s3_client
60
+ @client ||= begin
61
+ raise Error, "Journeybook S3 bucket is missing. Set config.s3_bucket." if bucket.blank?
62
+ raise Error, "Journeybook S3 region is missing. Set config.s3_region." if region.blank?
63
+ raise Error, "Journeybook S3 access key is missing. Set config.s3_access_key_id." if @configuration.s3_access_key_id.blank?
64
+ raise Error, "Journeybook S3 secret key is missing. Set config.s3_secret_access_key." if @configuration.s3_secret_access_key.blank?
65
+
66
+ Aws::S3::Client.new(
67
+ region: region,
68
+ access_key_id: @configuration.s3_access_key_id,
69
+ secret_access_key: @configuration.s3_secret_access_key
70
+ )
71
+ end
72
+ end
73
+
74
+ def build_storage_key(original_filename)
75
+ filename = sanitize_filename(original_filename.presence || "image")
76
+ [ prefix, "media", SecureRandom.uuid, filename ].reject(&:blank?).join("/")
77
+ end
78
+
79
+ def sanitize_filename(filename)
80
+ filename.to_s.downcase.gsub(/[^a-z0-9.\-_]+/, "-").delete_prefix("-").delete_suffix("-").presence || "image"
81
+ end
82
+
83
+ def prefix
84
+ @configuration.s3_prefix.to_s.strip.delete_prefix("/").delete_suffix("/")
85
+ end
86
+
87
+ def bucket
88
+ @configuration.s3_bucket
89
+ end
90
+
91
+ def region
92
+ @configuration.s3_region
93
+ end
94
+ end
95
+ end
96
+ end