rails_site_engine 0.6.2

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 (110) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +137 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/rails_site_engine/application.css +15 -0
  6. data/app/assets/stylesheets/rails_site_engine/engine.css +2 -0
  7. data/app/assets/stylesheets/rails_site_engine/engine.tailwind.css +5 -0
  8. data/app/controllers/rails_site_engine/application_controller.rb +27 -0
  9. data/app/controllers/rails_site_engine/contacts_controller.rb +35 -0
  10. data/app/controllers/rails_site_engine/meta_controller.rb +20 -0
  11. data/app/controllers/rails_site_engine/pages_controller.rb +18 -0
  12. data/app/helpers/rails_site_engine/application_helper.rb +266 -0
  13. data/app/javascript/controllers/rails_site_engine/color_mode_controller.js +83 -0
  14. data/app/javascript/controllers/rails_site_engine/form_submit_controller.js +22 -0
  15. data/app/javascript/controllers/rails_site_engine/mobile_nav_controller.js +45 -0
  16. data/app/javascript/rails_site_engine/application.js +8 -0
  17. data/app/jobs/rails_site_engine/application_job.rb +4 -0
  18. data/app/mailers/rails_site_engine/application_mailer.rb +6 -0
  19. data/app/mailers/rails_site_engine/contact_mailer.rb +25 -0
  20. data/app/models/rails_site_engine/application_record.rb +5 -0
  21. data/app/models/rails_site_engine/contact_message.rb +16 -0
  22. data/app/views/layouts/mailer.html.erb +13 -0
  23. data/app/views/layouts/mailer.text.erb +1 -0
  24. data/app/views/layouts/rails_site_engine/application.html.erb +54 -0
  25. data/app/views/rails_site_engine/contact_mailer/notify.text.erb +9 -0
  26. data/app/views/rails_site_engine/contacts/new.html.erb +1 -0
  27. data/app/views/rails_site_engine/pages/show.html.erb +15 -0
  28. data/app/views/rails_site_engine/sections/_contact_form.html.erb +112 -0
  29. data/app/views/rails_site_engine/sections/_cta_band.html.erb +27 -0
  30. data/app/views/rails_site_engine/sections/_faq.html.erb +29 -0
  31. data/app/views/rails_site_engine/sections/_feature_cards.html.erb +30 -0
  32. data/app/views/rails_site_engine/sections/_hero.html.erb +39 -0
  33. data/app/views/rails_site_engine/sections/_pricing_cards.html.erb +53 -0
  34. data/app/views/rails_site_engine/sections/_process_steps.html.erb +35 -0
  35. data/app/views/rails_site_engine/sections/_project_cards.html.erb +48 -0
  36. data/app/views/rails_site_engine/sections/_proof_strip.html.erb +16 -0
  37. data/app/views/rails_site_engine/sections/_rich_text.html.erb +15 -0
  38. data/app/views/rails_site_engine/sections/_stats.html.erb +34 -0
  39. data/app/views/rails_site_engine/sections/_testimonials.html.erb +40 -0
  40. data/app/views/rails_site_engine/sections/_unknown.html.erb +6 -0
  41. data/app/views/rails_site_engine/shared/_flash.html.erb +18 -0
  42. data/app/views/rails_site_engine/shared/_footer.html.erb +41 -0
  43. data/app/views/rails_site_engine/shared/_navbar.html.erb +118 -0
  44. data/config/importmap.rb +5 -0
  45. data/config/locales/en.yml +27 -0
  46. data/config/routes.rb +9 -0
  47. data/config/site_profiles.yml +23 -0
  48. data/lib/generators/rails_site_engine/install/install_generator.rb +454 -0
  49. data/lib/generators/rails_site_engine/install/templates/config/theme.yml +3 -0
  50. data/lib/generators/rails_site_engine/install/templates/content/pages/about.md +12 -0
  51. data/lib/generators/rails_site_engine/install/templates/content/pages/contact.md +13 -0
  52. data/lib/generators/rails_site_engine/install/templates/content/pages/home.md +39 -0
  53. data/lib/generators/rails_site_engine/install/templates/content/pages/privacy.md +43 -0
  54. data/lib/generators/rails_site_engine/install/templates/content/pages/services.md +25 -0
  55. data/lib/generators/rails_site_engine/install/templates/content/site.yml +36 -0
  56. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/config/theme.yml +3 -0
  57. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/about.md +14 -0
  58. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/contact.md +12 -0
  59. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/home.md +55 -0
  60. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/privacy.md +43 -0
  61. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/services.md +29 -0
  62. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/site.yml +36 -0
  63. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/config/theme.yml +3 -0
  64. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/about.md +14 -0
  65. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/contact.md +12 -0
  66. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/home.md +55 -0
  67. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/privacy.md +43 -0
  68. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/services.md +29 -0
  69. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/site.yml +36 -0
  70. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/config/theme.yml +3 -0
  71. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/about.md +14 -0
  72. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/contact.md +12 -0
  73. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/home.md +55 -0
  74. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/privacy.md +43 -0
  75. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/services.md +29 -0
  76. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/site.yml +36 -0
  77. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/config/theme.yml +3 -0
  78. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/about.md +14 -0
  79. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/contact.md +12 -0
  80. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/home.md +55 -0
  81. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/privacy.md +43 -0
  82. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/services.md +29 -0
  83. data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/site.yml +36 -0
  84. data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/config/theme.yml +3 -0
  85. data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/about.md +26 -0
  86. data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/contact.md +12 -0
  87. data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/home.md +43 -0
  88. data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/privacy.md +43 -0
  89. data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/work.md +27 -0
  90. data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/site.yml +36 -0
  91. data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/config/theme.yml +3 -0
  92. data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/about.md +14 -0
  93. data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/contact.md +12 -0
  94. data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/home.md +48 -0
  95. data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/pricing.md +57 -0
  96. data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/privacy.md +43 -0
  97. data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/projects.md +27 -0
  98. data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/site.yml +38 -0
  99. data/lib/rails_site_engine/content/store.rb +297 -0
  100. data/lib/rails_site_engine/engine.rb +15 -0
  101. data/lib/rails_site_engine/markdown_renderer.rb +37 -0
  102. data/lib/rails_site_engine/profile_defaults.rb +83 -0
  103. data/lib/rails_site_engine/site_engine_config.rb +78 -0
  104. data/lib/rails_site_engine/site_profiles.rb +166 -0
  105. data/lib/rails_site_engine/sitemap_xml.rb +26 -0
  106. data/lib/rails_site_engine/theme.rb +54 -0
  107. data/lib/rails_site_engine/version.rb +3 -0
  108. data/lib/rails_site_engine.rb +13 -0
  109. data/lib/tasks/rails_site_engine_tasks.rake +7 -0
  110. metadata +179 -0
@@ -0,0 +1,266 @@
1
+ require "uri"
2
+
3
+ module RailsSiteEngine
4
+ module ApplicationHelper
5
+ def site_language
6
+ I18n.locale.to_s
7
+ end
8
+
9
+ def site_name
10
+ site_config.dig("site", "name").to_s.strip.presence ||
11
+ branding_logo_text ||
12
+ "Website"
13
+ end
14
+
15
+ def branding_logo_text
16
+ site_config.dig("branding", "logo_text").to_s.strip.presence
17
+ end
18
+
19
+ def brand_label
20
+ branding_logo_text || site_name
21
+ end
22
+
23
+ def primary_nav_items
24
+ navigation_items_for("primary")
25
+ end
26
+
27
+ def footer_nav_items
28
+ navigation_items_for("footer")
29
+ end
30
+
31
+ def nav_link_options(item, data: nil)
32
+ options = {
33
+ class: ("menu-active" if item[:active]),
34
+ target: (item[:external] ? "_blank" : nil),
35
+ rel: (item[:external] ? "noopener noreferrer" : nil)
36
+ }.compact
37
+
38
+ options[:"aria-current"] = "page" if item[:active]
39
+ options[:data] = data if data.present?
40
+ options
41
+ end
42
+
43
+ def primary_cta
44
+ cta = site_config.dig("branding", "primary_cta")
45
+ return nil unless cta.is_a?(Hash)
46
+
47
+ label = cta["label"].to_s.strip
48
+ href = resolve_link_href(cta)
49
+ return nil if label.blank? || href.blank?
50
+
51
+ {
52
+ label: label,
53
+ href: href,
54
+ external: external_link?(href)
55
+ }
56
+ end
57
+
58
+ def contact_channels
59
+ {
60
+ phone: site_config.dig("contact", "phone").to_s.strip.presence,
61
+ email: site_config.dig("contact", "email").to_s.strip.presence
62
+ }.compact
63
+ end
64
+
65
+ def page_title
66
+ current_title = current_page&.title.to_s.strip.presence
67
+ default_title = seo_default_title
68
+
69
+ return default_title if current_title.blank?
70
+ return current_title if default_title.blank? || current_title == default_title
71
+
72
+ "#{current_title} | #{default_title}"
73
+ end
74
+
75
+ def page_description
76
+ current_page&.description.to_s.strip.presence ||
77
+ site_config.dig("seo", "default_description").to_s.strip.presence
78
+ end
79
+
80
+ def canonical_url
81
+ "#{request.base_url}#{request.path}"
82
+ end
83
+
84
+ def seo_meta_tags
85
+ tags = []
86
+
87
+ description = page_description
88
+ og_image = page_og_image
89
+
90
+ tags << tag.meta(name: "description", content: description) if description.present?
91
+ tags << tag.link(rel: "canonical", href: canonical_url)
92
+
93
+ tags << tag.meta(property: "og:title", content: page_title)
94
+ tags << tag.meta(property: "og:description", content: description) if description.present?
95
+ tags << tag.meta(property: "og:type", content: "website")
96
+ tags << tag.meta(property: "og:url", content: canonical_url)
97
+ tags << tag.meta(property: "og:image", content: og_image) if og_image.present?
98
+
99
+ tags << tag.meta(name: "twitter:card", content: og_image.present? ? "summary_large_image" : "summary")
100
+ tags << tag.meta(name: "twitter:title", content: current_page&.twitter_title.to_s.strip.presence || page_title)
101
+
102
+ twitter_description = current_page&.twitter_description.to_s.strip.presence || description
103
+ tags << tag.meta(name: "twitter:description", content: twitter_description) if twitter_description.present?
104
+ tags << tag.meta(name: "twitter:image", content: og_image) if og_image.present?
105
+
106
+ twitter_handle = site_config.dig("seo", "twitter_handle").to_s.strip.presence
107
+ tags << tag.meta(name: "twitter:site", content: twitter_handle) if twitter_handle.present?
108
+
109
+ safe_join(tags.compact, "\n")
110
+ end
111
+
112
+ def local_business_json_ld_tag
113
+ phone = site_config.dig("contact", "phone").to_s.strip.presence
114
+ email = site_config.dig("contact", "email").to_s.strip.presence
115
+ return nil if phone.blank? && email.blank?
116
+
117
+ json_ld = {
118
+ "@context" => "https://schema.org",
119
+ "@type" => "LocalBusiness",
120
+ "name" => site_name,
121
+ "url" => site_base_url || request.base_url
122
+ }
123
+
124
+ json_ld["telephone"] = phone if phone.present?
125
+ json_ld["email"] = email if email.present?
126
+
127
+ content_tag(
128
+ :script,
129
+ json_escape(json_ld.to_json).html_safe,
130
+ type: "application/ld+json"
131
+ )
132
+ end
133
+
134
+ def engine_stylesheet_link_tag
135
+ stylesheet_link_tag "rails_site_engine/engine"
136
+ end
137
+
138
+ def app_stylesheet_link_tag
139
+ return nil unless Rails.application.respond_to?(:assets) && Rails.application.assets&.respond_to?(:resolver)
140
+
141
+ candidates = {
142
+ "app.css" => "app",
143
+ "application.css" => "application"
144
+ }
145
+
146
+ logical_name = candidates.find { |asset_name, _| Rails.application.assets.resolver.resolve(asset_name).present? }&.last
147
+ return nil if logical_name.blank?
148
+
149
+ stylesheet_link_tag logical_name
150
+ end
151
+
152
+ def resolve_link_href(link_config)
153
+ return nil unless link_config.is_a?(Hash)
154
+
155
+ page_id = link_config["page"].to_s.strip.presence || link_config[:page].to_s.strip.presence
156
+ if page_id.present?
157
+ path = content_store.path_for_page(page_id)
158
+ return path if path.present?
159
+ end
160
+
161
+ link_config["href"].to_s.strip.presence || link_config[:href].to_s.strip.presence
162
+ end
163
+
164
+ def contact_page_path
165
+ content_store.path_for_page(content_store.contact_page_id) || "/contact"
166
+ end
167
+
168
+ def page_title_visible?(page = current_page)
169
+ page.present? && page.show_title && page.title.present?
170
+ end
171
+
172
+ def page_lead(page = current_page)
173
+ page&.lead.to_s.strip.presence
174
+ end
175
+
176
+ def section_heading_tag(section:, page:, index:)
177
+ section_type = section["type"].to_s.strip
178
+ return "h1" if section_type == "hero" && index.to_i.zero? && !page.show_title
179
+
180
+ "h2"
181
+ end
182
+
183
+ def render_section(section, page:, index:)
184
+ section = section.is_a?(Hash) ? section : {}
185
+ type = section["type"].to_s
186
+ partial = "rails_site_engine/sections/#{type}"
187
+ heading_tag = section_heading_tag(section: section, page: page, index: index)
188
+
189
+ render partial:, locals: { section: section, page: page, heading_tag: heading_tag }
190
+ rescue ActionView::MissingTemplate
191
+ Rails.logger.warn("rails_site_engine: unknown section type=#{type.inspect}")
192
+ return "".html_safe unless Rails.env.development?
193
+
194
+ render partial: "rails_site_engine/sections/unknown", locals: { section: section }
195
+ end
196
+
197
+ private
198
+
199
+ def navigation_items_for(key)
200
+ items = site_config.dig("navigation", key)
201
+ return [] unless items.is_a?(Array)
202
+
203
+ items.filter_map do |item|
204
+ next unless item.is_a?(Hash)
205
+
206
+ label = item["label"].to_s.strip
207
+ href = resolve_link_href(item)
208
+ next if label.blank? || href.blank?
209
+
210
+ {
211
+ label: label,
212
+ href: href,
213
+ active: active_path?(href),
214
+ external: external_link?(href)
215
+ }
216
+ end
217
+ end
218
+
219
+ def active_path?(href)
220
+ return false unless request
221
+
222
+ uri = URI.parse(href)
223
+ return false if uri.scheme.present? || uri.host.present?
224
+
225
+ path = uri.path.to_s.strip
226
+ return false if path.blank?
227
+ return request.path == "/" if path == "/"
228
+
229
+ request.path == path || request.path.start_with?("#{path}/")
230
+ rescue URI::InvalidURIError
231
+ false
232
+ end
233
+
234
+ def external_link?(href)
235
+ uri = URI.parse(href)
236
+ uri.scheme.present?
237
+ rescue URI::InvalidURIError
238
+ false
239
+ end
240
+
241
+ def site_base_url
242
+ site_config.dig("site", "base_url").to_s.strip.presence
243
+ end
244
+
245
+ def seo_default_title
246
+ site_config.dig("seo", "default_title").to_s.strip.presence || site_name
247
+ end
248
+
249
+ def page_og_image
250
+ image = current_page&.og_image.to_s.strip.presence ||
251
+ site_config.dig("seo", "default_og_image").to_s.strip.presence
252
+ return nil if image.blank?
253
+
254
+ absolute_url_for(image)
255
+ end
256
+
257
+ def absolute_url_for(url)
258
+ uri = URI.parse(url)
259
+ return url if uri.scheme.present?
260
+
261
+ "#{request.base_url}#{url.start_with?("/") ? url : "/#{url}"}"
262
+ rescue URI::InvalidURIError
263
+ nil
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,83 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["darkIcon", "lightIcon", "toggleButton"];
5
+ static values = {
6
+ lightTheme: String,
7
+ darkTheme: String,
8
+ };
9
+
10
+ connect() {
11
+ if (!this.hasLightThemeValue || !this.hasDarkThemeValue) return;
12
+
13
+ this.currentMode = this.resolvedMode();
14
+ this.applyMode(this.currentMode);
15
+ this.syncIcons(this.currentMode);
16
+ this.syncToggleButton(this.currentMode);
17
+ }
18
+
19
+ toggle() {
20
+ const nextMode = this.currentMode === "dark" ? "light" : "dark";
21
+ this.setStoredMode(nextMode);
22
+ this.applyMode(nextMode);
23
+ this.syncIcons(nextMode);
24
+ this.syncToggleButton(nextMode);
25
+ this.currentMode = nextMode;
26
+ }
27
+
28
+ resolvedMode() {
29
+ return this.getStoredMode() || (this.prefersDark() ? "dark" : "light");
30
+ }
31
+
32
+ applyMode(mode) {
33
+ const theme = mode === "dark" ? this.darkThemeValue : this.lightThemeValue;
34
+ document.documentElement.setAttribute("data-theme", theme);
35
+ document.documentElement.style.colorScheme = mode === "dark" ? "dark" : "light";
36
+ }
37
+
38
+ syncIcons(mode) {
39
+ if (this.hasLightIconTarget) {
40
+ this.lightIconTarget.classList.toggle("hidden", mode === "dark");
41
+ }
42
+ if (this.hasDarkIconTarget) {
43
+ this.darkIconTarget.classList.toggle("hidden", mode !== "dark");
44
+ }
45
+ }
46
+
47
+ syncToggleButton(mode) {
48
+ if (!this.hasToggleButtonTarget) return;
49
+
50
+ const isDark = mode === "dark";
51
+ this.toggleButtonTarget.setAttribute("aria-pressed", String(isDark));
52
+ this.toggleButtonTarget.setAttribute(
53
+ "title",
54
+ isDark ? "Switch to light mode" : "Switch to dark mode",
55
+ );
56
+ }
57
+
58
+ prefersDark() {
59
+ return (
60
+ typeof window.matchMedia === "function" &&
61
+ window.matchMedia("(prefers-color-scheme: dark)").matches
62
+ );
63
+ }
64
+
65
+ storageKey() {
66
+ return "rse-color-mode";
67
+ }
68
+
69
+ getStoredMode() {
70
+ try {
71
+ const value = localStorage.getItem(this.storageKey());
72
+ return value === "light" || value === "dark" ? value : null;
73
+ } catch (_) {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ setStoredMode(mode) {
79
+ try {
80
+ localStorage.setItem(this.storageKey(), mode);
81
+ } catch (_) {}
82
+ }
83
+ }
@@ -0,0 +1,22 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["submitButton", "submitText", "submittingText"];
5
+
6
+ submit() {
7
+ if (!this.hasSubmitButtonTarget || this.submitButtonTarget.disabled) return;
8
+
9
+ this.submitButtonTarget.disabled = true;
10
+ this.submitButtonTarget.classList.add("btn-disabled");
11
+ this.submitButtonTarget.setAttribute("aria-busy", "true");
12
+
13
+ if (this.hasSubmitTextTarget) {
14
+ this.submitTextTarget.classList.add("hidden");
15
+ }
16
+
17
+ if (this.hasSubmittingTextTarget) {
18
+ this.submittingTextTarget.classList.remove("hidden");
19
+ this.submittingTextTarget.classList.add("inline-flex");
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel", "toggleButton"];
5
+
6
+ connect() {
7
+ this.close();
8
+ }
9
+
10
+ toggle() {
11
+ if (this.isOpen()) {
12
+ this.close();
13
+ return;
14
+ }
15
+
16
+ this.open();
17
+ }
18
+
19
+ open() {
20
+ if (!this.hasPanelTarget) return;
21
+
22
+ this.panelTarget.classList.remove("hidden");
23
+ document.body.classList.add("overflow-hidden");
24
+ this.setExpanded(true);
25
+ }
26
+
27
+ close() {
28
+ if (this.hasPanelTarget) {
29
+ this.panelTarget.classList.add("hidden");
30
+ }
31
+
32
+ document.body.classList.remove("overflow-hidden");
33
+ this.setExpanded(false);
34
+ }
35
+
36
+ isOpen() {
37
+ return this.hasPanelTarget && !this.panelTarget.classList.contains("hidden");
38
+ }
39
+
40
+ setExpanded(expanded) {
41
+ if (!this.hasToggleButtonTarget) return;
42
+
43
+ this.toggleButtonTarget.setAttribute("aria-expanded", String(expanded));
44
+ }
45
+ }
@@ -0,0 +1,8 @@
1
+ import { Application } from "@hotwired/stimulus";
2
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading";
3
+
4
+ const application = Application.start();
5
+ application.debug = false;
6
+ window.Stimulus = application;
7
+
8
+ eagerLoadControllersFrom("rails_site_engine/controllers", application);
@@ -0,0 +1,4 @@
1
+ module RailsSiteEngine
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module RailsSiteEngine
2
+ class ApplicationMailer < ::ActionMailer::Base
3
+ default from: -> { ENV.fetch("DEFAULT_FROM_EMAIL", "no-reply@example.com") }
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,25 @@
1
+ module RailsSiteEngine
2
+ class ContactMailer < ApplicationMailer
3
+ def notify
4
+ contact_message = params.fetch(:contact_message)
5
+ site_config = params.fetch(:site_config, {})
6
+
7
+ recipient =
8
+ site_config.dig("contact", "recipient_email").to_s.strip.presence ||
9
+ ENV["CONTACT_RECIPIENT_EMAIL"].to_s.strip.presence
10
+ raise "Missing content/site.yml key: contact.recipient_email" if recipient.blank?
11
+
12
+ business_name = site_config.dig("site", "name").to_s.strip
13
+ subject = business_name.present? ? "New inquiry — #{business_name}" : "New inquiry"
14
+
15
+ @contact_message = contact_message
16
+ @site_config = site_config
17
+
18
+ mail(
19
+ to: recipient,
20
+ reply_to: @contact_message.email,
21
+ subject: subject
22
+ )
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ module RailsSiteEngine
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ module RailsSiteEngine
2
+ class ContactMessage
3
+ include ActiveModel::Model
4
+
5
+ attr_accessor :name, :email, :phone, :message, :website
6
+
7
+ validates :name, presence: true
8
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
9
+ validates :message, presence: true
10
+ validates :website, absence: true
11
+
12
+ def honeypot_triggered?
13
+ website.to_s.strip.present?
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
5
+ <style>
6
+ /* Email styles need to be inline */
7
+ </style>
8
+ </head>
9
+
10
+ <body>
11
+ <%= yield %>
12
+ </body>
13
+ </html>
@@ -0,0 +1 @@
1
+ <%= yield %>
@@ -0,0 +1,54 @@
1
+ <!DOCTYPE html>
2
+ <html
3
+ lang="<%= site_language %>"
4
+ data-theme="<%= theme.daisyui_theme %>"
5
+ <% if theme.daisyui_dark_mode_enabled? %>
6
+ data-controller="rails-site-engine--color-mode"
7
+ data-rails-site-engine--color-mode-light-theme-value="<%= theme.daisyui_light_theme %>"
8
+ data-rails-site-engine--color-mode-dark-theme-value="<%= theme.daisyui_dark_theme %>"
9
+ <% end %>
10
+ >
11
+ <head>
12
+ <title><%= page_title %></title>
13
+ <meta name="viewport" content="width=device-width,initial-scale=1">
14
+
15
+ <%= csrf_meta_tags %>
16
+ <%= csp_meta_tag %>
17
+
18
+ <%= seo_meta_tags %>
19
+ <%= local_business_json_ld_tag %>
20
+ <%= yield :head %>
21
+
22
+ <%= engine_stylesheet_link_tag %>
23
+ <%= app_stylesheet_link_tag %>
24
+ <%= javascript_importmap_tags "rails_site_engine/application" if respond_to?(:javascript_importmap_tags) %>
25
+ </head>
26
+
27
+ <body class="min-h-screen bg-base-100 text-base-content">
28
+ <a
29
+ href="#main-content"
30
+ class="sr-only fixed left-4 top-4 z-50 rounded-btn bg-base-content px-4 py-2 text-base-100 focus:not-sr-only"
31
+ >
32
+ <%= t("rails_site_engine.a11y.skip_to_main") %>
33
+ </a>
34
+
35
+ <header class="sticky top-0 z-40 border-b border-base-200 bg-base-100/95 backdrop-blur">
36
+ <%= render "rails_site_engine/shared/navbar" %>
37
+ </header>
38
+
39
+ <main id="main-content" class="mx-auto w-full max-w-6xl px-4 py-8 lg:py-10" tabindex="-1">
40
+ <%= render "rails_site_engine/shared/flash" %>
41
+ <%= yield %>
42
+ </main>
43
+
44
+ <%= render "rails_site_engine/shared/footer" %>
45
+
46
+ <% if (cta = primary_cta).present? %>
47
+ <div class="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4 lg:hidden">
48
+ <div class="pointer-events-auto mx-auto max-w-6xl">
49
+ <%= link_to cta[:label], cta[:href], class: "btn btn-primary btn-block shadow-lg", target: (cta[:external] ? "_blank" : nil), rel: (cta[:external] ? "noopener noreferrer" : nil) %>
50
+ </div>
51
+ </div>
52
+ <% end %>
53
+ </body>
54
+ </html>
@@ -0,0 +1,9 @@
1
+ New inquiry
2
+
3
+ Name: <%= @contact_message.name %>
4
+ Email: <%= @contact_message.email %>
5
+ Phone: <%= @contact_message.phone %>
6
+
7
+ Message:
8
+ <%= @contact_message.message %>
9
+
@@ -0,0 +1 @@
1
+ <%= render template: "rails_site_engine/pages/show" %>
@@ -0,0 +1,15 @@
1
+ <% if page_title_visible?(current_page) || page_lead(current_page).present? %>
2
+ <header class="mb-10">
3
+ <% if page_title_visible?(current_page) %>
4
+ <h1 class="text-3xl font-bold tracking-tight sm:text-4xl"><%= current_page.title %></h1>
5
+ <% end %>
6
+
7
+ <% if page_lead(current_page).present? %>
8
+ <p class="mt-4 max-w-3xl text-lg text-base-content/80"><%= page_lead(current_page) %></p>
9
+ <% end %>
10
+ </header>
11
+ <% end %>
12
+
13
+ <% current_page.sections.each_with_index do |section, index| %>
14
+ <%= render_section(section, page: current_page, index: index) %>
15
+ <% end %>