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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +137 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/rails_site_engine/application.css +15 -0
- data/app/assets/stylesheets/rails_site_engine/engine.css +2 -0
- data/app/assets/stylesheets/rails_site_engine/engine.tailwind.css +5 -0
- data/app/controllers/rails_site_engine/application_controller.rb +27 -0
- data/app/controllers/rails_site_engine/contacts_controller.rb +35 -0
- data/app/controllers/rails_site_engine/meta_controller.rb +20 -0
- data/app/controllers/rails_site_engine/pages_controller.rb +18 -0
- data/app/helpers/rails_site_engine/application_helper.rb +266 -0
- data/app/javascript/controllers/rails_site_engine/color_mode_controller.js +83 -0
- data/app/javascript/controllers/rails_site_engine/form_submit_controller.js +22 -0
- data/app/javascript/controllers/rails_site_engine/mobile_nav_controller.js +45 -0
- data/app/javascript/rails_site_engine/application.js +8 -0
- data/app/jobs/rails_site_engine/application_job.rb +4 -0
- data/app/mailers/rails_site_engine/application_mailer.rb +6 -0
- data/app/mailers/rails_site_engine/contact_mailer.rb +25 -0
- data/app/models/rails_site_engine/application_record.rb +5 -0
- data/app/models/rails_site_engine/contact_message.rb +16 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/layouts/rails_site_engine/application.html.erb +54 -0
- data/app/views/rails_site_engine/contact_mailer/notify.text.erb +9 -0
- data/app/views/rails_site_engine/contacts/new.html.erb +1 -0
- data/app/views/rails_site_engine/pages/show.html.erb +15 -0
- data/app/views/rails_site_engine/sections/_contact_form.html.erb +112 -0
- data/app/views/rails_site_engine/sections/_cta_band.html.erb +27 -0
- data/app/views/rails_site_engine/sections/_faq.html.erb +29 -0
- data/app/views/rails_site_engine/sections/_feature_cards.html.erb +30 -0
- data/app/views/rails_site_engine/sections/_hero.html.erb +39 -0
- data/app/views/rails_site_engine/sections/_pricing_cards.html.erb +53 -0
- data/app/views/rails_site_engine/sections/_process_steps.html.erb +35 -0
- data/app/views/rails_site_engine/sections/_project_cards.html.erb +48 -0
- data/app/views/rails_site_engine/sections/_proof_strip.html.erb +16 -0
- data/app/views/rails_site_engine/sections/_rich_text.html.erb +15 -0
- data/app/views/rails_site_engine/sections/_stats.html.erb +34 -0
- data/app/views/rails_site_engine/sections/_testimonials.html.erb +40 -0
- data/app/views/rails_site_engine/sections/_unknown.html.erb +6 -0
- data/app/views/rails_site_engine/shared/_flash.html.erb +18 -0
- data/app/views/rails_site_engine/shared/_footer.html.erb +41 -0
- data/app/views/rails_site_engine/shared/_navbar.html.erb +118 -0
- data/config/importmap.rb +5 -0
- data/config/locales/en.yml +27 -0
- data/config/routes.rb +9 -0
- data/config/site_profiles.yml +23 -0
- data/lib/generators/rails_site_engine/install/install_generator.rb +454 -0
- data/lib/generators/rails_site_engine/install/templates/config/theme.yml +3 -0
- data/lib/generators/rails_site_engine/install/templates/content/pages/about.md +12 -0
- data/lib/generators/rails_site_engine/install/templates/content/pages/contact.md +13 -0
- data/lib/generators/rails_site_engine/install/templates/content/pages/home.md +39 -0
- data/lib/generators/rails_site_engine/install/templates/content/pages/privacy.md +43 -0
- data/lib/generators/rails_site_engine/install/templates/content/pages/services.md +25 -0
- data/lib/generators/rails_site_engine/install/templates/content/site.yml +36 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/config/theme.yml +3 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/about.md +14 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/contact.md +12 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/home.md +55 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/privacy.md +43 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/pages/services.md +29 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-carpentry/content/site.yml +36 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/config/theme.yml +3 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/about.md +14 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/contact.md +12 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/home.md +55 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/privacy.md +43 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/pages/services.md +29 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-electrician/content/site.yml +36 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/config/theme.yml +3 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/about.md +14 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/contact.md +12 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/home.md +55 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/privacy.md +43 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/pages/services.md +29 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-general/content/site.yml +36 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/config/theme.yml +3 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/about.md +14 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/contact.md +12 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/home.md +55 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/privacy.md +43 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/pages/services.md +29 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/home-services-plumbing/content/site.yml +36 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/config/theme.yml +3 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/about.md +26 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/contact.md +12 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/home.md +43 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/privacy.md +43 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/pages/work.md +27 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/portfolio/content/site.yml +36 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/config/theme.yml +3 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/about.md +14 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/contact.md +12 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/home.md +48 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/pricing.md +57 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/privacy.md +43 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/pages/projects.md +27 -0
- data/lib/generators/rails_site_engine/install/templates/site_profiles/software-contracting/content/site.yml +38 -0
- data/lib/rails_site_engine/content/store.rb +297 -0
- data/lib/rails_site_engine/engine.rb +15 -0
- data/lib/rails_site_engine/markdown_renderer.rb +37 -0
- data/lib/rails_site_engine/profile_defaults.rb +83 -0
- data/lib/rails_site_engine/site_engine_config.rb +78 -0
- data/lib/rails_site_engine/site_profiles.rb +166 -0
- data/lib/rails_site_engine/sitemap_xml.rb +26 -0
- data/lib/rails_site_engine/theme.rb +54 -0
- data/lib/rails_site_engine/version.rb +3 -0
- data/lib/rails_site_engine.rb +13 -0
- data/lib/tasks/rails_site_engine_tasks.rake +7 -0
- 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
|