Skip to content

Commit f504491

Browse files
committed
feat: implement DocumentationBuilder for creating documentation navigation and associated pages
1 parent f37c709 commit f504491

File tree

5 files changed

+466
-299
lines changed

5 files changed

+466
-299
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# frozen_string_literal: true
2+
3+
# app/builders/better_together/documentation_builder.rb
4+
5+
module BetterTogether
6+
# Builds documentation navigation from markdown files in the docs/ directory
7+
class DocumentationBuilder < Builder # rubocop:todo Metrics/ClassLength
8+
class << self
9+
def build # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
10+
I18n.with_locale(:en) do
11+
entries = documentation_entries
12+
return if entries.blank?
13+
14+
area = if (existing_area = ::BetterTogether::NavigationArea.i18n.find_by(slug: 'documentation'))
15+
existing_area.navigation_items.delete_all
16+
existing_area.update!(name: 'Documentation', visible: true, protected: true)
17+
existing_area
18+
else
19+
::BetterTogether::NavigationArea.create! do |area|
20+
area.name = 'Documentation'
21+
area.slug = 'documentation'
22+
area.visible = true
23+
area.protected = true
24+
end
25+
end
26+
27+
entries.each_with_index do |entry, index|
28+
create_documentation_navigation_item(area, entry, index)
29+
end
30+
31+
area.reload.save!
32+
end
33+
end
34+
35+
private
36+
37+
def documentation_entries
38+
root = documentation_root
39+
return [] unless root.directory?
40+
41+
build_documentation_entries(root)
42+
end
43+
44+
def build_documentation_entries(current_path) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
45+
documentation_child_paths(current_path).filter_map do |child|
46+
if child.directory?
47+
children = build_documentation_entries(child)
48+
default_path = default_documentation_file(child)
49+
next if children.blank? && default_path.blank?
50+
51+
{
52+
type: :directory,
53+
title: documentation_title(child.basename.to_s),
54+
slug: documentation_slug(child),
55+
default_path: default_path,
56+
children: children
57+
}
58+
elsif markdown_file?(child)
59+
{
60+
type: :file,
61+
title: documentation_title(child.basename.sub_ext('').to_s),
62+
slug: documentation_slug(child),
63+
path: documentation_relative_path(child),
64+
children: []
65+
}
66+
end
67+
end
68+
end
69+
70+
def documentation_child_paths(path)
71+
Dir.children(path).sort.map { |child| path.join(child) }.select do |child_path|
72+
next false if child_path.basename.to_s.start_with?('.')
73+
74+
child_path.directory? || markdown_file?(child_path)
75+
end
76+
end
77+
78+
def markdown_file?(path)
79+
path.file? && path.extname.casecmp('.md').zero?
80+
end
81+
82+
def create_documentation_navigation_item(area, entry, position, parent: nil) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
83+
attributes = {
84+
navigation_area: area,
85+
title_en: entry[:title],
86+
position: position,
87+
visible: true,
88+
protected: true,
89+
parent:
90+
}
91+
attributes[:identifier] = entry[:slug] if entry[:slug].present?
92+
93+
if entry[:type] == :directory
94+
attributes[:item_type] = 'dropdown'
95+
if entry[:default_path].present?
96+
attributes[:linkable] = documentation_page_for(entry[:title], entry[:default_path], area)
97+
else
98+
attributes[:url] = '#'
99+
end
100+
item = create_documentation_item_with_context(area, attributes)
101+
entry[:children].each_with_index do |child, index|
102+
create_documentation_navigation_item(area, child, index, parent: item)
103+
end
104+
else
105+
attributes[:item_type] = 'link'
106+
attributes[:linkable] = documentation_page_for(entry[:title], entry[:path], area)
107+
create_documentation_item_with_context(area, attributes)
108+
end
109+
end
110+
111+
def documentation_title(name)
112+
base = name.to_s.sub(/\.md\z/i, '')
113+
return 'Overview' if base.casecmp('readme').zero?
114+
115+
base.tr('_-', ' ').squeeze(' ').strip.titleize
116+
end
117+
118+
def documentation_relative_path(path)
119+
path.relative_path_from(documentation_root).to_s
120+
end
121+
122+
def documentation_url(relative_path)
123+
File.join(documentation_url_prefix, relative_path)
124+
end
125+
126+
def create_documentation_item_with_context(area, attributes)
127+
puts "Creating documentation navigation item #{attributes.inspect}" if ENV['DEBUG_DOCUMENTATION_NAV'] == '1'
128+
area.navigation_items.create!(attributes)
129+
rescue ActiveRecord::RecordInvalid => e
130+
raise ActiveRecord::RecordInvalid.new(e.record), "#{e.message} -- #{attributes.inspect}"
131+
end
132+
133+
def documentation_page_for(title, relative_path, sidebar_nav_area = nil) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
134+
slug = documentation_slug(relative_path)
135+
attrs = documentation_page_attributes(title, slug, relative_path, sidebar_nav_area)
136+
page = ::BetterTogether::Page.i18n.find_by(slug: slug)
137+
138+
if page
139+
locked_page = ::BetterTogether::Page.lock.find(page.id)
140+
locked_page.page_blocks.destroy_all
141+
locked_page.reload
142+
locked_page.assign_attributes(attrs)
143+
locked_page.save!
144+
# Re-set the slug after save in case FriendlyId regenerated it
145+
locked_page.update_columns(slug: slug) if locked_page.slug != slug
146+
locked_page
147+
else
148+
new_page = ::BetterTogether::Page.create!(attrs)
149+
# Re-set the slug after creation in case FriendlyId regenerated it
150+
new_page.slug = slug if new_page.slug != slug
151+
new_page.save!(validate: false) if new_page.changed?
152+
new_page
153+
end
154+
end
155+
156+
def documentation_page_attributes(title, slug, relative_path, sidebar_nav_area = nil) # rubocop:todo Metrics/MethodLength
157+
attrs = {
158+
title_en: title,
159+
slug_en: slug, # Set slug directly via Mobility to bypass FriendlyId normalization
160+
published_at: Time.zone.now,
161+
privacy: 'public',
162+
protected: true,
163+
layout: 'layouts/better_together/full_width_page',
164+
page_blocks_attributes: [
165+
{
166+
block_attributes: {
167+
type: 'BetterTogether::Content::Markdown',
168+
markdown_file_path: documentation_file_path(relative_path)
169+
}
170+
}
171+
]
172+
}
173+
174+
# Associate the documentation navigation area as the sidebar nav
175+
attrs[:sidebar_nav] = sidebar_nav_area if sidebar_nav_area.present?
176+
177+
attrs
178+
end
179+
180+
def documentation_slug(path)
181+
relative = path.is_a?(Pathname) ? documentation_relative_path(path) : path.to_s
182+
# Remove .md extension, downcase, and preserve directory structure with slashes
183+
base_slug = relative.sub(/\.md\z/i, '').downcase.tr('_', '-')
184+
base_slug = 'overview' if base_slug.blank?
185+
"docs/#{base_slug}"
186+
end
187+
188+
def documentation_file_path(relative_path)
189+
documentation_root.join(relative_path).to_s
190+
end
191+
192+
def default_documentation_file(path)
193+
%w[README.md readme.md index.md INDEX.md].each do |filename|
194+
file_path = path.join(filename)
195+
return documentation_relative_path(file_path) if file_path.exist?
196+
end
197+
nil
198+
end
199+
200+
def documentation_root
201+
BetterTogether::Engine.root.join('docs')
202+
end
203+
204+
def documentation_url_prefix
205+
'/docs'
206+
end
207+
end
208+
end
209+
end

0 commit comments

Comments
 (0)