plum-cms 0.1.0

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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +252 -0
  5. data/app/assets/builds/tailwind.css +2 -0
  6. data/app/assets/images/plum-mark.svg +7 -0
  7. data/app/assets/images/table-needs-logo.svg +83 -0
  8. data/app/assets/javascripts/plum/application.js +15 -0
  9. data/app/assets/stylesheets/application.css +10 -0
  10. data/app/assets/stylesheets/plum/control_panel.css +2 -0
  11. data/app/assets/tailwind/application.css +47 -0
  12. data/app/controllers/plum/application_controller.rb +34 -0
  13. data/app/controllers/plum/cp/assets_controller.rb +84 -0
  14. data/app/controllers/plum/cp/base_controller.rb +45 -0
  15. data/app/controllers/plum/cp/content_types_controller.rb +59 -0
  16. data/app/controllers/plum/cp/dashboard_controller.rb +10 -0
  17. data/app/controllers/plum/cp/entries_controller.rb +240 -0
  18. data/app/controllers/plum/cp/form_definitions_controller.rb +105 -0
  19. data/app/controllers/plum/cp/form_submissions_controller.rb +26 -0
  20. data/app/controllers/plum/cp/globals_controller.rb +84 -0
  21. data/app/controllers/plum/cp/nav_items_controller.rb +68 -0
  22. data/app/controllers/plum/cp/nav_menus_controller.rb +56 -0
  23. data/app/controllers/plum/cp/site_settings_controller.rb +87 -0
  24. data/app/controllers/plum/cp/taxonomies_controller.rb +56 -0
  25. data/app/controllers/plum/cp/terms_controller.rb +55 -0
  26. data/app/controllers/plum/cp/theme_previews_controller.rb +27 -0
  27. data/app/controllers/plum/cp/themes_controller.rb +98 -0
  28. data/app/controllers/plum/form_submissions_controller.rb +44 -0
  29. data/app/controllers/plum/pages_controller.rb +161 -0
  30. data/app/controllers/plum/sessions_controller.rb +25 -0
  31. data/app/controllers/plum/theme_assets_controller.rb +14 -0
  32. data/app/helpers/application_helper.rb +2 -0
  33. data/app/helpers/markdown_helper.rb +21 -0
  34. data/app/helpers/plum/cp/assets_helper.rb +21 -0
  35. data/app/helpers/plum/cp/theme_settings_helper.rb +26 -0
  36. data/app/javascript/application.js +18 -0
  37. data/app/javascript/controllers/application.js +9 -0
  38. data/app/javascript/controllers/index.js +4 -0
  39. data/app/javascript/controllers/plum/blocks_editor_controller.js +275 -0
  40. data/app/javascript/controllers/plum/blueprint_controller.js +120 -0
  41. data/app/javascript/controllers/plum/form_fields_controller.js +84 -0
  42. data/app/javascript/controllers/plum/image_picker_controller.js +143 -0
  43. data/app/javascript/controllers/plum/theme_settings_controller.js +17 -0
  44. data/app/models/plum/application_record.rb +5 -0
  45. data/app/models/plum/asset.rb +69 -0
  46. data/app/models/plum/content_type.rb +35 -0
  47. data/app/models/plum/entry.rb +67 -0
  48. data/app/models/plum/entry_term.rb +8 -0
  49. data/app/models/plum/form_definition.rb +68 -0
  50. data/app/models/plum/form_submission.rb +76 -0
  51. data/app/models/plum/global.rb +24 -0
  52. data/app/models/plum/nav_item.rb +58 -0
  53. data/app/models/plum/nav_menu.rb +24 -0
  54. data/app/models/plum/site.rb +112 -0
  55. data/app/models/plum/site_scoped.rb +17 -0
  56. data/app/models/plum/site_setting.rb +35 -0
  57. data/app/models/plum/taxonomy.rb +24 -0
  58. data/app/models/plum/term.rb +23 -0
  59. data/app/models/plum/user.rb +16 -0
  60. data/app/services/plum/base_blocks.rb +161 -0
  61. data/app/services/plum/block_editor_config.rb +65 -0
  62. data/app/services/plum/block_library.rb +31 -0
  63. data/app/services/plum/builder_renderer.rb +63 -0
  64. data/app/services/plum/field_expander.rb +78 -0
  65. data/app/services/plum/form_renderer.rb +110 -0
  66. data/app/services/plum/liquid_context.rb +335 -0
  67. data/app/services/plum/liquid_filters.rb +58 -0
  68. data/app/services/plum/liquid_renderer.rb +49 -0
  69. data/app/services/plum/theme.rb +160 -0
  70. data/app/services/plum/theme_asset_path.rb +31 -0
  71. data/app/services/plum/theme_package_installer.rb +287 -0
  72. data/app/services/plum/theme_registry.rb +63 -0
  73. data/app/services/plum/theme_resolver.rb +55 -0
  74. data/app/services/plum/theme_settings_params.rb +54 -0
  75. data/app/themes/bagel-boy/assets/screenshot.svg +92 -0
  76. data/app/themes/bagel-boy/assets/theme.css +184 -0
  77. data/app/themes/bagel-boy/blocks/hours.liquid +4 -0
  78. data/app/themes/bagel-boy/blocks/menu_item.liquid +7 -0
  79. data/app/themes/bagel-boy/layouts/base.liquid +64 -0
  80. data/app/themes/bagel-boy/templates/collections/posts.liquid +32 -0
  81. data/app/themes/bagel-boy/templates/entries/_default.liquid +13 -0
  82. data/app/themes/bagel-boy/templates/entries/landing.liquid +4 -0
  83. data/app/themes/bagel-boy/templates/entries/pages.liquid +18 -0
  84. data/app/themes/bagel-boy/templates/entries/posts.liquid +8 -0
  85. data/app/themes/bagel-boy/templates/index.liquid +10 -0
  86. data/app/themes/bagel-boy/templates/search.liquid +28 -0
  87. data/app/themes/bagel-boy/theme.yml +47 -0
  88. data/app/themes/bagel-shop/assets/screenshot.svg +28 -0
  89. data/app/themes/bagel-shop/assets/theme.css +7 -0
  90. data/app/themes/bagel-shop/layouts/base.liquid +507 -0
  91. data/app/themes/bagel-shop/templates/entries/_default.liquid +15 -0
  92. data/app/themes/bagel-shop/templates/entries/pages.liquid +12 -0
  93. data/app/themes/bagel-shop/templates/entries/posts.liquid +19 -0
  94. data/app/themes/bagel-shop/templates/index.liquid +55 -0
  95. data/app/themes/bagel-shop/theme.yml +30 -0
  96. data/app/themes/default/assets/screenshot.svg +24 -0
  97. data/app/themes/default/assets/theme.css +89 -0
  98. data/app/themes/default/layouts/base.liquid +117 -0
  99. data/app/themes/default/templates/collections/_default.liquid +16 -0
  100. data/app/themes/default/templates/collections/posts.liquid +27 -0
  101. data/app/themes/default/templates/entries/_default.liquid +26 -0
  102. data/app/themes/default/templates/entries/landing.liquid +5 -0
  103. data/app/themes/default/templates/entries/pages.liquid +17 -0
  104. data/app/themes/default/templates/entries/posts.liquid +20 -0
  105. data/app/themes/default/templates/index.liquid +22 -0
  106. data/app/themes/default/templates/search.liquid +28 -0
  107. data/app/themes/default/templates/taxonomies/_default.liquid +25 -0
  108. data/app/themes/default/templates/taxonomies/_default_index.liquid +14 -0
  109. data/app/themes/default/theme.yml +23 -0
  110. data/app/views/layouts/plum/cp.html.erb +138 -0
  111. data/app/views/layouts/plum/session.html.erb +43 -0
  112. data/app/views/plum/cp/assets/_form.html.erb +44 -0
  113. data/app/views/plum/cp/assets/edit.html.erb +14 -0
  114. data/app/views/plum/cp/assets/index.html.erb +52 -0
  115. data/app/views/plum/cp/assets/new.html.erb +9 -0
  116. data/app/views/plum/cp/content_types/_form.html.erb +113 -0
  117. data/app/views/plum/cp/content_types/edit.html.erb +8 -0
  118. data/app/views/plum/cp/content_types/index.html.erb +48 -0
  119. data/app/views/plum/cp/content_types/new.html.erb +5 -0
  120. data/app/views/plum/cp/content_types/show.html.erb +59 -0
  121. data/app/views/plum/cp/dashboard/show.html.erb +50 -0
  122. data/app/views/plum/cp/entries/_form.html.erb +196 -0
  123. data/app/views/plum/cp/entries/edit.html.erb +9 -0
  124. data/app/views/plum/cp/entries/index.html.erb +51 -0
  125. data/app/views/plum/cp/entries/new.html.erb +5 -0
  126. data/app/views/plum/cp/form_definitions/_form.html.erb +90 -0
  127. data/app/views/plum/cp/form_definitions/edit.html.erb +11 -0
  128. data/app/views/plum/cp/form_definitions/index.html.erb +43 -0
  129. data/app/views/plum/cp/form_definitions/new.html.erb +5 -0
  130. data/app/views/plum/cp/form_definitions/show.html.erb +70 -0
  131. data/app/views/plum/cp/form_submissions/show.html.erb +20 -0
  132. data/app/views/plum/cp/globals/_form.html.erb +44 -0
  133. data/app/views/plum/cp/globals/edit.html.erb +11 -0
  134. data/app/views/plum/cp/globals/index.html.erb +41 -0
  135. data/app/views/plum/cp/globals/new.html.erb +5 -0
  136. data/app/views/plum/cp/globals/show.html.erb +14 -0
  137. data/app/views/plum/cp/nav_items/_form.html.erb +56 -0
  138. data/app/views/plum/cp/nav_items/edit.html.erb +11 -0
  139. data/app/views/plum/cp/nav_items/new.html.erb +6 -0
  140. data/app/views/plum/cp/nav_menus/_form.html.erb +33 -0
  141. data/app/views/plum/cp/nav_menus/_nav_item.html.erb +24 -0
  142. data/app/views/plum/cp/nav_menus/edit.html.erb +11 -0
  143. data/app/views/plum/cp/nav_menus/index.html.erb +41 -0
  144. data/app/views/plum/cp/nav_menus/new.html.erb +5 -0
  145. data/app/views/plum/cp/nav_menus/show.html.erb +24 -0
  146. data/app/views/plum/cp/shared/_icon.html.erb +39 -0
  147. data/app/views/plum/cp/shared/_image_picker.html.erb +38 -0
  148. data/app/views/plum/cp/site_settings/edit.html.erb +145 -0
  149. data/app/views/plum/cp/site_settings/show.html.erb +62 -0
  150. data/app/views/plum/cp/taxonomies/_form.html.erb +38 -0
  151. data/app/views/plum/cp/taxonomies/edit.html.erb +2 -0
  152. data/app/views/plum/cp/taxonomies/index.html.erb +38 -0
  153. data/app/views/plum/cp/taxonomies/new.html.erb +2 -0
  154. data/app/views/plum/cp/taxonomies/show.html.erb +46 -0
  155. data/app/views/plum/cp/terms/_form.html.erb +30 -0
  156. data/app/views/plum/cp/terms/edit.html.erb +2 -0
  157. data/app/views/plum/cp/terms/new.html.erb +2 -0
  158. data/app/views/plum/cp/themes/index.html.erb +160 -0
  159. data/app/views/plum/form_mailer/submission_notification.html.erb +13 -0
  160. data/app/views/plum/form_mailer/submission_notification.text.erb +7 -0
  161. data/app/views/plum/sessions/new.html.erb +20 -0
  162. data/compat/plum-cms.rb +1 -0
  163. data/config/locales/en.yml +31 -0
  164. data/config/plum_importmap.rb +4 -0
  165. data/config/plum_routes.rb +34 -0
  166. data/db/engine_migrate/20260101000001_create_plum_tables.rb +154 -0
  167. data/lib/generators/plum/install/install_generator.rb +54 -0
  168. data/lib/generators/plum/install/templates/plum_initializer.rb +37 -0
  169. data/lib/plum/configuration.rb +88 -0
  170. data/lib/plum/content_source.rb +45 -0
  171. data/lib/plum/content_source_context.rb +30 -0
  172. data/lib/plum/content_source_registry.rb +120 -0
  173. data/lib/plum/engine.rb +30 -0
  174. data/lib/plum/liquid_tags/form_tag.rb +17 -0
  175. data/lib/plum/version.rb +7 -0
  176. data/lib/plum.rb +11 -0
  177. data/vendor/javascript/lexxy.js +11859 -0
  178. metadata +405 -0
@@ -0,0 +1,110 @@
1
+ module Plum
2
+ class FormRenderer
3
+ FIELD_CLASSES = "plum-form-field".freeze
4
+
5
+ def initialize(form)
6
+ @form = form
7
+ end
8
+
9
+ def render
10
+ return "" if form.blank?
11
+
12
+ <<~HTML
13
+ <form class="plum-form" method="post" action="#{h(form['action'])}" data-plum-form-handle="#{h(form['handle'])}">
14
+ #{hidden_inputs}
15
+ #{fields_html}
16
+ <button class="plum-form-submit" type="submit">Submit</button>
17
+ </form>
18
+ HTML
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :form
24
+
25
+ def hidden_inputs
26
+ [
27
+ hidden_input(form["csrf_param"], form["csrf_token"]),
28
+ hidden_input("return_to", form["return_to"])
29
+ ].compact.join("\n ")
30
+ end
31
+
32
+ def fields_html
33
+ Array(form["fields"]).map { |field| field_html(field) }.join("\n ")
34
+ end
35
+
36
+ def field_html(field)
37
+ handle = field["handle"].to_s
38
+ type = field["type"].to_s
39
+ label = field_label(field)
40
+ input_id = "plum_form_#{form['handle']}_#{handle}"
41
+
42
+ <<~HTML.strip
43
+ <div class="#{FIELD_CLASSES}">
44
+ <label for="#{h(input_id)}">#{h(label)}</label>
45
+ #{input_html(field, input_id)}
46
+ </div>
47
+ HTML
48
+ end
49
+
50
+ def input_html(field, input_id)
51
+ case field["type"]
52
+ when "textarea"
53
+ textarea_html(field, input_id)
54
+ when "select"
55
+ select_html(field, input_id)
56
+ when "checkbox"
57
+ checkbox_html(field, input_id)
58
+ else
59
+ text_input_html(field, input_id)
60
+ end
61
+ end
62
+
63
+ def text_input_html(field, input_id)
64
+ type = field["type"] == "email" ? "email" : "text"
65
+
66
+ %(<input type="#{type}" id="#{h(input_id)}" name="#{field_name(field)}"#{required_attribute(field)}>)
67
+ end
68
+
69
+ def textarea_html(field, input_id)
70
+ %(<textarea id="#{h(input_id)}" name="#{field_name(field)}"#{required_attribute(field)}></textarea>)
71
+ end
72
+
73
+ def select_html(field, input_id)
74
+ options = Array(field["options"]).map do |option|
75
+ %(<option value="#{h(option)}">#{h(option)}</option>)
76
+ end.join
77
+
78
+ %(<select id="#{h(input_id)}" name="#{field_name(field)}"#{required_attribute(field)}><option value=""></option>#{options}</select>)
79
+ end
80
+
81
+ def checkbox_html(field, input_id)
82
+ <<~HTML.squish
83
+ <input type="hidden" name="#{field_name(field)}" value="0">
84
+ <input type="checkbox" id="#{h(input_id)}" name="#{field_name(field)}" value="1"#{required_attribute(field)}>
85
+ HTML
86
+ end
87
+
88
+ def hidden_input(name, value)
89
+ return if name.blank? || value.blank?
90
+
91
+ %(<input type="hidden" name="#{h(name)}" value="#{h(value)}">)
92
+ end
93
+
94
+ def field_name(field)
95
+ h("form_submission[data][#{field['handle']}]")
96
+ end
97
+
98
+ def field_label(field)
99
+ field["label"].presence || field["handle"].to_s.humanize
100
+ end
101
+
102
+ def required_attribute(field)
103
+ ActiveModel::Type::Boolean.new.cast(field["required"]) ? " required" : ""
104
+ end
105
+
106
+ def h(value)
107
+ ERB::Util.html_escape(value.to_s)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,335 @@
1
+ require "nokogiri"
2
+
3
+ module Plum
4
+ class LiquidContext
5
+ MAX_RELATIONSHIP_DEPTH = 2
6
+
7
+ def initialize(controller:, site:, entry: nil, theme_name: nil, theme_settings: nil)
8
+ @controller = controller
9
+ @site = site
10
+ @entry = entry
11
+ @theme_name = theme_name
12
+ @theme_settings = theme_settings
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ "site" => site_context,
18
+ "entry" => entry ? entry_context(entry, expand_blocks: true) : nil,
19
+ "entries" => entries_context,
20
+ "taxonomies" => taxonomies_context,
21
+ "forms" => forms_context,
22
+ "globals" => globals_context,
23
+ "nav" => nav_context
24
+ }.compact.merge(Plum.content_sources_for(controller, site: site))
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :controller, :site, :entry, :theme_name, :theme_settings
30
+
31
+ def site_context
32
+ @site_context ||= begin
33
+ settings = SiteSetting.instance(site)
34
+ {
35
+ "name" => settings.name,
36
+ "tagline" => settings.tagline,
37
+ "seo_title" => settings.seo_title,
38
+ "seo_description" => settings.seo_description,
39
+ "primary_color" => settings.primary_color,
40
+ "support_email" => settings.support_email,
41
+ "logo" => image_asset_context(settings.logo),
42
+ "theme_name" => theme_name.presence || settings.theme_name,
43
+ "theme_settings" => theme_settings || site.theme_settings,
44
+ "theme_asset_base_url" => theme_asset_base_url,
45
+ "custom_css" => site.custom_css,
46
+ "url" => public_root_path,
47
+ "meta_title" => settings.seo_title,
48
+ "meta_description" => settings.seo_description,
49
+ "powered_by_name" => Plum.configuration.powered_by_name,
50
+ "powered_by_url" => Plum.configuration.powered_by_url
51
+ }
52
+ end
53
+ end
54
+
55
+ def entry_context(entry, relationship_depth: MAX_RELATIONSHIP_DEPTH, expand_blocks: false)
56
+ ctx = {
57
+ "title" => entry.title,
58
+ "slug" => entry.slug,
59
+ "published_at" => entry.published_at,
60
+ "url" => public_entry_path(entry),
61
+ "data" => entry_data_context(entry, relationship_depth: relationship_depth, expand_blocks: expand_blocks)
62
+ }
63
+ if entry.association(:terms).loaded? && entry.terms.any?
64
+ ctx["terms"] = entry.terms.group_by { |t| t.taxonomy.handle }.transform_values do |terms|
65
+ terms.map { |t| { "name" => t.name, "slug" => t.slug, "url" => "/#{t.taxonomy.slug}/#{t.slug}" } }
66
+ end
67
+ end
68
+ ctx.merge!(adjacent_entry_context(entry)) if expand_blocks
69
+ ctx
70
+ end
71
+
72
+ def entry_data_context(entry, relationship_depth:, expand_blocks: false)
73
+ data = (entry.data || {}).deep_dup
74
+
75
+ entry.content_type.fields.each do |field|
76
+ handle = field["handle"].to_s
77
+ next if handle.blank?
78
+
79
+ case field["type"]
80
+ when "image"
81
+ data[handle] = image_asset_context(data[handle])
82
+ when "rich_text"
83
+ data[handle] = resolve_rich_text(data[handle])
84
+ when "relationship"
85
+ data[handle] = relationship_entry_context(data[handle], relationship_depth: relationship_depth)
86
+ when "blocks"
87
+ data[handle] = expand_blocks ? blocks_html(data[handle]) : "".html_safe
88
+ end
89
+ end
90
+
91
+ data
92
+ end
93
+
94
+ def taxonomies_context
95
+ @taxonomies_context ||= Taxonomy.for_site(site).includes(:terms).each_with_object({}) do |taxonomy, hash|
96
+ hash[taxonomy.handle] = {
97
+ "name" => taxonomy.name,
98
+ "handle" => taxonomy.handle,
99
+ "slug" => taxonomy.slug,
100
+ "terms" => taxonomy.terms.sort_by(&:position).map { |term|
101
+ { "name" => term.name, "slug" => term.slug, "url" => "/#{taxonomy.slug}/#{term.slug}" }
102
+ }
103
+ }
104
+ end
105
+ end
106
+
107
+ def entries_context
108
+ @entries_context ||= begin
109
+ ContentType.for_site(site).each_with_object({}) do |content_type, hash|
110
+ hash[content_type.handle] = Array(live_entries_by_type[content_type.id]).map { |item| entry_context(item) }
111
+ end
112
+ end
113
+ end
114
+
115
+ def globals_context
116
+ @globals_context ||= Global.for_site(site).each_with_object({}) do |global, hash|
117
+ hash[global.handle] = global.data
118
+ end
119
+ end
120
+
121
+ def forms_context
122
+ @forms_context ||= FormDefinition.for_site(site).each_with_object({}) do |form, hash|
123
+ hash[form.handle] = {
124
+ "name" => form.name,
125
+ "handle" => form.handle,
126
+ "fields" => form.form_fields,
127
+ "action" => public_form_path(form),
128
+ "csrf_token" => form_authenticity_token,
129
+ "csrf_param" => "authenticity_token",
130
+ "return_to" => controller.request.fullpath
131
+ }
132
+ end
133
+ end
134
+
135
+ def nav_context
136
+ @nav_context ||= begin
137
+ items_by_menu = NavItem.for_site(site)
138
+ .includes(entry: [ :content_type, { terms: :taxonomy } ])
139
+ .group_by(&:nav_menu_id)
140
+
141
+ NavMenu.for_site(site).each_with_object({}) do |menu, hash|
142
+ menu_items = Array(items_by_menu[menu.id])
143
+ items_by_parent = menu_items.group_by(&:parent_id)
144
+ hash[menu.handle] = {
145
+ "name" => menu.name,
146
+ "handle" => menu.handle,
147
+ "items" => Array(items_by_parent[nil]).map { |item| nav_item_context(item, items_by_parent) }
148
+ }
149
+ end
150
+ end
151
+ end
152
+
153
+ def nav_item_context(item, items_by_parent)
154
+ {
155
+ "label" => item.label,
156
+ "url" => nav_item_url(item),
157
+ "entry" => item.entry ? entry_context(item.entry) : nil,
158
+ "children" => Array(items_by_parent[item.id]).map { |child| nav_item_context(child, items_by_parent) }
159
+ }
160
+ end
161
+
162
+ def nav_item_url(item)
163
+ item.entry ? public_entry_path(item.entry) : item.url
164
+ end
165
+
166
+ def image_asset_context(value)
167
+ return if value.blank?
168
+
169
+ asset_cache[value.to_i]&.to_liquid
170
+ end
171
+
172
+ def relationship_entry_context(value, relationship_depth:)
173
+ return if value.blank? || relationship_depth <= 0
174
+
175
+ related_entry = relationship_entry_cache[value.to_i]
176
+ return unless related_entry
177
+
178
+ entry_context(related_entry, relationship_depth: relationship_depth - 1)
179
+ end
180
+
181
+ LEXXY_CSS_VARS = {
182
+ "var(--highlight-1)" => "rgb(136, 118, 38)",
183
+ "var(--highlight-2)" => "rgb(185, 94, 6)",
184
+ "var(--highlight-3)" => "rgb(207, 0, 0)",
185
+ "var(--highlight-4)" => "rgb(216, 28, 170)",
186
+ "var(--highlight-5)" => "rgb(144, 19, 254)",
187
+ "var(--highlight-6)" => "rgb(5, 98, 185)",
188
+ "var(--highlight-7)" => "rgb(17, 138, 15)",
189
+ "var(--highlight-8)" => "rgb(148, 82, 22)",
190
+ "var(--highlight-9)" => "rgb(102, 102, 102)",
191
+ "var(--highlight-bg-1)" => "rgba(229, 223, 6, 0.3)",
192
+ "var(--highlight-bg-2)" => "rgba(255, 185, 87, 0.3)",
193
+ "var(--highlight-bg-3)" => "rgba(255, 118, 118, 0.3)",
194
+ "var(--highlight-bg-4)" => "rgba(248, 137, 216, 0.3)",
195
+ "var(--highlight-bg-5)" => "rgba(190, 165, 255, 0.3)",
196
+ "var(--highlight-bg-6)" => "rgba(124, 192, 252, 0.3)",
197
+ "var(--highlight-bg-7)" => "rgba(140, 255, 129, 0.3)",
198
+ "var(--highlight-bg-8)" => "rgba(221, 170, 123, 0.3)",
199
+ "var(--highlight-bg-9)" => "rgba(200, 200, 200, 0.3)"
200
+ }.freeze
201
+
202
+ def resolve_rich_text(html)
203
+ return html if html.blank?
204
+
205
+ result = html.to_s
206
+
207
+ result.gsub!(/<action-text-attachment[^>]*>.*?<\/action-text-attachment>/m) do |tag|
208
+ url = tag[/url="([^"]*)"/, 1]
209
+ alt = tag[/alt="([^"]*)"/, 1] || ""
210
+ width = tag[/width="([^"]*)"/, 1]
211
+ height = tag[/height="([^"]*)"/, 1]
212
+
213
+ attrs = %(src="#{url}" alt="#{alt}")
214
+ attrs += %( width="#{width}") if width
215
+ attrs += %( height="#{height}") if height
216
+ %(<img #{attrs}>)
217
+ end
218
+
219
+ LEXXY_CSS_VARS.each { |var, val| result.gsub!(var, val) }
220
+
221
+ result.gsub!(/<mark\b/, "<span")
222
+ result.gsub!(%r{</mark>}, "</span>")
223
+
224
+ add_heading_ids(result)
225
+ end
226
+
227
+ def add_heading_ids(html)
228
+ fragment = Nokogiri::HTML.fragment(html)
229
+ used_ids = Hash.new(0)
230
+
231
+ fragment.css("h2, h3").each do |heading|
232
+ base_id = heading["id"].presence || heading.text.parameterize.presence || "section"
233
+ used_ids[base_id] += 1
234
+ heading["id"] = used_ids[base_id] == 1 ? base_id : "#{base_id}-#{used_ids[base_id]}"
235
+ end
236
+
237
+ fragment.to_html
238
+ end
239
+
240
+ def adjacent_entry_context(current_entry)
241
+ siblings = Array(live_entries_by_type[current_entry.content_type_id]).sort_by do |item|
242
+ [ item.data&.fetch("position", nil).to_i, item.title.to_s ]
243
+ end
244
+ index = siblings.index { |item| item.id == current_entry.id }
245
+ return {} unless index
246
+
247
+ {
248
+ "previous" => index.positive? ? navigation_entry_context(siblings[index - 1]) : nil,
249
+ "next" => index < siblings.length - 1 ? navigation_entry_context(siblings[index + 1]) : nil
250
+ }
251
+ end
252
+
253
+ def navigation_entry_context(item)
254
+ { "title" => item.title, "slug" => item.slug, "url" => public_entry_path(item) }
255
+ end
256
+
257
+ # Renders a blocks field value (an array of block instances) into HTML using
258
+ # the active theme's Liquid block partials. The shared site context (site,
259
+ # globals, nav, forms, registered content sources) is exposed inside each
260
+ # block, but the global `entries` collection is intentionally excluded to
261
+ # avoid an infinite render loop, since every entry's blocks are expanded.
262
+ def blocks_html(value)
263
+ return "".html_safe unless value.is_a?(Array) && value.any?
264
+
265
+ BuilderRenderer.new(
266
+ blocks: value,
267
+ base_assigns: blocks_base_assigns,
268
+ site: site,
269
+ theme: blocks_theme,
270
+ registers: blocks_registers
271
+ ).render
272
+ end
273
+
274
+ def blocks_theme
275
+ @blocks_theme ||= ThemeRegistry.new.fetch(blocks_theme_name)
276
+ end
277
+
278
+ def blocks_theme_name
279
+ theme_name.presence || SiteSetting.instance(site).theme_name
280
+ end
281
+
282
+ def blocks_base_assigns
283
+ @blocks_base_assigns ||= {
284
+ "site" => site_context,
285
+ "globals" => globals_context,
286
+ "nav" => nav_context,
287
+ "forms" => forms_context
288
+ }.merge(Plum.content_sources_for(controller, site: site))
289
+ end
290
+
291
+ def blocks_registers
292
+ base_url = theme_asset_base_url
293
+ {
294
+ theme_asset_url_builder: ->(path) { ThemeAssetPath.url(base_url: base_url, path: path) },
295
+ form_renderer: ->(handle) { FormRenderer.new(forms_context[handle]).render }
296
+ }
297
+ end
298
+
299
+ def asset_cache
300
+ @asset_cache ||= Asset.for_site(site).with_attached_file.index_by(&:id)
301
+ end
302
+
303
+ def relationship_entry_cache
304
+ @relationship_entry_cache ||= Entry.for_site(site).live.includes(:content_type).index_by(&:id)
305
+ end
306
+
307
+ def live_entries_by_type
308
+ @live_entries_by_type ||= Entry.for_site(site).live
309
+ .includes(:content_type, terms: :taxonomy)
310
+ .order(published_at: :desc, created_at: :desc)
311
+ .group_by(&:content_type_id)
312
+ end
313
+
314
+ def public_root_path
315
+ controller.request.script_name.presence || "/"
316
+ end
317
+
318
+ def theme_asset_base_url
319
+ "#{controller.request.script_name.to_s.chomp("/")}/theme_assets/#{theme_name.presence || site.theme_name}"
320
+ end
321
+
322
+ def public_entry_path(entry)
323
+ path = [ entry.content_type.route_prefix, entry.slug ].compact.join("/")
324
+ "#{controller.request.script_name.to_s.chomp("/")}/#{path}"
325
+ end
326
+
327
+ def public_form_path(form)
328
+ "#{controller.request.script_name.to_s.chomp("/")}/forms/#{form.handle}"
329
+ end
330
+
331
+ def form_authenticity_token
332
+ controller.send(:form_authenticity_token) if controller.respond_to?(:form_authenticity_token, true)
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,58 @@
1
+ require "redcarpet"
2
+ require "nokogiri"
3
+
4
+ module Plum
5
+ module LiquidFilters
6
+ def markdown(input)
7
+ return "" if input.blank?
8
+
9
+ text = input.to_s
10
+ return text if text.match?(/<[a-z][\s\S]*>/i)
11
+
12
+ renderer = Redcarpet::Render::HTML.new(
13
+ hard_wrap: true,
14
+ link_attributes: { target: "_blank", rel: "noopener" }
15
+ )
16
+
17
+ md = Redcarpet::Markdown.new(renderer,
18
+ autolink: true,
19
+ tables: true,
20
+ fenced_code_blocks: true,
21
+ strikethrough: true,
22
+ superscript: true,
23
+ no_intra_emphasis: true
24
+ )
25
+
26
+ md.render(text)
27
+ end
28
+
29
+ def image_url(input, size = nil)
30
+ return "" if input.blank?
31
+ return input["url"].to_s unless size && input.is_a?(Hash)
32
+
33
+ input[size].presence || input["url"].to_s
34
+ end
35
+
36
+ def theme_asset_url(input)
37
+ builder = @context.registers[:theme_asset_url_builder]
38
+ return "" unless builder
39
+
40
+ builder.call(input)
41
+ end
42
+
43
+ def table_of_contents(input)
44
+ return "" if input.blank?
45
+
46
+ headings = Nokogiri::HTML.fragment(input.to_s).css("h2, h3")
47
+ return "" if headings.empty?
48
+
49
+ items = headings.map do |heading|
50
+ id = heading["id"].presence || heading.text.parameterize
51
+ label = ERB::Util.html_escape(heading.text)
52
+ %(<li class="toc-#{heading.name}"><a href="##{id}">#{label}</a></li>)
53
+ end
54
+
55
+ %(<nav class="table-of-contents" aria-label="On this page"><p>On this page</p><ol>#{items.join}</ol></nav>)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,49 @@
1
+ require "liquid"
2
+ require_relative "liquid_filters"
3
+ require "plum/liquid_tags/form_tag"
4
+
5
+ module Plum
6
+ class LiquidRenderer
7
+ Liquid::Environment.default.register_tag("form", LiquidTags::FormTag)
8
+
9
+ class << self
10
+ def render_template(template_name, context = {})
11
+ theme = current_theme(context)
12
+ resolver = ThemeResolver.new
13
+ template_path = resolver.find_template(theme, template_name)
14
+
15
+ raise "Template not found: #{template_name}" unless template_path
16
+
17
+ template_content = File.read(template_path)
18
+ template = Liquid::Template.parse(template_content)
19
+ registers = render_registers(context)
20
+
21
+ rendered_content = template.render(context, filters: [ LiquidFilters ], registers: registers)
22
+
23
+ layout_path = resolver.find_layout(theme)
24
+ if layout_path
25
+ layout_content = File.read(layout_path)
26
+ layout = Liquid::Template.parse(layout_content)
27
+ layout.render(context.merge("content" => rendered_content), filters: [ LiquidFilters ], registers: registers)
28
+ else
29
+ rendered_content
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def current_theme(context)
36
+ context.dig("site", "theme_name").presence || "default"
37
+ end
38
+
39
+ def render_registers(context)
40
+ base_url = context.dig("site", "theme_asset_base_url").presence || "/theme_assets/#{current_theme(context)}"
41
+
42
+ {
43
+ theme_asset_url_builder: ->(path) { ThemeAssetPath.url(base_url: base_url, path: path) },
44
+ form_renderer: ->(handle) { FormRenderer.new(context.dig("forms", handle)).render }
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end