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,43 @@
1
+ ---
2
+ title: "Privacy Policy"
3
+ description: "How <%= business_name %> collects, uses, and protects your information."
4
+ path: "/privacy"
5
+ template: "standard"
6
+ show_title: true
7
+ lead: "This page explains what information we collect, how we use it, and your choices."
8
+ sections:
9
+ - type: rich_text
10
+ ---
11
+
12
+ ## Information We Collect
13
+ When you use our contact form, we may collect your name, email address, phone number, and the details in your message.
14
+
15
+ Our form also includes a hidden `website` field used as an anti-spam honeypot. Legitimate visitors should leave this field empty.
16
+
17
+ ## How We Use Information
18
+ We use inquiry details to:
19
+
20
+ - respond to your questions and requests
21
+ - follow up about potential work
22
+ - keep basic records of conversations and project requests
23
+ - improve service and communication
24
+
25
+ ## Sharing and Disclosure
26
+ We do not sell or rent your personal information.
27
+
28
+ We may share information with trusted service providers only when needed to operate this site or process communications (for example, email delivery). We may also disclose information if required by law.
29
+
30
+ ## Retention
31
+ We keep inquiry information only as long as needed for legitimate business purposes, legal compliance, and recordkeeping. When information is no longer needed, we delete it or anonymize it.
32
+
33
+ ## Security
34
+ We use reasonable technical and organizational safeguards to protect personal information. No internet transmission or storage method is 100% secure, but we work to reduce risk.
35
+
36
+ ## Your Choices
37
+ You can request access, correction, or deletion of personal information you have submitted to us.
38
+
39
+ ## Changes to This Policy
40
+ We may update this Privacy Policy from time to time. Updates are effective when posted on this page.
41
+
42
+ ## Contact
43
+ For privacy questions or requests, use the contact details listed on the [Contact page](/contact).
@@ -0,0 +1,27 @@
1
+ ---
2
+ title: "Projects"
3
+ description: "Past client projects by <%= business_name %>."
4
+ path: "/projects"
5
+ template: "standard"
6
+ show_title: true
7
+ lead: "Recent engagements focused on lead generation, speed, and maintainability."
8
+ sections:
9
+ - type: project_cards
10
+ heading: "Past projects"
11
+ items:
12
+ - title: "Northline Plumbing Website"
13
+ summary: "Rebuilt a local plumbing site with clearer offers and lead capture."
14
+ stack: "Rails, Tailwind, daisyUI"
15
+ url: "https://example.com/projects/northline"
16
+ outcome: "42% increase in contact form submissions in 60 days."
17
+ - title: "Oakridge Carpentry Refresh"
18
+ summary: "Migrated a legacy WordPress site to a faster, easier-to-maintain stack."
19
+ stack: "Rails, Propshaft, Stimulus"
20
+ url: "https://example.com/projects/oakridge"
21
+ outcome: "Cut page load times by 58% and simplified content updates."
22
+ - title: "Atlas Electrical Lead Funnel"
23
+ summary: "Built dedicated service landing pages tied to paid campaigns."
24
+ stack: "Rails, PostgreSQL, analytics events"
25
+ url: "https://example.com/projects/atlas"
26
+ outcome: "Reduced cost per qualified lead by 31%."
27
+ ---
@@ -0,0 +1,38 @@
1
+ site:
2
+ locale: "en"
3
+ name: "<%= business_name %>"
4
+ base_url: "https://example.com"
5
+
6
+ navigation:
7
+ primary:
8
+ - label: "Home"
9
+ page: "home"
10
+ - label: "Projects"
11
+ page: "projects"
12
+ - label: "Pricing"
13
+ page: "pricing"
14
+ - label: "About"
15
+ page: "about"
16
+ - label: "Contact"
17
+ page: "contact"
18
+ footer:
19
+ - label: "Privacy"
20
+ page: "privacy"
21
+
22
+ branding:
23
+ logo_text: "<%= business_name %>"
24
+ primary_cta:
25
+ label: "Book a discovery call"
26
+ page: "contact"
27
+
28
+ contact:
29
+ page: "contact"
30
+ recipient_email: "leads@example.com"
31
+ phone: "(555) 123-4567"
32
+ email: "hello@example.com"
33
+
34
+ seo:
35
+ default_title: "<%= business_name %>"
36
+ default_description: "Custom software and website delivery with hosting and maintenance included."
37
+ default_og_image: "/images/og-default.jpg"
38
+ twitter_handle: ""
@@ -0,0 +1,297 @@
1
+ require "pathname"
2
+ require "yaml"
3
+
4
+ module RailsSiteEngine
5
+ module Content
6
+ class RoutingConfigError < StandardError; end
7
+
8
+ Page = Struct.new(
9
+ :id,
10
+ :slug,
11
+ :path,
12
+ :title,
13
+ :description,
14
+ :template,
15
+ :show_title,
16
+ :lead,
17
+ :og_image,
18
+ :twitter_title,
19
+ :twitter_description,
20
+ :sections,
21
+ :body_markdown,
22
+ :body_html,
23
+ keyword_init: true
24
+ )
25
+
26
+ class Store
27
+ RESERVED_PATHS = [ "/robots.txt", "/sitemap.xml" ].freeze
28
+ ALLOWED_TEMPLATES = %w[landing standard contact].freeze
29
+
30
+ def initialize(
31
+ root_path: nil,
32
+ host_config: RailsSiteEngine::SiteEngineConfig.new
33
+ )
34
+ @host_config = host_config
35
+
36
+ if root_path
37
+ root = Pathname.new(root_path)
38
+ @override_site_config_path = root.join("site.yml")
39
+ @override_pages_path = root.join("pages")
40
+ else
41
+ @override_site_config_path = @host_config.override_site_config_path
42
+ @override_pages_path = @host_config.override_pages_path
43
+ end
44
+ end
45
+
46
+ def site_config
47
+ @site_config ||= load_yaml(@override_site_config_path)
48
+ end
49
+
50
+ def site_locale
51
+ configured_locale = site_config.dig("site", "locale").to_s.strip
52
+ return I18n.default_locale.to_s if configured_locale.blank?
53
+
54
+ available_locales = I18n.available_locales.map(&:to_s)
55
+ return configured_locale if available_locales.include?(configured_locale)
56
+
57
+ I18n.default_locale.to_s
58
+ end
59
+
60
+ def contact_page_id
61
+ site_config.dig("contact", "page").to_s.strip.presence || "contact"
62
+ end
63
+
64
+ def page(page_id)
65
+ page_id = page_id.to_s.strip
66
+ return nil if page_id.empty?
67
+
68
+ parsed = parsed_page_content(page_id)
69
+ return nil unless parsed
70
+
71
+ frontmatter = parsed[:frontmatter]
72
+ body_markdown = parsed[:body_markdown]
73
+ template = resolved_template(page_id, frontmatter)
74
+ sections = normalized_sections(frontmatter, body_markdown, template)
75
+
76
+ Page.new(
77
+ id: page_id,
78
+ slug: page_id,
79
+ path: path_for_page(page_id),
80
+ title: frontmatter["title"].to_s.strip.presence,
81
+ description: frontmatter["description"].to_s.strip.presence,
82
+ template: template,
83
+ show_title: resolved_show_title(frontmatter),
84
+ lead: frontmatter["lead"].to_s.strip.presence,
85
+ og_image: frontmatter["og_image"].to_s.strip.presence,
86
+ twitter_title: frontmatter["twitter_title"].to_s.strip.presence,
87
+ twitter_description: frontmatter["twitter_description"].to_s.strip.presence,
88
+ sections: sections,
89
+ body_markdown: body_markdown,
90
+ body_html: RailsSiteEngine::MarkdownRenderer.render(body_markdown)
91
+ )
92
+ end
93
+
94
+ def page_for_path(request_path)
95
+ page_id = route_map[normalize_request_path(request_path)]
96
+ return nil unless page_id
97
+
98
+ page(page_id)
99
+ end
100
+
101
+ def path_for_page(page_id)
102
+ page_id = page_id.to_s.strip
103
+ return nil if page_id.empty?
104
+
105
+ page_path_map[page_id]
106
+ end
107
+
108
+ def page_ids
109
+ @page_ids ||= begin
110
+ if @override_pages_path.directory?
111
+ @override_pages_path.glob("*.md").map { |path| path.basename(".md").to_s }.sort
112
+ else
113
+ []
114
+ end
115
+ end
116
+ end
117
+
118
+ def page_slugs = page_ids
119
+
120
+ def page_paths
121
+ route_map.keys.sort_by { |path| path == "/" ? "" : path }
122
+ end
123
+
124
+ private
125
+
126
+ def parsed_pages
127
+ @parsed_pages ||= page_ids.each_with_object({}) do |page_id, memo|
128
+ source = page_source(page_id)
129
+ next if source.nil?
130
+
131
+ frontmatter, body_markdown = parse_frontmatter_and_body(source)
132
+ memo[page_id] = {
133
+ frontmatter: frontmatter,
134
+ body_markdown: body_markdown.to_s
135
+ }
136
+ end
137
+ end
138
+
139
+ def parsed_page_content(page_id)
140
+ parsed_pages[page_id]
141
+ end
142
+
143
+ def route_map
144
+ @route_map ||= build_route_map
145
+ end
146
+
147
+ def page_path_map
148
+ @page_path_map ||= route_map.each_with_object({}) do |(path, page_id), memo|
149
+ memo[page_id] = path
150
+ end
151
+ end
152
+
153
+ def build_route_map
154
+ routes = {}
155
+ errors = []
156
+
157
+ page_ids.each do |page_id|
158
+ parsed = parsed_page_content(page_id)
159
+ frontmatter = parsed ? parsed[:frontmatter] : {}
160
+
161
+ path = normalize_config_path(frontmatter["path"])
162
+ path ||= default_path_for_page(page_id)
163
+
164
+ if path.blank?
165
+ errors << "content/pages/#{page_id}.md path must be a non-empty path"
166
+ next
167
+ end
168
+
169
+ if RESERVED_PATHS.include?(path)
170
+ errors << "content/pages/#{page_id}.md path cannot use reserved path #{path.inspect}"
171
+ next
172
+ end
173
+
174
+ if (conflict = routes[path]).present? && conflict != page_id
175
+ errors << "routing path #{path.inspect} is duplicated by #{conflict.inspect} and #{page_id.inspect}"
176
+ next
177
+ end
178
+
179
+ routes[path] = page_id
180
+ end
181
+
182
+ handle_routing_errors(errors)
183
+ routes
184
+ end
185
+
186
+ def default_path_for_page(page_id)
187
+ page_id == "home" ? "/" : "/#{page_id}"
188
+ end
189
+
190
+ def normalize_request_path(path)
191
+ normalize_path(path, blank: "/")
192
+ end
193
+
194
+ def normalize_config_path(path)
195
+ normalize_path(path, blank: nil)
196
+ end
197
+
198
+ def normalize_path(path, blank:)
199
+ value = path.to_s.strip
200
+ return blank if value.empty?
201
+
202
+ value = "/#{value}" unless value.start_with?("/")
203
+ value = value.gsub(%r{/+}, "/")
204
+ value = value.delete_suffix("/") unless value == "/"
205
+ value
206
+ end
207
+
208
+ def resolved_template(page_id, frontmatter)
209
+ configured_template = frontmatter["template"].to_s.strip.presence
210
+ template = configured_template || default_template_for_page(page_id)
211
+ ALLOWED_TEMPLATES.include?(template) ? template : default_template_for_page(page_id)
212
+ end
213
+
214
+ def default_template_for_page(page_id)
215
+ return "contact" if page_id == contact_page_id
216
+ return "landing" if page_id == "home"
217
+
218
+ "standard"
219
+ end
220
+
221
+ def resolved_show_title(frontmatter)
222
+ value = frontmatter["show_title"]
223
+ return true if value.nil?
224
+ return value if value == true || value == false
225
+
226
+ true
227
+ end
228
+
229
+ def normalized_sections(frontmatter, body_markdown, template)
230
+ sections = frontmatter["sections"]
231
+ sections = sections.is_a?(Array) ? sections.filter_map { |section| section.is_a?(Hash) ? section : nil } : []
232
+
233
+ if sections.empty? && body_markdown.strip.present?
234
+ sections = [ { "type" => "rich_text" } ]
235
+ end
236
+
237
+ if template == "contact" && sections.none? { |section| section["type"].to_s.strip == "contact_form" }
238
+ sections << { "type" => "contact_form" }
239
+ end
240
+
241
+ sections
242
+ end
243
+
244
+ def handle_routing_errors(errors)
245
+ return if errors.empty?
246
+
247
+ message = "rails_site_engine: invalid page path configuration:\n- #{errors.join("\n- ")}"
248
+
249
+ if Rails.env.production?
250
+ Rails.logger.error(message)
251
+ else
252
+ raise RoutingConfigError, message
253
+ end
254
+ end
255
+
256
+ def load_yaml(path)
257
+ return {} unless path.file?
258
+
259
+ YAML.safe_load(
260
+ path.read,
261
+ permitted_classes: [],
262
+ permitted_symbols: [],
263
+ aliases: false
264
+ ) || {}
265
+ rescue Psych::SyntaxError
266
+ {}
267
+ end
268
+
269
+ def parse_frontmatter_and_body(text)
270
+ text = text.to_s
271
+
272
+ match = text.match(/\A---\s*\n(?<yaml>.*?)\n---\s*\n/m)
273
+ return [ {}, text ] unless match
274
+
275
+ yaml_hash = YAML.safe_load(
276
+ match[:yaml],
277
+ permitted_classes: [],
278
+ permitted_symbols: [],
279
+ aliases: false
280
+ ) || {}
281
+ yaml_hash = {} unless yaml_hash.is_a?(Hash)
282
+
283
+ body = text[match.end(0)..] || ""
284
+ [ yaml_hash, body ]
285
+ rescue Psych::SyntaxError
286
+ [ {}, text ]
287
+ end
288
+
289
+ def page_source(page_id)
290
+ override_path = @override_pages_path.join("#{page_id}.md")
291
+ return nil unless override_path.file?
292
+
293
+ override_path.read
294
+ end
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,15 @@
1
+ module RailsSiteEngine
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RailsSiteEngine
4
+
5
+ initializer "rails_site_engine.importmap", before: "importmap" do |app|
6
+ next unless app.config.respond_to?(:importmap)
7
+
8
+ app.config.importmap.paths << root.join("config/importmap.rb")
9
+ end
10
+
11
+ initializer "rails_site_engine.assets" do |app|
12
+ app.config.assets.paths << root.join("app/javascript")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ require "commonmarker"
2
+
3
+ module RailsSiteEngine
4
+ module MarkdownRenderer
5
+ SANITIZE_TAGS = %w[
6
+ a
7
+ b
8
+ blockquote
9
+ br
10
+ code
11
+ em
12
+ h2
13
+ h3
14
+ h4
15
+ h5
16
+ h6
17
+ hr
18
+ i
19
+ li
20
+ ol
21
+ p
22
+ pre
23
+ strong
24
+ ul
25
+ ].freeze
26
+
27
+ SANITIZE_ATTRIBUTES = %w[href title].freeze
28
+
29
+ def self.render(markdown)
30
+ markdown = markdown.to_s
31
+ return "".html_safe if markdown.strip.empty?
32
+
33
+ html = Commonmarker.to_html(markdown)
34
+ ActionController::Base.helpers.sanitize(html, tags: SANITIZE_TAGS, attributes: SANITIZE_ATTRIBUTES)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,83 @@
1
+ require "erb"
2
+ require "pathname"
3
+ require "yaml"
4
+
5
+ module RailsSiteEngine
6
+ class ProfileDefaults
7
+ def initialize(profile:, business_name:, site_profiles: nil)
8
+ @profile = profile.to_s.strip
9
+ @business_name = business_name.to_s
10
+ @site_profiles =
11
+ site_profiles || RailsSiteEngine::SiteProfiles.new(path: RailsSiteEngine::Engine.root.join("config/site_profiles.yml"))
12
+ end
13
+
14
+ def profile_key
15
+ return @profile if @site_profiles.valid_concrete_profile?(@profile)
16
+
17
+ @site_profiles.fallback_concrete_profile
18
+ end
19
+
20
+ def site_config
21
+ @site_config ||= load_yaml_from_template("content/site.yml")
22
+ end
23
+
24
+ def theme_config
25
+ @theme_config ||= load_yaml_from_template("config/theme.yml")
26
+ end
27
+
28
+ def page_ids
29
+ return [] unless pages_root.directory?
30
+
31
+ pages_root.glob("*.md").map { |path| path.basename(".md").to_s }.sort
32
+ end
33
+
34
+ def page_content(page_id)
35
+ path = pages_root.join("#{page_id}.md")
36
+ return nil unless path.file?
37
+
38
+ render_template(path.read)
39
+ end
40
+
41
+ private
42
+
43
+ def template_root
44
+ @template_root ||= begin
45
+ concrete_profile = profile_key
46
+ return nil if concrete_profile.to_s.empty?
47
+
48
+ @site_profiles.template_root_for(concrete_profile)
49
+ end
50
+ end
51
+
52
+ def pages_root
53
+ return Pathname.new("/nonexistent") if template_root.nil?
54
+
55
+ template_root.join("content/pages")
56
+ end
57
+
58
+ def load_yaml_from_template(relative_path)
59
+ return {} if template_root.nil?
60
+
61
+ path = template_root.join(relative_path)
62
+ return {} unless path.file?
63
+
64
+ payload = YAML.safe_load(
65
+ render_template(path.read),
66
+ permitted_classes: [],
67
+ permitted_symbols: [],
68
+ aliases: false
69
+ ) || {}
70
+ payload.is_a?(Hash) ? payload : {}
71
+ rescue Psych::SyntaxError
72
+ {}
73
+ end
74
+
75
+ def render_template(template)
76
+ ERB.new(template.to_s).result_with_hash(
77
+ business_name: @business_name
78
+ )
79
+ rescue StandardError
80
+ template.to_s
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,78 @@
1
+ require "pathname"
2
+ require "yaml"
3
+
4
+ module RailsSiteEngine
5
+ class SiteEngineConfig
6
+ DEFAULT_PROFILE = "home-services-general".freeze
7
+ DEFAULT_SITE_CONFIG_PATH = "content/site.yml".freeze
8
+ DEFAULT_PAGES_PATH = "content/pages".freeze
9
+ DEFAULT_THEME_PATH = "config/theme.yml".freeze
10
+
11
+ def initialize(path: Rails.root.join("config/site_engine.yml"), site_profiles: nil)
12
+ @path = Pathname.new(path)
13
+ @site_profiles =
14
+ site_profiles || RailsSiteEngine::SiteProfiles.new(path: RailsSiteEngine::Engine.root.join("config/site_profiles.yml"))
15
+ end
16
+
17
+ def profile
18
+ requested = raw_config["profile"].to_s.strip
19
+ return requested if @site_profiles.valid_concrete_profile?(requested)
20
+
21
+ @site_profiles.fallback_concrete_profile || DEFAULT_PROFILE
22
+ end
23
+
24
+ def business_name
25
+ configured = raw_config["business_name"].to_s.strip
26
+ return configured unless configured.empty?
27
+
28
+ inferred_business_name
29
+ end
30
+
31
+ def override_site_config_path
32
+ configured_path("site_config_path", DEFAULT_SITE_CONFIG_PATH)
33
+ end
34
+
35
+ def override_pages_path
36
+ configured_path("pages_path", DEFAULT_PAGES_PATH)
37
+ end
38
+
39
+ def override_theme_path
40
+ configured_path("theme_path", DEFAULT_THEME_PATH)
41
+ end
42
+
43
+ private
44
+
45
+ def raw_config
46
+ @raw_config ||= load_yaml(@path)
47
+ end
48
+
49
+ def load_yaml(path)
50
+ return {} unless path.file?
51
+
52
+ payload = YAML.safe_load(
53
+ path.read,
54
+ permitted_classes: [],
55
+ permitted_symbols: [],
56
+ aliases: false
57
+ ) || {}
58
+
59
+ payload.is_a?(Hash) ? payload : {}
60
+ rescue Psych::SyntaxError
61
+ {}
62
+ end
63
+
64
+ def configured_path(key, fallback)
65
+ value = raw_config.dig("overrides", key).to_s.strip
66
+ value = fallback if value.empty?
67
+
68
+ path = Pathname.new(value)
69
+ path.absolute? ? path : Rails.root.join(path)
70
+ end
71
+
72
+ def inferred_business_name
73
+ app_name = Rails.application.class.module_parent_name.to_s
74
+ normalized = app_name.gsub(/([a-z])([A-Z])/, '\1 \2').tr("_", " ").strip
75
+ normalized.empty? ? "Your Business" : normalized
76
+ end
77
+ end
78
+ end