docyard 0.6.0 → 0.8.0

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 (177) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -1
  3. data/CHANGELOG.md +34 -1
  4. data/lib/docyard/build/asset_bundler.rb +22 -7
  5. data/lib/docyard/build/file_copier.rb +49 -27
  6. data/lib/docyard/build/sitemap_generator.rb +6 -6
  7. data/lib/docyard/build/static_generator.rb +82 -50
  8. data/lib/docyard/builder.rb +20 -10
  9. data/lib/docyard/cli.rb +6 -3
  10. data/lib/docyard/components/aliases.rb +29 -0
  11. data/lib/docyard/components/processors/callout_processor.rb +124 -0
  12. data/lib/docyard/components/processors/code_block_diff_preprocessor.rb +106 -0
  13. data/lib/docyard/components/processors/code_block_focus_preprocessor.rb +79 -0
  14. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +78 -0
  15. data/lib/docyard/components/processors/code_block_processor.rb +175 -0
  16. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +127 -0
  17. data/lib/docyard/components/processors/heading_anchor_processor.rb +39 -0
  18. data/lib/docyard/components/processors/icon_processor.rb +53 -0
  19. data/lib/docyard/components/processors/table_of_contents_processor.rb +68 -0
  20. data/lib/docyard/components/processors/table_wrapper_processor.rb +22 -0
  21. data/lib/docyard/components/processors/tabs_processor.rb +48 -0
  22. data/lib/docyard/components/support/code_block/feature_extractor.rb +117 -0
  23. data/lib/docyard/components/support/code_block/icon_detector.rb +44 -0
  24. data/lib/docyard/components/support/code_block/line_parser.rb +84 -0
  25. data/lib/docyard/components/support/code_block/line_wrapper.rb +50 -0
  26. data/lib/docyard/components/support/code_block/patterns.rb +55 -0
  27. data/lib/docyard/components/support/code_detector.rb +61 -0
  28. data/lib/docyard/components/support/tabs/icon_detector.rb +62 -0
  29. data/lib/docyard/components/support/tabs/parser.rb +195 -0
  30. data/lib/docyard/components/support/tabs/range_finder.rb +46 -0
  31. data/lib/docyard/config/branding_resolver.rb +183 -0
  32. data/lib/docyard/{constants.rb → config/constants.rb} +7 -4
  33. data/lib/docyard/config/validator.rb +122 -99
  34. data/lib/docyard/config.rb +38 -36
  35. data/lib/docyard/initializer.rb +15 -76
  36. data/lib/docyard/navigation/breadcrumb_builder.rb +133 -0
  37. data/lib/docyard/{prev_next_builder.rb → navigation/prev_next_builder.rb} +6 -3
  38. data/lib/docyard/navigation/sidebar/children_discoverer.rb +51 -0
  39. data/lib/docyard/navigation/sidebar/config_parser.rb +208 -0
  40. data/lib/docyard/navigation/sidebar/file_resolver.rb +78 -0
  41. data/lib/docyard/{sidebar → navigation/sidebar}/file_system_scanner.rb +2 -1
  42. data/lib/docyard/navigation/sidebar/item.rb +96 -0
  43. data/lib/docyard/navigation/sidebar/local_config_loader.rb +51 -0
  44. data/lib/docyard/navigation/sidebar/metadata_extractor.rb +69 -0
  45. data/lib/docyard/navigation/sidebar/metadata_reader.rb +47 -0
  46. data/lib/docyard/navigation/sidebar/path_prefixer.rb +34 -0
  47. data/lib/docyard/navigation/sidebar/renderer.rb +144 -0
  48. data/lib/docyard/navigation/sidebar/sorter.rb +21 -0
  49. data/lib/docyard/navigation/sidebar/tree_builder.rb +139 -0
  50. data/lib/docyard/navigation/sidebar/tree_filter.rb +55 -0
  51. data/lib/docyard/navigation/sidebar_builder.rb +159 -0
  52. data/lib/docyard/rendering/icon_helpers.rb +13 -0
  53. data/lib/docyard/{icons → rendering/icons}/phosphor.rb +26 -1
  54. data/lib/docyard/{markdown.rb → rendering/markdown.rb} +19 -13
  55. data/lib/docyard/rendering/renderer.rb +163 -0
  56. data/lib/docyard/rendering/template_resolver.rb +172 -0
  57. data/lib/docyard/routing/fallback_resolver.rb +92 -0
  58. data/lib/docyard/search/build_indexer.rb +74 -0
  59. data/lib/docyard/search/dev_indexer.rb +155 -0
  60. data/lib/docyard/search/pagefind_support.rb +33 -0
  61. data/lib/docyard/{asset_handler.rb → server/asset_handler.rb} +24 -19
  62. data/lib/docyard/{server.rb → server/dev_server.rb} +32 -9
  63. data/lib/docyard/server/pagefind_handler.rb +63 -0
  64. data/lib/docyard/{preview_server.rb → server/preview_server.rb} +2 -2
  65. data/lib/docyard/server/rack_application.rb +192 -0
  66. data/lib/docyard/server/resolution_result.rb +29 -0
  67. data/lib/docyard/{router.rb → server/router.rb} +4 -4
  68. data/lib/docyard/templates/assets/css/code.css +18 -51
  69. data/lib/docyard/templates/assets/css/components/breadcrumbs.css +143 -0
  70. data/lib/docyard/templates/assets/css/components/callout.css +67 -67
  71. data/lib/docyard/templates/assets/css/components/code-block.css +180 -282
  72. data/lib/docyard/templates/assets/css/components/heading-anchor.css +28 -15
  73. data/lib/docyard/templates/assets/css/components/icon.css +0 -1
  74. data/lib/docyard/templates/assets/css/components/logo.css +0 -2
  75. data/lib/docyard/templates/assets/css/components/nav-menu.css +237 -0
  76. data/lib/docyard/templates/assets/css/components/navigation.css +186 -167
  77. data/lib/docyard/templates/assets/css/components/prev-next.css +76 -47
  78. data/lib/docyard/templates/assets/css/components/search.css +561 -0
  79. data/lib/docyard/templates/assets/css/components/tab-bar.css +163 -0
  80. data/lib/docyard/templates/assets/css/components/table-of-contents.css +127 -114
  81. data/lib/docyard/templates/assets/css/components/tabs.css +119 -160
  82. data/lib/docyard/templates/assets/css/components/theme-toggle.css +48 -44
  83. data/lib/docyard/templates/assets/css/landing.css +815 -0
  84. data/lib/docyard/templates/assets/css/layout.css +503 -87
  85. data/lib/docyard/templates/assets/css/main.css +1 -3
  86. data/lib/docyard/templates/assets/css/markdown.css +111 -93
  87. data/lib/docyard/templates/assets/css/reset.css +0 -3
  88. data/lib/docyard/templates/assets/css/typography.css +43 -41
  89. data/lib/docyard/templates/assets/css/variables.css +268 -208
  90. data/lib/docyard/templates/assets/favicon.svg +7 -8
  91. data/lib/docyard/templates/assets/fonts/Inter-Variable.ttf +0 -0
  92. data/lib/docyard/templates/assets/js/components/code-block.js +24 -42
  93. data/lib/docyard/templates/assets/js/components/heading-anchor.js +26 -24
  94. data/lib/docyard/templates/assets/js/components/navigation.js +181 -70
  95. data/lib/docyard/templates/assets/js/components/search.js +610 -0
  96. data/lib/docyard/templates/assets/js/components/sidebar-toggle.js +29 -0
  97. data/lib/docyard/templates/assets/js/components/tab-navigation.js +145 -0
  98. data/lib/docyard/templates/assets/js/components/table-of-contents.js +153 -66
  99. data/lib/docyard/templates/assets/js/components/tabs.js +31 -69
  100. data/lib/docyard/templates/assets/js/theme.js +0 -3
  101. data/lib/docyard/templates/assets/logo-dark.svg +8 -2
  102. data/lib/docyard/templates/assets/logo.svg +7 -4
  103. data/lib/docyard/templates/config/docyard.yml.erb +37 -34
  104. data/lib/docyard/templates/errors/404.html.erb +1 -1
  105. data/lib/docyard/templates/errors/500.html.erb +1 -1
  106. data/lib/docyard/templates/layouts/default.html.erb +19 -56
  107. data/lib/docyard/templates/layouts/splash.html.erb +176 -0
  108. data/lib/docyard/templates/partials/_breadcrumbs.html.erb +24 -0
  109. data/lib/docyard/templates/partials/_code_block.html.erb +6 -4
  110. data/lib/docyard/templates/partials/_doc_footer.html.erb +25 -0
  111. data/lib/docyard/templates/partials/_features.html.erb +15 -0
  112. data/lib/docyard/templates/partials/_footer.html.erb +42 -0
  113. data/lib/docyard/templates/partials/_head.html.erb +22 -0
  114. data/lib/docyard/templates/partials/_header.html.erb +49 -0
  115. data/lib/docyard/templates/partials/_heading_anchor.html.erb +3 -1
  116. data/lib/docyard/templates/partials/_hero.html.erb +27 -0
  117. data/lib/docyard/templates/partials/_nav_group.html.erb +25 -11
  118. data/lib/docyard/templates/partials/_nav_leaf.html.erb +1 -1
  119. data/lib/docyard/templates/partials/_nav_menu.html.erb +42 -0
  120. data/lib/docyard/templates/partials/_nav_nested_section.html.erb +11 -0
  121. data/lib/docyard/templates/partials/_nav_section.html.erb +1 -1
  122. data/lib/docyard/templates/partials/_prev_next.html.erb +9 -3
  123. data/lib/docyard/templates/partials/_scripts.html.erb +7 -0
  124. data/lib/docyard/templates/partials/_search_modal.html.erb +41 -0
  125. data/lib/docyard/templates/partials/_search_trigger.html.erb +18 -0
  126. data/lib/docyard/templates/partials/_sidebar.html.erb +21 -4
  127. data/lib/docyard/templates/partials/_tab_bar.html.erb +25 -0
  128. data/lib/docyard/templates/partials/_table_of_contents.html.erb +12 -12
  129. data/lib/docyard/templates/partials/_table_of_contents_toggle.html.erb +1 -3
  130. data/lib/docyard/templates/partials/_tabs.html.erb +2 -2
  131. data/lib/docyard/templates/partials/_theme_toggle.html.erb +2 -11
  132. data/lib/docyard/utils/html_helpers.rb +14 -0
  133. data/lib/docyard/utils/path_resolver.rb +2 -1
  134. data/lib/docyard/utils/url_helpers.rb +20 -0
  135. data/lib/docyard/version.rb +1 -1
  136. data/lib/docyard.rb +22 -15
  137. metadata +89 -50
  138. data/lib/docyard/components/callout_processor.rb +0 -121
  139. data/lib/docyard/components/code_block_diff_preprocessor.rb +0 -104
  140. data/lib/docyard/components/code_block_feature_extractor.rb +0 -113
  141. data/lib/docyard/components/code_block_focus_preprocessor.rb +0 -77
  142. data/lib/docyard/components/code_block_icon_detector.rb +0 -40
  143. data/lib/docyard/components/code_block_line_wrapper.rb +0 -46
  144. data/lib/docyard/components/code_block_options_preprocessor.rb +0 -76
  145. data/lib/docyard/components/code_block_patterns.rb +0 -51
  146. data/lib/docyard/components/code_block_processor.rb +0 -176
  147. data/lib/docyard/components/code_detector.rb +0 -59
  148. data/lib/docyard/components/code_line_parser.rb +0 -80
  149. data/lib/docyard/components/code_snippet_import_preprocessor.rb +0 -125
  150. data/lib/docyard/components/heading_anchor_processor.rb +0 -34
  151. data/lib/docyard/components/icon_detector.rb +0 -57
  152. data/lib/docyard/components/icon_processor.rb +0 -51
  153. data/lib/docyard/components/table_of_contents_processor.rb +0 -64
  154. data/lib/docyard/components/table_wrapper_processor.rb +0 -18
  155. data/lib/docyard/components/tabs_parser.rb +0 -191
  156. data/lib/docyard/components/tabs_processor.rb +0 -44
  157. data/lib/docyard/components/tabs_range_finder.rb +0 -42
  158. data/lib/docyard/rack_application.rb +0 -172
  159. data/lib/docyard/renderer.rb +0 -120
  160. data/lib/docyard/routing/resolution_result.rb +0 -31
  161. data/lib/docyard/sidebar/config_parser.rb +0 -180
  162. data/lib/docyard/sidebar/item.rb +0 -58
  163. data/lib/docyard/sidebar/renderer.rb +0 -137
  164. data/lib/docyard/sidebar/tree_builder.rb +0 -59
  165. data/lib/docyard/sidebar_builder.rb +0 -102
  166. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +0 -77
  167. data/lib/docyard/templates/markdown/guides/configuration.md.erb +0 -202
  168. data/lib/docyard/templates/markdown/guides/markdown-features.md.erb +0 -247
  169. data/lib/docyard/templates/markdown/index.md.erb +0 -82
  170. /data/lib/docyard/{sidebar → navigation/sidebar}/title_extractor.rb +0 -0
  171. /data/lib/docyard/{icons → rendering/icons}/LICENSE.phosphor +0 -0
  172. /data/lib/docyard/{icons → rendering/icons}/file_types.rb +0 -0
  173. /data/lib/docyard/{icons.rb → rendering/icons.rb} +0 -0
  174. /data/lib/docyard/{language_mapping.rb → rendering/language_mapping.rb} +0 -0
  175. /data/lib/docyard/{file_watcher.rb → server/file_watcher.rb} +0 -0
  176. /data/lib/docyard/{errors.rb → utils/errors.rb} +0 -0
  177. /data/lib/docyard/{logging.rb → utils/logging.rb} +0 -0
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require_relative "../config/constants"
5
+ require_relative "icon_helpers"
6
+
7
+ module Docyard
8
+ class Renderer
9
+ include Utils::UrlHelpers
10
+ include IconHelpers
11
+
12
+ LAYOUTS_PATH = File.join(__dir__, "../templates", "layouts")
13
+ ERRORS_PATH = File.join(__dir__, "../templates", "errors")
14
+ PARTIALS_PATH = File.join(__dir__, "../templates", "partials")
15
+ DEFAULT_LAYOUT = "default"
16
+
17
+ attr_reader :base_url, :config
18
+
19
+ def initialize(base_url: "/", config: nil)
20
+ @base_url = normalize_base_url(base_url)
21
+ @config = config
22
+ end
23
+
24
+ def render_file(file_path, sidebar_html: "", prev_next_html: "", breadcrumbs: nil, branding: {},
25
+ template_options: {}, current_path: "/")
26
+ markdown = Markdown.new(File.read(file_path), config: config)
27
+
28
+ render(
29
+ content: strip_md_from_links(markdown.html),
30
+ page_title: markdown.title || Constants::DEFAULT_SITE_TITLE,
31
+ navigation: build_navigation(sidebar_html, prev_next_html, markdown.toc, breadcrumbs),
32
+ branding: branding,
33
+ template_options: template_options,
34
+ current_path: current_path
35
+ )
36
+ end
37
+
38
+ def build_navigation(sidebar_html, prev_next_html, toc, breadcrumbs)
39
+ { sidebar_html: sidebar_html, prev_next_html: prev_next_html, toc: toc, breadcrumbs: breadcrumbs }
40
+ end
41
+
42
+ def render(content:, page_title: Constants::DEFAULT_SITE_TITLE, navigation: {}, branding: {},
43
+ template_options: {}, current_path: "/")
44
+ layout = template_options[:template] || DEFAULT_LAYOUT
45
+ layout_path = File.join(LAYOUTS_PATH, "#{layout}.html.erb")
46
+ template = File.read(layout_path)
47
+
48
+ assign_content_variables(content, page_title, navigation)
49
+ assign_branding_variables(branding, current_path)
50
+ assign_template_variables(template_options)
51
+
52
+ ERB.new(template).result(binding)
53
+ end
54
+
55
+ def render_not_found
56
+ render_error_template(404)
57
+ end
58
+
59
+ def render_server_error(error)
60
+ @error_message = error.message
61
+ @backtrace = error.backtrace.join("\n")
62
+ render_error_template(500)
63
+ end
64
+
65
+ def render_error_template(status)
66
+ error_template_path = File.join(ERRORS_PATH, "#{status}.html.erb")
67
+ template = File.read(error_template_path)
68
+ ERB.new(template).result(binding)
69
+ end
70
+
71
+ def render_partial(name, locals = {})
72
+ partial_path = File.join(PARTIALS_PATH, "#{name}.html.erb")
73
+ template = File.read(partial_path)
74
+
75
+ locals.each { |key, value| instance_variable_set("@#{key}", value) }
76
+
77
+ ERB.new(template).result(binding)
78
+ end
79
+
80
+ def asset_path(path)
81
+ return path if path.nil? || path.start_with?("http://", "https://")
82
+
83
+ "#{base_url}#{path}"
84
+ end
85
+
86
+ private
87
+
88
+ def assign_content_variables(content, page_title, navigation)
89
+ @content = content
90
+ @page_title = page_title
91
+ @sidebar_html = navigation[:sidebar_html] || ""
92
+ @prev_next_html = navigation[:prev_next_html] || ""
93
+ @toc = navigation[:toc] || []
94
+ @breadcrumbs = navigation[:breadcrumbs]
95
+ end
96
+
97
+ def assign_branding_variables(branding, current_path = "/")
98
+ assign_site_branding(branding)
99
+ assign_search_options(branding)
100
+ assign_credits_and_social(branding)
101
+ assign_tabs(branding, current_path)
102
+ end
103
+
104
+ def assign_site_branding(branding)
105
+ @site_title = branding[:site_title] || Constants::DEFAULT_SITE_TITLE
106
+ @site_description = branding[:site_description] || ""
107
+ @logo = branding[:logo] || Constants::DEFAULT_LOGO_PATH
108
+ @logo_dark = branding[:logo_dark]
109
+ @favicon = branding[:favicon] || Constants::DEFAULT_FAVICON_PATH
110
+ @has_custom_logo = branding[:has_custom_logo] || false
111
+ end
112
+
113
+ def assign_search_options(branding)
114
+ @search_enabled = branding[:search_enabled].nil? || branding[:search_enabled]
115
+ @search_placeholder = branding[:search_placeholder] || "Search documentation..."
116
+ end
117
+
118
+ def assign_credits_and_social(branding)
119
+ @credits = branding[:credits] != false
120
+ @copyright = branding[:copyright]
121
+ @social = branding[:social] || []
122
+ @header_ctas = branding[:header_ctas] || []
123
+ end
124
+
125
+ def assign_tabs(branding, current_path)
126
+ tabs = branding[:tabs] || []
127
+ @tabs = tabs.map { |tab| tab.merge(active: tab_active?(tab[:href], current_path)) }
128
+ @has_tabs = branding[:has_tabs] || false
129
+ @current_path = current_path
130
+ end
131
+
132
+ def tab_active?(tab_href, current_path)
133
+ return false if tab_href.nil? || current_path.nil?
134
+ return false if tab_href.start_with?("http://", "https://")
135
+
136
+ normalized_tab = tab_href.chomp("/")
137
+ normalized_current = current_path.chomp("/")
138
+
139
+ return true if normalized_tab == normalized_current
140
+
141
+ current_path.start_with?("#{normalized_tab}/")
142
+ end
143
+
144
+ def assign_template_variables(template_options)
145
+ @hero = template_options[:hero]
146
+ @features = template_options[:features]
147
+ @features_header = template_options[:features_header]
148
+ @show_sidebar = template_options.fetch(:show_sidebar, true)
149
+ @show_toc = template_options.fetch(:show_toc, true)
150
+ assign_footer_from_landing(template_options[:footer])
151
+ end
152
+
153
+ def assign_footer_from_landing(footer)
154
+ return unless footer
155
+
156
+ @footer_links = footer[:links]
157
+ end
158
+
159
+ def strip_md_from_links(html)
160
+ html.gsub(/href="([^"]+)\.md"/, 'href="\1"')
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class TemplateResolver
5
+ BACKGROUNDS = %w[grid glow mesh none].freeze
6
+ DEFAULT_BACKGROUND = "grid"
7
+
8
+ attr_reader :frontmatter, :site_config
9
+
10
+ def initialize(frontmatter, site_config = {})
11
+ @frontmatter = frontmatter || {}
12
+ @site_config = site_config || {}
13
+ end
14
+
15
+ def landing?
16
+ landing_config.any?
17
+ end
18
+
19
+ def template
20
+ landing? ? "splash" : "default"
21
+ end
22
+
23
+ def show_sidebar?
24
+ if landing?
25
+ landing_config.fetch("sidebar", false)
26
+ else
27
+ true
28
+ end
29
+ end
30
+
31
+ def show_toc?
32
+ return false if landing?
33
+
34
+ true
35
+ end
36
+
37
+ def hero_config
38
+ return nil unless landing?
39
+
40
+ hero = landing_config["hero"]
41
+ return nil unless hero.is_a?(Hash)
42
+
43
+ symbolize_hero(hero)
44
+ end
45
+
46
+ def features_config
47
+ return nil unless landing?
48
+
49
+ features = landing_config["features"]
50
+ return nil unless features.is_a?(Array)
51
+
52
+ features.map { |f| symbolize_feature(f) }
53
+ end
54
+
55
+ def features_header_config
56
+ return nil unless landing?
57
+
58
+ header = landing_config["features_header"]
59
+ return nil unless header.is_a?(Hash)
60
+
61
+ {
62
+ label: header["label"],
63
+ title: header["title"],
64
+ description: header["description"]
65
+ }.compact
66
+ end
67
+
68
+ def footer_config
69
+ return nil unless landing?
70
+
71
+ footer = landing_config["footer"]
72
+ return nil unless footer.is_a?(Hash)
73
+
74
+ {
75
+ links: normalize_footer_links(footer["links"])
76
+ }
77
+ end
78
+
79
+ def to_options
80
+ {
81
+ template: template,
82
+ landing: landing?,
83
+ show_sidebar: show_sidebar?,
84
+ show_toc: show_toc?,
85
+ hero: hero_config,
86
+ features: features_config,
87
+ features_header: features_header_config,
88
+ footer: footer_config
89
+ }
90
+ end
91
+
92
+ private
93
+
94
+ def normalize_footer_links(links)
95
+ return nil unless links.is_a?(Array)
96
+
97
+ links.map do |link|
98
+ next unless link.is_a?(Hash)
99
+
100
+ { text: link["text"], link: link["link"] }
101
+ end.compact
102
+ end
103
+
104
+ def landing_config
105
+ @landing_config ||= frontmatter["landing"] || site_config["landing"] || {}
106
+ end
107
+
108
+ def symbolize_hero(hero)
109
+ background = hero["background"]
110
+ validated_bg = BACKGROUNDS.include?(background) ? background : DEFAULT_BACKGROUND
111
+
112
+ {
113
+ background: validated_bg,
114
+ badge: hero["badge"],
115
+ name: hero["name"],
116
+ title: hero["title"],
117
+ tagline: hero["tagline"],
118
+ gradient: hero.fetch("gradient", true),
119
+ image: symbolize_image(hero["image"]),
120
+ actions: symbolize_actions(hero["actions"])
121
+ }.compact
122
+ end
123
+
124
+ def symbolize_image(image)
125
+ return nil unless image.is_a?(Hash)
126
+
127
+ if image["light"] || image["dark"]
128
+ {
129
+ light: image["light"],
130
+ dark: image["dark"],
131
+ alt: image["alt"]
132
+ }.compact
133
+ else
134
+ {
135
+ src: image["src"],
136
+ alt: image["alt"]
137
+ }.compact
138
+ end
139
+ end
140
+
141
+ def symbolize_actions(actions)
142
+ return nil unless actions.is_a?(Array)
143
+
144
+ actions.map do |action|
145
+ {
146
+ text: action["text"],
147
+ link: action["link"],
148
+ icon: action["icon"],
149
+ variant: action["variant"] || "primary",
150
+ target: action["target"],
151
+ rel: action["rel"]
152
+ }.compact
153
+ end
154
+ end
155
+
156
+ def symbolize_feature(feature)
157
+ return {} unless feature.is_a?(Hash)
158
+
159
+ {
160
+ title: feature["title"],
161
+ description: feature["description"],
162
+ icon: feature["icon"],
163
+ color: feature["color"],
164
+ link: feature["link"],
165
+ link_text: feature["link_text"],
166
+ size: feature["size"],
167
+ target: feature["target"],
168
+ rel: feature["rel"]
169
+ }.compact
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Routing
5
+ class FallbackResolver
6
+ attr_reader :docs_path, :sidebar_builder
7
+
8
+ def initialize(docs_path:, sidebar_builder:)
9
+ @docs_path = docs_path
10
+ @sidebar_builder = sidebar_builder
11
+ end
12
+
13
+ def resolve_fallback(request_path)
14
+ return nil if file_exists?(request_path)
15
+
16
+ find_first_item_in_section(request_path)
17
+ end
18
+
19
+ private
20
+
21
+ def file_exists?(request_path)
22
+ clean_path = sanitize_path(request_path)
23
+
24
+ file_path = File.join(docs_path, "#{clean_path}.md")
25
+ return true if File.file?(file_path)
26
+
27
+ index_path = File.join(docs_path, clean_path, "index.md")
28
+ File.file?(index_path)
29
+ end
30
+
31
+ def sanitize_path(request_path)
32
+ clean = request_path.to_s.delete_prefix("/").delete_suffix("/")
33
+ clean = "index" if clean.empty?
34
+ clean.delete_suffix(".md")
35
+ end
36
+
37
+ def find_first_item_in_section(request_path)
38
+ tree = sidebar_builder.tree
39
+
40
+ if root_path?(request_path)
41
+ find_first_navigable_item(tree)
42
+ else
43
+ section = find_section_in_tree(tree, request_path)
44
+ section ? find_first_navigable_item(section[:children] || []) : nil
45
+ end
46
+ end
47
+
48
+ def root_path?(request_path)
49
+ request_path.nil? || request_path == "/" || request_path.empty?
50
+ end
51
+
52
+ def find_section_in_tree(tree, path)
53
+ normalized_path = normalize_path(path)
54
+
55
+ tree.each do |item|
56
+ return item if path_matches_section?(item, normalized_path)
57
+
58
+ if item[:children]&.any?
59
+ found = find_section_in_tree(item[:children], path)
60
+ return found if found
61
+ end
62
+ end
63
+
64
+ nil
65
+ end
66
+
67
+ def normalize_path(path)
68
+ path.to_s.delete_prefix("/").delete_suffix("/").downcase
69
+ end
70
+
71
+ def path_matches_section?(item, normalized_path)
72
+ return false unless item[:type] == :directory
73
+
74
+ item_path = item[:title].to_s.downcase.gsub(/\s+/, "-")
75
+ item_path == normalized_path
76
+ end
77
+
78
+ def find_first_navigable_item(items)
79
+ items.each do |item|
80
+ return item[:path] if item[:path] && item[:type] == :file
81
+
82
+ if item[:children]&.any?
83
+ found = find_first_navigable_item(item[:children])
84
+ return found if found
85
+ end
86
+ end
87
+
88
+ nil
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Docyard
6
+ module Search
7
+ class BuildIndexer
8
+ include PagefindSupport
9
+
10
+ PAGEFIND_COMMAND = "npx"
11
+
12
+ attr_reader :config, :output_dir, :verbose
13
+
14
+ def initialize(config, verbose: false)
15
+ @config = config
16
+ @output_dir = config.build.output
17
+ @verbose = verbose
18
+ end
19
+
20
+ def index
21
+ return 0 unless search_enabled?
22
+
23
+ log "Generating search index..."
24
+
25
+ unless pagefind_available?
26
+ warn_pagefind_missing
27
+ return 0
28
+ end
29
+
30
+ run_pagefind
31
+ end
32
+
33
+ private
34
+
35
+ def warn_pagefind_missing
36
+ log_warning "[!] Search index skipped: Pagefind not found"
37
+ log_warning " Install with: npm install -g pagefind"
38
+ log_warning " Or run: npx pagefind --site #{output_dir}"
39
+ end
40
+
41
+ def run_pagefind
42
+ args = build_pagefind_args(output_dir)
43
+ log "Running: npx #{args.join(' ')}" if verbose
44
+
45
+ stdout, stderr, status = Open3.capture3(PAGEFIND_COMMAND, *args)
46
+
47
+ if status.success?
48
+ page_count = extract_page_count(stdout)
49
+ log "[+] Generated search index (#{page_count} pages indexed)"
50
+ page_count
51
+ else
52
+ log_warning "[!] Search indexing failed: #{stderr}"
53
+ 0
54
+ end
55
+ end
56
+
57
+ def extract_page_count(output)
58
+ if output =~ /Indexed (\d+) page/i
59
+ Regexp.last_match(1).to_i
60
+ else
61
+ 0
62
+ end
63
+ end
64
+
65
+ def log(message)
66
+ puts message
67
+ end
68
+
69
+ def log_warning(message)
70
+ warn message
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+ require "open3"
6
+ require "tty-progressbar"
7
+
8
+ module Docyard
9
+ module Search
10
+ class DevIndexer
11
+ include PagefindSupport
12
+
13
+ attr_reader :docs_path, :config, :temp_dir, :pagefind_path
14
+
15
+ def initialize(docs_path:, config:)
16
+ @docs_path = docs_path
17
+ @config = config
18
+ @temp_dir = nil
19
+ @pagefind_path = nil
20
+ end
21
+
22
+ def generate
23
+ return unless search_enabled?
24
+ return unless pagefind_available?
25
+
26
+ @temp_dir = Dir.mktmpdir("docyard-search-")
27
+ generate_html_files
28
+ page_count = run_pagefind
29
+ @pagefind_path = File.join(temp_dir, "pagefind")
30
+
31
+ log_success(page_count)
32
+ pagefind_path
33
+ rescue StandardError => e
34
+ warn "[!] Search index generation failed: #{e.message}"
35
+ cleanup
36
+ nil
37
+ end
38
+
39
+ def cleanup
40
+ return unless temp_dir && Dir.exist?(temp_dir)
41
+
42
+ FileUtils.rm_rf(temp_dir)
43
+ end
44
+
45
+ private
46
+
47
+ def pagefind_available?
48
+ result = super
49
+ warn "[!] Search disabled: Pagefind not found (npm install -g pagefind)" unless result
50
+ result
51
+ end
52
+
53
+ def generate_html_files
54
+ markdown_files = Dir.glob(File.join(docs_path, "**", "*.md"))
55
+ markdown_files = filter_excluded_files(markdown_files)
56
+ markdown_files = filter_non_indexable_files(markdown_files)
57
+ renderer = Renderer.new(base_url: "/", config: config)
58
+
59
+ progress = TTY::ProgressBar.new(
60
+ "Indexing search [:bar] :current/:total (:percent)",
61
+ total: markdown_files.size,
62
+ width: 50
63
+ )
64
+
65
+ markdown_files.each do |file_path|
66
+ generate_html_file(file_path, renderer)
67
+ progress.advance
68
+ end
69
+ end
70
+
71
+ def filter_excluded_files(files)
72
+ exclude_patterns = config.search.exclude || []
73
+ return files if exclude_patterns.empty?
74
+
75
+ files.reject do |file_path|
76
+ url_path = file_to_url_path(file_path)
77
+ exclude_patterns.any? { |pattern| File.fnmatch(pattern, url_path, File::FNM_PATHNAME) }
78
+ end
79
+ end
80
+
81
+ def filter_non_indexable_files(files)
82
+ files.reject do |file_path|
83
+ content = File.read(file_path)
84
+ markdown = Markdown.new(content)
85
+ frontmatter = markdown.frontmatter
86
+
87
+ uses_splash_template?(frontmatter)
88
+ end
89
+ end
90
+
91
+ def uses_splash_template?(frontmatter)
92
+ return true if frontmatter["template"] == "splash"
93
+ return true if frontmatter.key?("landing")
94
+
95
+ frontmatter.key?("hero") || frontmatter.key?("features")
96
+ end
97
+
98
+ def file_to_url_path(file_path)
99
+ relative_path = file_path.delete_prefix("#{docs_path}/")
100
+ base_name = File.basename(relative_path, ".md")
101
+ dir_name = File.dirname(relative_path)
102
+
103
+ if base_name == "index"
104
+ dir_name == "." ? "/" : "/#{dir_name}"
105
+ else
106
+ dir_name == "." ? "/#{base_name}" : "/#{dir_name}/#{base_name}"
107
+ end
108
+ end
109
+
110
+ def generate_html_file(markdown_file, renderer)
111
+ relative_path = markdown_file.delete_prefix("#{docs_path}/")
112
+ output_path = determine_output_path(relative_path)
113
+
114
+ html = renderer.render_file(markdown_file, branding: branding_options)
115
+
116
+ FileUtils.mkdir_p(File.dirname(output_path))
117
+ File.write(output_path, html)
118
+ end
119
+
120
+ def determine_output_path(relative_path)
121
+ base_name = File.basename(relative_path, ".md")
122
+ dir_name = File.dirname(relative_path)
123
+
124
+ if base_name == "index"
125
+ File.join(temp_dir, dir_name, "index.html")
126
+ else
127
+ File.join(temp_dir, dir_name, base_name, "index.html")
128
+ end
129
+ end
130
+
131
+ def branding_options
132
+ BrandingResolver.new(config).resolve
133
+ end
134
+
135
+ def run_pagefind
136
+ args = build_pagefind_args(temp_dir)
137
+ stdout, stderr, status = Open3.capture3("npx", *args)
138
+
139
+ raise "Pagefind failed: #{stderr}" unless status.success?
140
+
141
+ extract_page_count(stdout)
142
+ end
143
+
144
+ def extract_page_count(output)
145
+ match = output.match(/Indexed (\d+) page/i)
146
+ match ? match[1].to_i : 0
147
+ end
148
+
149
+ def log_success(page_count)
150
+ puts "=> Search index generated (#{page_count} pages indexed)"
151
+ puts "=> Temp directory: #{temp_dir}" if ENV["DOCYARD_DEBUG"]
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Docyard
6
+ module Search
7
+ module PagefindSupport
8
+ def search_enabled?
9
+ config.search.enabled != false
10
+ end
11
+
12
+ def pagefind_available?
13
+ _stdout, _stderr, status = Open3.capture3("npx", "pagefind", "--version")
14
+ status.success?
15
+ rescue Errno::ENOENT
16
+ false
17
+ end
18
+
19
+ def build_pagefind_args(site_dir)
20
+ args = ["pagefind", "--site", site_dir]
21
+
22
+ exclusions = config.search.exclude || []
23
+ exclusions.each do |pattern|
24
+ next if pattern.start_with?("/")
25
+
26
+ args += ["--exclude-selectors", pattern]
27
+ end
28
+
29
+ args
30
+ end
31
+ end
32
+ end
33
+ end