Skip to content

Commit 0ece290

Browse files
committed
feat: add markdown localization support with auto-sync option and enhanced rendering
1 parent 7c6d594 commit 0ece290

File tree

9 files changed

+613
-38
lines changed

9 files changed

+613
-38
lines changed

app/helpers/better_together/markdown_helper.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,55 @@ def render_markdown_plain(source, options = {})
5757

5858
MarkdownRendererService.new(source, options).render_plain_text
5959
end
60+
61+
# Renders markdown with automatic locale detection for file paths
62+
#
63+
# @param file [String, nil] Path to markdown file (with automatic locale detection)
64+
# @param text [String, nil] Direct markdown text content
65+
# @param locale [Symbol] Locale to use (defaults to current locale)
66+
# @param options [Hash] Optional rendering options to pass to MarkdownRendererService
67+
# @return [ActiveSupport::SafeBuffer] HTML-safe rendered markdown
68+
#
69+
# @example
70+
# <%= render_markdown_block(file: 'policies/privacy') %>
71+
# <%= render_markdown_block(text: t('content.welcome')) %>
72+
def render_markdown_block(file: nil, text: nil, locale: I18n.locale, options: {})
73+
I18n.with_locale(locale) do
74+
content = if file.present?
75+
read_localized_file(file)
76+
else
77+
text
78+
end
79+
80+
render_markdown(content, options)
81+
end
82+
end
83+
84+
private
85+
86+
# Read a localized markdown file with automatic locale detection
87+
#
88+
# @param base_path [String] Base path to the markdown file (without locale extension)
89+
# @return [String] File content or empty string if not found
90+
def read_localized_file(base_path)
91+
# Remove .md extension if present
92+
base = base_path.sub(/\.(md|markdown)$/i, '')
93+
94+
# Try current locale, then fallback to English, then original
95+
[
96+
"#{base}.#{I18n.locale}.md",
97+
"#{base}.en.md",
98+
"#{base}.md"
99+
].each do |filename|
100+
path = Rails.root.join('app', 'views', filename)
101+
return File.read(path) if File.exist?(path)
102+
end
103+
104+
Rails.logger.warn("Markdown file not found: #{base_path}")
105+
''
106+
rescue Errno::ENOENT => e
107+
Rails.logger.error("Failed to read localized markdown file: #{e.message}")
108+
''
109+
end
60110
end
61111
end

app/models/better_together/content/markdown.rb

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,44 @@
33
module BetterTogether
44
module Content
55
# Renders markdown content from source or file
6-
class Markdown < Block
6+
class Markdown < Block # rubocop:disable Metrics/ClassLength
7+
include Translatable
8+
9+
translates :markdown_source, type: :text
10+
711
store_attributes :content_data do
8-
markdown_source String
912
markdown_file_path String
13+
auto_sync_from_file Boolean, default: false
1014
end
1115

1216
validate :markdown_source_or_file_path_present
1317
validate :file_must_exist, if: :markdown_file_path?
1418
validate :file_must_be_markdown, if: :markdown_file_path?
1519

20+
# Load file content before validation if file path changed
21+
before_validation :load_file_into_source,
22+
if: -> { markdown_file_path_changed? && auto_sync_from_file? }
23+
1624
# Define permitted attributes for controller strong parameters
1725
def self.permitted_attributes
18-
%i[markdown_source markdown_file_path]
26+
%i[markdown_source markdown_file_path auto_sync_from_file]
1927
end
2028

2129
# Get markdown content from either source or file
2230
def content
23-
if markdown_source.present?
24-
markdown_source
25-
elsif markdown_file_path.present?
26-
load_markdown_file
27-
else
28-
''
29-
end
31+
return markdown_source if markdown_source.present? && !auto_sync_from_file?
32+
return load_markdown_file_for_current_locale if markdown_file_path.present?
33+
34+
''
35+
end
36+
37+
# Manually import file content into markdown_source for all locales
38+
def import_file_content!(sync_future_changes: false)
39+
return false unless markdown_file_path.present?
40+
41+
load_localized_files
42+
self.auto_sync_from_file = sync_future_changes
43+
save!
3044
end
3145

3246
# Render markdown content as HTML
@@ -47,8 +61,10 @@ def rendered_plain_text
4761
def as_indexed_json(_options = {})
4862
{
4963
id:,
50-
localized_content: I18n.available_locales.index_with do |_locale|
51-
rendered_plain_text
64+
localized_content: I18n.available_locales.index_with do |locale|
65+
I18n.with_locale(locale) do
66+
rendered_plain_text
67+
end
5268
end
5369
}
5470
end
@@ -61,6 +77,56 @@ def markdown_source_or_file_path_present
6177
errors.add(:base, 'Either markdown source or file path must be provided')
6278
end
6379

80+
def load_file_into_source
81+
load_localized_files
82+
end
83+
84+
def load_localized_files # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
85+
base_path = markdown_file_path.sub(/\.(md|markdown)$/i, '')
86+
87+
I18n.available_locales.each do |locale|
88+
load_locale_file(base_path, locale)
89+
end
90+
end
91+
92+
def load_locale_file(base_path, locale)
93+
# Try locale-specific file first
94+
locale_file = "#{base_path}.#{locale}.md"
95+
resolved_path = resolve_file_path_for(locale_file)
96+
97+
if File.exist?(resolved_path)
98+
set_markdown_for_locale(locale, resolved_path)
99+
elsif locale == I18n.default_locale
100+
load_default_file_for_locale(locale)
101+
end
102+
end
103+
104+
def set_markdown_for_locale(locale, file_path)
105+
I18n.with_locale(locale) do
106+
self.markdown_source = File.read(file_path)
107+
end
108+
end
109+
110+
def load_default_file_for_locale(locale)
111+
default_path = resolve_file_path
112+
return unless File.exist?(default_path)
113+
114+
set_markdown_for_locale(locale, default_path)
115+
end
116+
117+
def load_markdown_file_for_current_locale
118+
base_path = markdown_file_path.sub(/\.(md|markdown)$/i, '')
119+
locale_file = "#{base_path}.#{I18n.locale}.md"
120+
121+
# Try locale-specific, fallback to default
122+
[locale_file, markdown_file_path].each do |file|
123+
path = resolve_file_path_for(file)
124+
return File.read(path) if File.exist?(path)
125+
end
126+
127+
''
128+
end
129+
64130
def load_markdown_file
65131
file_path = resolve_file_path
66132
return '' unless File.exist?(file_path)
@@ -74,6 +140,12 @@ def resolve_file_path
74140
Rails.root.join(markdown_file_path)
75141
end
76142

143+
def resolve_file_path_for(path)
144+
return Pathname.new(path) if Pathname.new(path).absolute?
145+
146+
Rails.root.join(path)
147+
end
148+
77149
def file_must_exist
78150
file_path = resolve_file_path
79151
return if File.exist?(file_path)

app/views/better_together/content/blocks/fields/_markdown.html.erb

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
data-controller="better-together--markdown-block better-together--dependent-fields">
1010
<!-- Radio buttons to choose between source or file -->
1111
<div class="mb-3">
12-
<%= label_tag nil, 'Markdown Source', class: 'form-label' %>
12+
<%= label_tag nil, t('better_together.content.blocks.markdown.fields.markdown_source'), class: 'form-label' %>
1313
<div class="form-check">
1414
<%= radio_button_tag "#{scope}[markdown_source_type]", 'inline', inline_selected,
1515
class: 'form-check-input',
@@ -19,7 +19,7 @@
1919
'better-together--markdown-block-target': 'sourceTypeRadio',
2020
'better-together--dependent-fields-target': 'controlField'
2121
} %>
22-
<%= label_tag inline_radio_id, 'Write Markdown Inline', class: 'form-check-label' %>
22+
<%= label_tag inline_radio_id, t('better_together.content.blocks.markdown.fields.write_inline'), class: 'form-check-label' %>
2323
</div>
2424
<div class="form-check">
2525
<%= radio_button_tag "#{scope}[markdown_source_type]", 'file', block.markdown_file_path.present?,
@@ -30,33 +30,27 @@
3030
'better-together--markdown-block-target': 'sourceTypeRadio',
3131
'better-together--dependent-fields-target': 'controlField'
3232
} %>
33-
<%= label_tag file_radio_id, 'Reference a Markdown File', class: 'form-check-label' %>
33+
<%= label_tag file_radio_id, t('better_together.content.blocks.markdown.fields.reference_file'), class: 'form-check-label' %>
3434
</div>
3535
</div>
3636

37-
<!-- Inline markdown source textarea -->
37+
<!-- Inline markdown source textarea with locale support -->
3838
<div class="mb-3 markdown-inline-field <%= 'hidden-field' unless inline_selected %>"
3939
data-better-together--markdown-block-target="inlineField"
4040
data-better-together--dependent-fields-target="dependentField"
4141
data-dependent-fields-control="<%= inline_radio_id %>"
4242
data-show-if-control_<%= inline_radio_id %>="inline">
43-
<%= label_tag "#{scope}[markdown_source]", 'Markdown Content', class: 'form-label' %>
44-
<%= text_area_tag "#{scope}[markdown_source]",
45-
block.markdown_source,
46-
class: "form-control font-monospace#{' is-invalid' if block.errors[:markdown_source].any?}",
47-
rows: 12,
48-
placeholder: "# Your Markdown Here\n\nWrite your markdown content...",
49-
data: { 'better-together--markdown-block-target': 'sourceTextarea' },
50-
disabled: !inline_selected %>
5143

52-
<% if block.errors[:markdown_source].any? %>
53-
<div class="invalid-feedback">
54-
<%= block.errors[:markdown_source].join(", ") %>
55-
</div>
56-
<% end %>
44+
<%= render partial: 'better_together/content/blocks/fields/shared/translatable_text_field',
45+
locals: {
46+
model: block,
47+
scope: scope,
48+
temp_id: temp_id,
49+
attribute: 'markdown_source'
50+
} %>
5751

5852
<small class="form-text text-muted mt-2">
59-
Write your markdown content directly. Supports GitHub-flavored markdown including tables, code blocks, and more.
53+
<%= t('better_together.content.blocks.markdown.help.inline_content') %>
6054
</small>
6155
</div>
6256

@@ -66,11 +60,11 @@
6660
data-better-together--dependent-fields-target="dependentField"
6761
data-dependent-fields-control="<%= file_radio_id %>"
6862
data-show-if-control_<%= file_radio_id %>="file">
69-
<%= label_tag "#{scope}[markdown_file_path]", 'Markdown File Path', class: 'form-label' %>
63+
<%= label_tag "#{scope}[markdown_file_path]", t('better_together.content.blocks.markdown.fields.file_path'), class: 'form-label' %>
7064
<%= text_field_tag "#{scope}[markdown_file_path]",
7165
block.markdown_file_path,
7266
class: "form-control font-monospace#{' is-invalid' if block.errors[:markdown_file_path].any?}",
73-
placeholder: "docs/README.md or /absolute/path/to/file.md",
67+
placeholder: t('better_together.content.blocks.markdown.fields.file_path_placeholder'),
7468
data: { 'better-together--markdown-block-target': 'filePathInput' },
7569
disabled: inline_selected %>
7670

@@ -81,18 +75,59 @@
8175
<% end %>
8276

8377
<small class="form-text text-muted mt-2">
84-
Path to a markdown file. Can be relative to Rails.root (e.g., <code>docs/README.md</code>) or absolute (e.g., <code>/path/to/file.md</code>). File must have a .md or .markdown extension.
78+
<%= t('better_together.content.blocks.markdown.help.file_path') %>
79+
<br>
80+
<strong><%= t('better_together.content.blocks.markdown.help.localized_files') %></strong>
8581
</small>
82+
83+
<!-- Auto-sync from file option -->
84+
<div class="form-check mt-3">
85+
<%= check_box_tag "#{scope}[auto_sync_from_file]",
86+
'1',
87+
block.auto_sync_from_file,
88+
class: 'form-check-input',
89+
id: "#{temp_id}_auto_sync",
90+
disabled: inline_selected %>
91+
<%= label_tag "#{temp_id}_auto_sync",
92+
t('better_together.content.blocks.markdown.fields.auto_sync'),
93+
class: 'form-check-label' %>
94+
<small class="form-text text-muted d-block">
95+
<%= t('better_together.content.blocks.markdown.help.auto_sync') %>
96+
</small>
97+
</div>
98+
99+
<!-- Available translations indicator -->
100+
<% if block.markdown_file_path.present? %>
101+
<div class="alert alert-info mt-3 mb-0">
102+
<strong><i class="fa fa-info-circle"></i> <%= t('better_together.content.blocks.markdown.help.available_translations') %></strong>
103+
<%
104+
base_path = block.markdown_file_path.sub(/\.(md|markdown)$/i, '')
105+
I18n.available_locales.each do |locale|
106+
locale_file = "#{base_path}.#{locale}.md"
107+
# Try to resolve the file path
108+
file_path = if Pathname.new(locale_file).absolute?
109+
Pathname.new(locale_file)
110+
else
111+
Rails.root.join(locale_file)
112+
end
113+
file_exists = File.exist?(file_path)
114+
%>
115+
<span class="badge <%= file_exists ? 'bg-success' : 'bg-secondary' %> me-1">
116+
<%= locale.upcase %> <%= file_exists ? '✓' : '✗' %>
117+
</span>
118+
<% end %>
119+
</div>
120+
<% end %>
86121
</div>
87122

88123
<!-- Preview area -->
89124
<div class="mb-3">
90125
<div class="d-flex justify-content-between align-items-center mb-2">
91-
<%= label_tag nil, 'Preview', class: 'form-label mb-0' %>
126+
<%= label_tag nil, t('better_together.content.blocks.markdown.fields.preview'), class: 'form-label mb-0' %>
92127
<button type="button"
93128
class="btn btn-sm btn-outline-secondary"
94129
data-action="click->better-together--markdown-block#refreshPreview">
95-
<i class="fa fa-refresh"></i> Refresh Preview
130+
<i class="fa fa-refresh"></i> <%= t('better_together.content.blocks.markdown.fields.refresh_preview') %>
96131
</button>
97132
</div>
98133
<div class="border rounded p-3 bg-light markdown-content"
@@ -102,7 +137,7 @@
102137
<%= block.rendered_html %>
103138
<% else %>
104139
<p class="text-muted mb-0">
105-
<em>Preview will appear here...</em>
140+
<em><%= t('better_together.content.blocks.markdown.help.preview_placeholder') %></em>
106141
</p>
107142
<% end %>
108143
</div>

config/locales/en.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,31 @@ en:
722722
content:
723723
blocks:
724724
associated_pages: Associated Pages
725+
markdown:
726+
fields:
727+
auto_sync: Always load from file (ignore database content)
728+
file_path: Markdown File Path
729+
file_path_placeholder: docs/README.md or /absolute/path/to/file.md
730+
markdown_source: Markdown Source
731+
preview: Preview
732+
reference_file: Reference a Markdown File
733+
refresh_preview: Refresh Preview
734+
write_inline: Write Markdown Inline
735+
help:
736+
auto_sync: When checked, content is always loaded from the file. When
737+
unchecked, file content is imported into the database and can be edited
738+
separately.
739+
available_translations: 'Available translations:'
740+
file_path: Path to a markdown file. Can be relative to Rails.root (e.g.,
741+
docs/README.md) or absolute (e.g., /path/to/file.md). File must have
742+
a .md or .markdown extension.
743+
inline_content: Write your markdown content directly. Supports GitHub-flavored
744+
markdown including tables, code blocks, and more. Content is translatable
745+
for each locale.
746+
localized_files: The system automatically looks for locale-specific versions
747+
(e.g., privacy.en.md, privacy.es.md, privacy.fr.md) and falls back to
748+
the base file if not found.
749+
preview_placeholder: Preview will appear here...
725750
conversation_mailer:
726751
new_message_notification:
727752
from_address: New message via %{from_address}

0 commit comments

Comments
 (0)