docyard 1.0.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edade5380162369781998f1693e7658c34eb7275092cd709fce68ee25393f0af
4
- data.tar.gz: ec1c12df9c38802e881404526b4bff6d10f860320f0f0e3b459344f04c980f51
3
+ metadata.gz: 69c121af95a08c379f260d0d444aa4bc5272fdfe2d0699866091885c9f503601
4
+ data.tar.gz: 4550cdcc1d6863b545c4900aac9adb10f14d96a7394ccd3fe4c8924969b278dc
5
5
  SHA512:
6
- metadata.gz: 656e618b495b1b657bb002857c030dff8929546f453acd559268c9fb25f5d4f355938cf0f705914b54d22d3ca3c5a601635c5168fe85cd1d574f72b1f0c7a897
7
- data.tar.gz: f86343f57d8a64520652351ff1d8fa43518af247227f96092b5feff5b0ec2b185b6295017c8d18eb62f12fe28a22c66b71c1390834d296baed643181972a3480
6
+ metadata.gz: 690e11d0792b71540f09d26fe45dc46d57558b3aad1b3cf80f3e08c592a4056408b6b8a57bfc47f2459feba94e481038f11ccef287534a9f82e557c255728b53
7
+ data.tar.gz: 8baa1a707f2ff94152499b66c19596a57984c5ff763d2cf43390d986030e2e2eea994febac3089766a764ec1d6314a61479fe2caa54434ee5e64c86ade6431e1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.2] - 2026-01-23
11
+
12
+ ### Fixed
13
+ - "Last updated" timestamps now show correct dates when deploying via CI (#129)
14
+
15
+ ### Documentation
16
+ - Added git history requirements for accurate timestamps on GitHub Actions, Vercel, and Netlify
17
+
18
+ ## [1.0.1] - 2026-01-22
19
+
20
+ ### Added
21
+ - **Root Fallback Redirect** - Auto-redirect to first page when no index.md exists (#127)
22
+
23
+ ### Fixed
24
+ - Breadcrumb sections without index pages now link to first navigable child (#126)
25
+ - Error pages (404/500) now use custom branding colors (#125)
26
+ - Base URL now correctly applied to all assets including fonts and search (#124)
27
+ - Dev server no longer applies build.base config, always uses root path (#123)
28
+
10
29
  ## [1.0.0] - 2026-01-22
11
30
 
12
31
  ### Added
@@ -205,7 +224,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
205
224
  - Initial gem structure
206
225
  - Project scaffolding
207
226
 
208
- [Unreleased]: https://github.com/sanifhimani/docyard/compare/v0.9.0...HEAD
227
+ [Unreleased]: https://github.com/sanifhimani/docyard/compare/v1.0.2...HEAD
228
+ [1.0.2]: https://github.com/sanifhimani/docyard/compare/v1.0.1...v1.0.2
229
+ [1.0.1]: https://github.com/sanifhimani/docyard/compare/v1.0.0...v1.0.1
230
+ [1.0.0]: https://github.com/sanifhimani/docyard/compare/v0.9.0...v1.0.0
209
231
  [0.9.0]: https://github.com/sanifhimani/docyard/compare/v0.8.0...v0.9.0
210
232
  [0.8.0]: https://github.com/sanifhimani/docyard/compare/v0.7.0...v0.8.0
211
233
  [0.7.0]: https://github.com/sanifhimani/docyard/compare/v0.6.0...v0.7.0
@@ -37,6 +37,7 @@ module Docyard
37
37
  minified = CSSminify.compress(css_content)
38
38
  minified = fix_calc_whitespace(minified)
39
39
  minified = fix_css_math_functions(minified)
40
+ minified = replace_css_asset_urls(minified)
40
41
  hash = generate_hash(minified)
41
42
 
42
43
  write_bundled_asset(minified, hash, "css")
@@ -58,6 +59,11 @@ module Docyard
58
59
  css.gsub(/\bmax\(0,/, "max(0px,").gsub(/\bmin\(0,/, "min(0px,)")
59
60
  end
60
61
 
62
+ def replace_css_asset_urls(css)
63
+ base_url = normalize_base_url(config.build.base)
64
+ css.gsub(%r{/_docyard/fonts/}, "#{base_url}_docyard/fonts/")
65
+ end
66
+
61
67
  def resolve_css_imports(css_content)
62
68
  css_content.gsub(/@import url\('([^']+)'\);/) do |match|
63
69
  import_file = Regexp.last_match(1)
@@ -86,6 +92,7 @@ module Docyard
86
92
  components_js = concatenate_component_js
87
93
  js_content = [theme_js, components_js].join("\n")
88
94
  minified = Terser.compile(js_content)
95
+ minified = replace_js_asset_urls(minified)
89
96
  hash = generate_hash(minified)
90
97
 
91
98
  write_bundled_asset(minified, hash, "js")
@@ -94,6 +101,12 @@ module Docyard
94
101
  hash
95
102
  end
96
103
 
104
+ def replace_js_asset_urls(js_content)
105
+ base_url = normalize_base_url(config.build.base)
106
+ js_content.gsub(%r{"/_docyard/pagefind/}, "\"#{base_url}_docyard/pagefind/")
107
+ .gsub(%r{baseUrl:\s*["']/["']}, "baseUrl:\"#{base_url}\"")
108
+ end
109
+
97
110
  def concatenate_component_js
98
111
  components_dir = File.join(ASSETS_PATH, "js", "components")
99
112
  return "" unless Dir.exist?(components_dir)
@@ -123,6 +136,16 @@ module Docyard
123
136
  .gsub(%r{/_docyard/js/theme\.js}, "#{base_url}_docyard/bundle.#{js_hash}.js")
124
137
  .gsub(%r{/_docyard/js/components\.js}, "")
125
138
  .gsub(%r{<script src="/_docyard/js/reload\.js"></script>}, "")
139
+ .then { |html| replace_content_image_paths(html, base_url) }
140
+ end
141
+
142
+ def replace_content_image_paths(content, base_url)
143
+ return content if base_url == "/"
144
+
145
+ base_path_pattern = Regexp.escape(base_url.delete_prefix("/"))
146
+ content.gsub(%r{(<img[^>]*\ssrc=")/(?!_docyard/|#{base_path_pattern})([^"]*")}) do
147
+ "#{Regexp.last_match(1)}#{base_url}#{Regexp.last_match(2)}"
148
+ end
126
149
  end
127
150
 
128
151
  def write_bundled_asset(content, hash, extension)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Build
5
+ class ErrorPageGenerator
6
+ attr_reader :config, :docs_path, :renderer
7
+
8
+ def initialize(config:, docs_path:, renderer:)
9
+ @config = config
10
+ @docs_path = docs_path
11
+ @renderer = renderer
12
+ end
13
+
14
+ def generate
15
+ output_path = File.join(config.build.output, "404.html")
16
+ html_content = load_content
17
+
18
+ FileUtils.mkdir_p(File.dirname(output_path))
19
+ File.write(output_path, html_content)
20
+ end
21
+
22
+ private
23
+
24
+ def load_content
25
+ custom_error_page = File.join(docs_path, "404.html")
26
+ return File.read(custom_error_page) if File.exist?(custom_error_page)
27
+
28
+ branding = BrandingResolver.new(config).resolve
29
+ renderer.render_not_found(branding: branding)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Build
5
+ module FileWriter
6
+ def safe_file_write(path)
7
+ yield
8
+ rescue Errno::EACCES => e
9
+ raise BuildError, "Permission denied writing to #{path}: #{e.message}"
10
+ rescue Errno::ENOSPC => e
11
+ raise BuildError, "Disk full, cannot write to #{path}: #{e.message}"
12
+ rescue Errno::EROFS => e
13
+ raise BuildError, "Read-only filesystem, cannot write to #{path}: #{e.message}"
14
+ rescue SystemCallError => e
15
+ raise BuildError, "Failed to write #{path}: #{e.message}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Build
5
+ class RootFallbackGenerator
6
+ attr_reader :config, :docs_path, :sidebar_cache, :renderer
7
+
8
+ def initialize(config:, docs_path:, sidebar_cache:, renderer:)
9
+ @config = config
10
+ @docs_path = docs_path
11
+ @sidebar_cache = sidebar_cache
12
+ @renderer = renderer
13
+ end
14
+
15
+ def generate_if_needed
16
+ return if root_index_exists?
17
+
18
+ first_path = find_first_navigable_path
19
+ return unless first_path
20
+
21
+ generate_redirect_page(first_path)
22
+ end
23
+
24
+ private
25
+
26
+ def root_index_exists?
27
+ File.exist?(File.join(docs_path, "index.md")) ||
28
+ File.exist?(File.join(docs_path, "index.html"))
29
+ end
30
+
31
+ def find_first_navigable_path
32
+ return nil unless sidebar_cache&.tree&.any?
33
+
34
+ find_first_file_in_tree(sidebar_cache.tree)
35
+ end
36
+
37
+ def find_first_file_in_tree(items)
38
+ items.each do |item|
39
+ return item[:path] if item[:type] == :file && item[:path]
40
+
41
+ if item[:children]&.any?
42
+ nested = find_first_file_in_tree(item[:children])
43
+ return nested if nested
44
+ end
45
+ end
46
+
47
+ nil
48
+ end
49
+
50
+ def generate_redirect_page(target_path)
51
+ output_path = File.join(config.build.output, "index.html")
52
+ full_target = build_full_target_url(target_path)
53
+
54
+ FileUtils.mkdir_p(File.dirname(output_path))
55
+ File.write(output_path, renderer.render_redirect(full_target))
56
+
57
+ full_target
58
+ end
59
+
60
+ def build_full_target_url(target_path)
61
+ base = config.build.base&.chomp("/") || ""
62
+ "#{base}#{target_path}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -7,10 +7,15 @@ require_relative "../navigation/page_navigation_builder"
7
7
  require_relative "../navigation/sidebar/cache"
8
8
  require_relative "../utils/path_utils"
9
9
  require_relative "../utils/git_info"
10
+ require_relative "root_fallback_generator"
11
+ require_relative "error_page_generator"
12
+ require_relative "file_writer"
10
13
 
11
14
  module Docyard
12
15
  module Build
13
16
  class StaticGenerator
17
+ include FileWriter
18
+
14
19
  PARALLEL_THRESHOLD = 10
15
20
 
16
21
  attr_reader :config, :verbose, :sidebar_cache
@@ -31,6 +36,7 @@ module Docyard
31
36
 
32
37
  generate_all_pages(markdown_files)
33
38
  generate_error_page
39
+ generate_root_fallback_if_needed
34
40
 
35
41
  markdown_files.size
36
42
  ensure
@@ -149,7 +155,8 @@ module Docyard
149
155
  @navigation_builder ||= Navigation::PageNavigationBuilder.new(
150
156
  docs_path: docs_path,
151
157
  config: config,
152
- sidebar_cache: sidebar_cache
158
+ sidebar_cache: sidebar_cache,
159
+ base_url: config.build.base
153
160
  )
154
161
  end
155
162
 
@@ -161,18 +168,6 @@ module Docyard
161
168
  log "Generated: #{output_path}" if verbose
162
169
  end
163
170
 
164
- def safe_file_write(path, &block)
165
- block.call
166
- rescue Errno::EACCES => e
167
- raise BuildError, "Permission denied writing to #{path}: #{e.message}"
168
- rescue Errno::ENOSPC => e
169
- raise BuildError, "Disk full, cannot write to #{path}: #{e.message}"
170
- rescue Errno::EROFS => e
171
- raise BuildError, "Read-only filesystem, cannot write to #{path}: #{e.message}"
172
- rescue SystemCallError => e
173
- raise BuildError, "Failed to write #{path}: #{e.message}"
174
- end
175
-
176
171
  def build_sidebar_cache
177
172
  @sidebar_cache = Sidebar::Cache.new(
178
173
  docs_path: docs_path,
@@ -194,15 +189,23 @@ module Docyard
194
189
  end
195
190
 
196
191
  def generate_error_page
197
- output_path = File.join(config.build.output, "404.html")
198
- html_content = load_error_page_content
199
- safe_file_write(output_path) { File.write(output_path, html_content) }
192
+ ErrorPageGenerator.new(
193
+ config: config,
194
+ docs_path: docs_path,
195
+ renderer: build_renderer
196
+ ).generate
200
197
  log "[✓] Generated 404.html"
201
198
  end
202
199
 
203
- def load_error_page_content
204
- error_page = File.join(docs_path, "404.html")
205
- File.exist?(error_page) ? File.read(error_page) : build_renderer.render_not_found
200
+ def generate_root_fallback_if_needed
201
+ generator = RootFallbackGenerator.new(
202
+ config: config,
203
+ docs_path: docs_path,
204
+ sidebar_cache: sidebar_cache,
205
+ renderer: build_renderer
206
+ )
207
+ target = generator.generate_if_needed
208
+ log "[✓] Generated root redirect to #{target}" if target
206
209
  end
207
210
  end
208
211
  end
@@ -76,9 +76,10 @@ module Docyard
76
76
  def search_in_ancestors(node, path, title, href)
77
77
  return unless node[:children]&.any?
78
78
 
79
- effective_href = href == "/" ? derive_section_path(node) : href
80
- return unless path_is_ancestor?(effective_href)
79
+ section_path = derive_section_path(node, href)
80
+ return unless path_is_ancestor?(section_path)
81
81
 
82
+ effective_href = resolve_section_href(node, href, section_path)
82
83
  result = find_breadcrumb_path(
83
84
  node[:children],
84
85
  path + [Item.new(title: title, href: effective_href, current: false)]
@@ -86,14 +87,52 @@ module Docyard
86
87
  result.any? ? result : nil
87
88
  end
88
89
 
89
- def derive_section_path(node)
90
- first_child = node[:children]&.first
90
+ def derive_section_path(node, href)
91
+ return href if section_has_index?(href)
92
+
93
+ derive_section_path_from_children(node[:children])
94
+ end
95
+
96
+ def derive_section_path_from_children(children)
97
+ first_child = children&.first
91
98
  return nil unless first_child
92
99
 
93
100
  child_path = first_child[:path]
94
- return nil if child_path.nil? || child_path.empty?
101
+ return nil unless navigable_path?(child_path)
102
+
103
+ parent_dir = File.dirname(child_path)
104
+ root_parent?(parent_dir) ? child_path : parent_dir
105
+ end
106
+
107
+ def root_parent?(parent_dir)
108
+ parent_dir == "/" || parent_dir.empty?
109
+ end
110
+
111
+ def resolve_section_href(node, href, section_path)
112
+ return href if section_has_index?(href)
113
+
114
+ find_first_navigable_child(node[:children]) || section_path
115
+ end
116
+
117
+ def section_has_index?(path)
118
+ path && !path.empty? && path != "/"
119
+ end
120
+
121
+ def find_first_navigable_child(children)
122
+ return nil unless children&.any?
123
+
124
+ children.each do |child|
125
+ return child[:path] if navigable_path?(child[:path])
126
+
127
+ nested = find_first_navigable_child(child[:children])
128
+ return nested if nested
129
+ end
130
+
131
+ nil
132
+ end
95
133
 
96
- File.dirname(child_path)
134
+ def navigable_path?(path)
135
+ path && !path.empty? && path != "/"
97
136
  end
98
137
 
99
138
  def search_in_children(node, path)
@@ -7,10 +7,11 @@ require_relative "breadcrumb_builder"
7
7
  module Docyard
8
8
  module Navigation
9
9
  class PageNavigationBuilder
10
- def initialize(docs_path:, config:, sidebar_cache: nil)
10
+ def initialize(docs_path:, config:, sidebar_cache: nil, base_url: "/")
11
11
  @docs_path = docs_path
12
12
  @config = config
13
13
  @sidebar_cache = sidebar_cache
14
+ @base_url = base_url
14
15
  end
15
16
 
16
17
  def build(current_path:, markdown:, header_ctas: [], show_sidebar: true)
@@ -26,7 +27,7 @@ module Docyard
26
27
 
27
28
  private
28
29
 
29
- attr_reader :docs_path, :config, :sidebar_cache
30
+ attr_reader :docs_path, :config, :sidebar_cache, :base_url
30
31
 
31
32
  def empty_navigation
32
33
  { sidebar_html: "", prev_next_html: "", breadcrumbs: nil }
@@ -47,7 +48,8 @@ module Docyard
47
48
  sidebar_tree: sidebar_builder.tree,
48
49
  current_path: current_path,
49
50
  frontmatter: markdown.frontmatter,
50
- config: {}
51
+ config: {},
52
+ base_url: base_url
51
53
  ).to_html
52
54
  end
53
55
 
@@ -5,13 +5,14 @@ require_relative "../utils/path_resolver"
5
5
 
6
6
  module Docyard
7
7
  class PrevNextBuilder
8
- attr_reader :sidebar_tree, :current_path, :frontmatter, :config
8
+ attr_reader :sidebar_tree, :current_path, :frontmatter, :config, :base_url
9
9
 
10
- def initialize(sidebar_tree:, current_path:, frontmatter: {}, config: {})
10
+ def initialize(sidebar_tree:, current_path:, frontmatter: {}, config: {}, base_url: "/")
11
11
  @sidebar_tree = sidebar_tree
12
12
  @current_path = Utils::PathResolver.normalize(current_path)
13
13
  @frontmatter = frontmatter
14
14
  @config = config
15
+ @base_url = base_url
15
16
  end
16
17
 
17
18
  def prev_next_links
@@ -27,7 +28,7 @@ module Docyard
27
28
  links = prev_next_links
28
29
  return "" if links.nil? || (links[:prev].nil? && links[:next].nil?)
29
30
 
30
- Renderer.new.render_partial(
31
+ Renderer.new(base_url: base_url).render_partial(
31
32
  "_prev_next", {
32
33
  prev: links[:prev],
33
34
  next: links[:next],
@@ -35,7 +35,7 @@ module Docyard
35
35
  markdown = Markdown.new(raw_content, config: config, file_path: file_path)
36
36
 
37
37
  render(
38
- content: strip_md_from_links(markdown.html),
38
+ content: process_content_links(markdown.html),
39
39
  page_title: markdown.title || Constants::DEFAULT_SITE_TITLE,
40
40
  page_description: markdown.description,
41
41
  page_og_image: markdown.og_image,
@@ -51,7 +51,7 @@ module Docyard
51
51
  def render_for_search(file_path)
52
52
  markdown = Markdown.new(File.read(file_path), config: config, file_path: file_path)
53
53
  title = markdown.title || Constants::DEFAULT_SITE_TITLE
54
- content = strip_md_from_links(markdown.html)
54
+ content = process_content_links(markdown.html)
55
55
 
56
56
  <<~HTML
57
57
  <!DOCTYPE html>
@@ -83,16 +83,23 @@ module Docyard
83
83
  ERB.new(template).result(binding)
84
84
  end
85
85
 
86
- def render_not_found
86
+ def render_not_found(branding: nil)
87
+ @primary_color = branding&.dig(:primary_color)
87
88
  render_error_template(404)
88
89
  end
89
90
 
90
- def render_server_error(error)
91
+ def render_server_error(error, branding: nil)
91
92
  @error_message = error.message
92
93
  @backtrace = error.backtrace&.join("\n") || "No backtrace available"
94
+ @primary_color = branding&.dig(:primary_color)
93
95
  render_error_template(500)
94
96
  end
95
97
 
98
+ def render_redirect(target_url)
99
+ @target_url = target_url
100
+ render_error_template(:redirect)
101
+ end
102
+
96
103
  def render_error_template(status)
97
104
  error_template_path = File.join(ERRORS_PATH, "#{status}.html.erb")
98
105
  template = File.read(error_template_path)
@@ -163,10 +170,25 @@ module Docyard
163
170
  @footer_links = footer[:links]
164
171
  end
165
172
 
173
+ def process_content_links(html)
174
+ rewrite_internal_links(strip_md_from_links(html))
175
+ end
176
+
166
177
  def strip_md_from_links(html)
167
178
  html.gsub(/href="([^"]+)\.md"/, 'href="\1"')
168
179
  end
169
180
 
181
+ def rewrite_internal_links(html)
182
+ return html if base_url == "/"
183
+
184
+ html.gsub(%r{href="(/(?!/)[^"]*)"}) do |_match|
185
+ path = ::Regexp.last_match(1)
186
+ next %(href="#{path}") if path.start_with?(base_url)
187
+
188
+ %(href="#{base_url.chomp('/')}#{path}")
189
+ end
190
+ end
191
+
170
192
  def assign_git_info(branding, file_path)
171
193
  @show_edit_link = branding[:show_edit_link] && file_path
172
194
  @show_last_updated = branding[:show_last_updated] && file_path
@@ -11,12 +11,13 @@ module Docyard
11
11
  class PreviewServer
12
12
  DEFAULT_PORT = 4000
13
13
 
14
- attr_reader :port, :output_dir
14
+ attr_reader :port, :output_dir, :base_url
15
15
 
16
16
  def initialize(port: DEFAULT_PORT)
17
17
  @port = port
18
18
  @config = Config.load
19
19
  @output_dir = File.expand_path(@config.build.output)
20
+ @base_url = normalize_base_url(@config.build.base)
20
21
  @launcher = nil
21
22
  end
22
23
 
@@ -38,12 +39,12 @@ module Docyard
38
39
  def print_server_info
39
40
  Docyard.logger.info("Starting preview server...")
40
41
  Docyard.logger.info("* Version: #{Docyard::VERSION}")
41
- Docyard.logger.info("* Running at: http://localhost:#{port}")
42
+ Docyard.logger.info("* Running at: http://localhost:#{port}#{base_url}")
42
43
  Docyard.logger.info("Use Ctrl+C to stop\n")
43
44
  end
44
45
 
45
46
  def run_server
46
- app = StaticFileApp.new(output_dir)
47
+ app = StaticFileApp.new(output_dir, base_path: base_url)
47
48
  puma_config = build_puma_config(app)
48
49
  log_writer = Puma::LogWriter.strings
49
50
 
@@ -64,5 +65,12 @@ module Docyard
64
65
  config.quiet
65
66
  end
66
67
  end
68
+
69
+ def normalize_base_url(url)
70
+ return "/" if url.nil? || url.empty?
71
+
72
+ url = "/#{url}" unless url.start_with?("/")
73
+ url.end_with?("/") ? url : "#{url}/"
74
+ end
67
75
  end
68
76
  end
@@ -19,7 +19,7 @@ module Docyard
19
19
  @dev_mode = !sse_port.nil?
20
20
  @sidebar_cache = sidebar_cache
21
21
  @router = Router.new(docs_path: docs_path)
22
- @renderer = Renderer.new(base_url: config&.build&.base || "/", config: config, dev_mode: @dev_mode,
22
+ @renderer = Renderer.new(base_url: "/", config: config, dev_mode: @dev_mode,
23
23
  sse_port: sse_port)
24
24
  @asset_handler = AssetHandler.new(public_dir: config&.public_dir || "docs/public")
25
25
  @pagefind_handler = PagefindHandler.new(pagefind_path: pagefind_path, config: config)
@@ -124,7 +124,7 @@ module Docyard
124
124
  end
125
125
 
126
126
  def render_not_found_page
127
- html = renderer.render_not_found
127
+ html = renderer.render_not_found(branding: branding_options)
128
128
  [Constants::STATUS_NOT_FOUND, { "Content-Type" => Constants::CONTENT_TYPE_HTML }, [html]]
129
129
  end
130
130
 
@@ -147,7 +147,7 @@ module Docyard
147
147
  Docyard.logger.error("Request error: #{error.message} [#{request_context}]")
148
148
  Docyard.logger.debug(error.backtrace.join("\n"))
149
149
  [Constants::STATUS_INTERNAL_ERROR, { "Content-Type" => Constants::CONTENT_TYPE_HTML },
150
- [renderer.render_server_error(error)]]
150
+ [renderer.render_server_error(error, branding: branding_options)]]
151
151
  end
152
152
 
153
153
  def build_request_context(env)
@@ -4,15 +4,20 @@ require "rack/mime"
4
4
 
5
5
  module Docyard
6
6
  class StaticFileApp
7
- def initialize(root)
7
+ def initialize(root, base_path: "/")
8
8
  @root = root
9
+ @base_path = base_path.chomp("/")
9
10
  end
10
11
 
11
12
  def call(env)
12
13
  path = env["PATH_INFO"]
13
- file_path = File.join(@root, path)
14
14
 
15
- if path.end_with?("/") || File.directory?(file_path)
15
+ return serve_not_found unless path_under_base?(path)
16
+
17
+ relative_path = strip_base_path(path)
18
+ file_path = File.join(@root, relative_path)
19
+
20
+ if relative_path.end_with?("/") || relative_path.empty? || File.directory?(file_path)
16
21
  index_path = File.join(file_path, "index.html")
17
22
  return serve_file(index_path) if File.file?(index_path)
18
23
  elsif File.file?(file_path)
@@ -24,6 +29,18 @@ module Docyard
24
29
 
25
30
  private
26
31
 
32
+ def path_under_base?(path)
33
+ return true if @base_path.empty?
34
+
35
+ path == @base_path || path.start_with?("#{@base_path}/")
36
+ end
37
+
38
+ def strip_base_path(path)
39
+ return path if @base_path.empty?
40
+
41
+ path.delete_prefix(@base_path)
42
+ end
43
+
27
44
  def serve_file(path)
28
45
  content = File.read(path)
29
46
  content_type = Rack::Mime.mime_type(File.extname(path), "application/octet-stream")
@@ -76,11 +76,11 @@ body.has-announcement:not(.has-tabs) .layout {
76
76
 
77
77
  @media (max-width: 1280px) and (min-width: 1025px) {
78
78
  body.has-announcement .layout {
79
- padding-top: calc(var(--header-height) + var(--tab-bar-height) + var(--announcement-height) + 3rem);
79
+ padding-top: calc(var(--header-height) + var(--tab-bar-height) + var(--announcement-height) + var(--secondary-header-height));
80
80
  }
81
81
 
82
82
  body.has-announcement:not(.has-tabs) .layout {
83
- padding-top: calc(var(--header-height) + var(--announcement-height) + 3rem);
83
+ padding-top: calc(var(--header-height) + var(--announcement-height) + var(--secondary-header-height));
84
84
  }
85
85
  }
86
86
 
@@ -91,7 +91,7 @@ body.has-announcement:not(.has-tabs) .layout {
91
91
  }
92
92
 
93
93
  body.has-announcement .layout {
94
- padding-top: calc(var(--header-height) + var(--announcement-height) + 3rem);
94
+ padding-top: calc(var(--header-height) + var(--announcement-height) + var(--secondary-header-height));
95
95
  }
96
96
 
97
97
  body.has-announcement .sidebar {
@@ -12,7 +12,7 @@
12
12
  .has-tabs .content h4[id],
13
13
  .has-tabs .content h5[id],
14
14
  .has-tabs .content h6[id] {
15
- scroll-margin-top: calc(var(--header-height) + 3rem + var(--spacing-6));
15
+ scroll-margin-top: calc(var(--header-height) + var(--tab-bar-height) + var(--spacing-6));
16
16
  }
17
17
 
18
18
  @media (max-width: 1280px) and (min-width: 1025px) {
@@ -22,7 +22,7 @@
22
22
  .content h4[id],
23
23
  .content h5[id],
24
24
  .content h6[id] {
25
- scroll-margin-top: calc(var(--header-height) + 3rem + var(--spacing-6));
25
+ scroll-margin-top: calc(var(--header-height) + var(--secondary-header-height) + var(--spacing-6));
26
26
  }
27
27
  }
28
28
 
@@ -123,7 +123,7 @@
123
123
 
124
124
  @media (max-width: 1280px) and (min-width: 1025px) {
125
125
  .has-tabs .layout {
126
- padding-top: calc(var(--header-height) + var(--tab-bar-height) + 3rem);
126
+ padding-top: calc(var(--header-height) + var(--tab-bar-height) + var(--secondary-header-height));
127
127
  }
128
128
  }
129
129
 
@@ -143,7 +143,7 @@
143
143
  }
144
144
 
145
145
  .has-tabs .layout {
146
- padding-top: calc(var(--header-height) + 3rem);
146
+ padding-top: calc(var(--header-height) + var(--secondary-header-height));
147
147
  }
148
148
 
149
149
  .has-tabs .sidebar {
@@ -257,30 +257,30 @@
257
257
 
258
258
  @media (max-width: 1280px) and (min-width: 1025px) {
259
259
  .docyard-toc__nav {
260
- top: calc(var(--header-height) + 3rem + var(--spacing-2));
260
+ top: calc(var(--header-height) + var(--secondary-header-height) + var(--spacing-2));
261
261
  right: var(--spacing-6);
262
262
  width: 280px;
263
- max-height: calc(100vh - var(--header-height) - 3rem - var(--spacing-8));
263
+ max-height: calc(100vh - var(--header-height) - var(--secondary-header-height) - var(--spacing-8));
264
264
  }
265
265
 
266
266
  .has-tabs .docyard-toc__nav {
267
- top: calc(var(--header-height) + var(--tab-bar-height) + 3rem + var(--spacing-2));
268
- max-height: calc(100vh - var(--header-height) - var(--tab-bar-height) - 3rem - var(--spacing-8));
267
+ top: calc(var(--header-height) + var(--tab-bar-height) + var(--secondary-header-height) + var(--spacing-2));
268
+ max-height: calc(100vh - var(--header-height) - var(--tab-bar-height) - var(--secondary-header-height) - var(--spacing-8));
269
269
  }
270
270
  }
271
271
 
272
272
  @media (max-width: 1024px) {
273
273
  .docyard-toc__nav {
274
- top: calc(var(--header-height) + 3rem - 2px);
274
+ top: calc(var(--header-height) + var(--secondary-header-height) - 2px);
275
275
  left: var(--spacing-4);
276
276
  right: var(--spacing-4);
277
- max-height: calc(100vh - var(--header-height) - 3rem - var(--spacing-2));
277
+ max-height: calc(100vh - var(--header-height) - var(--secondary-header-height) - var(--spacing-2));
278
278
  transform-origin: top center;
279
279
  }
280
280
 
281
281
  .secondary-header.shift-up ~ .layout .docyard-toc__nav {
282
- top: calc(3rem - 2px);
283
- max-height: calc(100vh - 3rem - var(--spacing-2));
282
+ top: calc(var(--secondary-header-height) - 2px);
283
+ max-height: calc(100vh - var(--secondary-header-height) - var(--spacing-2));
284
284
  }
285
285
 
286
286
  .docyard-toc__list .docyard-toc__list {
@@ -292,25 +292,25 @@
292
292
  /* Adjust TOC dropdown position when announcement banner is visible */
293
293
  @media (max-width: 1280px) and (min-width: 1025px) {
294
294
  body.has-announcement .docyard-toc__nav {
295
- top: calc(var(--header-height) + var(--announcement-height) + 3rem + var(--spacing-2));
296
- max-height: calc(100vh - var(--header-height) - var(--announcement-height) - 3rem - var(--spacing-8));
295
+ top: calc(var(--header-height) + var(--announcement-height) + var(--secondary-header-height) + var(--spacing-2));
296
+ max-height: calc(100vh - var(--header-height) - var(--announcement-height) - var(--secondary-header-height) - var(--spacing-8));
297
297
  }
298
298
 
299
299
  body.has-announcement.has-tabs .docyard-toc__nav {
300
- top: calc(var(--header-height) + var(--tab-bar-height) + var(--announcement-height) + 3rem + var(--spacing-2));
301
- max-height: calc(100vh - var(--header-height) - var(--tab-bar-height) - var(--announcement-height) - 3rem - var(--spacing-8));
300
+ top: calc(var(--header-height) + var(--tab-bar-height) + var(--announcement-height) + var(--secondary-header-height) + var(--spacing-2));
301
+ max-height: calc(100vh - var(--header-height) - var(--tab-bar-height) - var(--announcement-height) - var(--secondary-header-height) - var(--spacing-8));
302
302
  }
303
303
  }
304
304
 
305
305
  @media (max-width: 1024px) {
306
306
  body.has-announcement .docyard-toc__nav {
307
- top: calc(var(--header-height) + var(--announcement-height) + 3rem - 2px);
308
- max-height: calc(100vh - var(--header-height) - var(--announcement-height) - 3rem - var(--spacing-2));
307
+ top: calc(var(--header-height) + var(--announcement-height) + var(--secondary-header-height) - 2px);
308
+ max-height: calc(100vh - var(--header-height) - var(--announcement-height) - var(--secondary-header-height) - var(--spacing-2));
309
309
  }
310
310
 
311
311
  body.has-announcement .secondary-header.shift-up ~ .layout .docyard-toc__nav {
312
- top: calc(var(--announcement-height) + 3rem - 2px);
313
- max-height: calc(100vh - var(--announcement-height) - 3rem - var(--spacing-2));
312
+ top: calc(var(--announcement-height) + var(--secondary-header-height) - 2px);
313
+ max-height: calc(100vh - var(--announcement-height) - var(--secondary-header-height) - var(--spacing-2));
314
314
  }
315
315
  }
316
316
 
@@ -179,6 +179,15 @@ html:has(.has-tabs) {
179
179
  display: none;
180
180
  }
181
181
 
182
+ .layout {
183
+ display: flex;
184
+ min-height: calc(100vh - var(--header-height));
185
+ padding-top: var(--header-height);
186
+ width: 100%;
187
+ max-width: var(--layout-max-width);
188
+ margin: 0 auto;
189
+ position: relative;
190
+ }
182
191
 
183
192
  .secondary-header {
184
193
  display: none;
@@ -193,8 +202,8 @@ html:has(.has-tabs) {
193
202
  top: var(--header-height);
194
203
  left: calc(max(0px, (100vw - var(--layout-max-width)) / 2) + var(--sidebar-width));
195
204
  right: max(0px, calc((100vw - var(--layout-max-width)) / 2));
196
- height: 3rem;
197
- min-height: 3rem;
205
+ height: var(--secondary-header-height);
206
+ min-height: var(--secondary-header-height);
198
207
  background: var(--background);
199
208
  border-bottom: 1px solid var(--border);
200
209
  padding: 0 var(--spacing-6);
@@ -213,7 +222,7 @@ html:has(.has-tabs) {
213
222
  }
214
223
 
215
224
  .layout {
216
- padding-top: calc(var(--header-height) + 3rem);
225
+ padding-top: calc(var(--header-height) + var(--secondary-header-height));
217
226
  }
218
227
  }
219
228
 
@@ -226,8 +235,8 @@ html:has(.has-tabs) {
226
235
  top: var(--header-height);
227
236
  left: 0;
228
237
  right: 0;
229
- height: 3rem;
230
- min-height: 3rem;
238
+ height: var(--secondary-header-height);
239
+ min-height: var(--secondary-header-height);
231
240
  background: var(--background);
232
241
  border-bottom: 1px solid var(--border);
233
242
  padding: 0 var(--spacing-6);
@@ -278,16 +287,6 @@ html:has(.has-tabs) {
278
287
  }
279
288
  }
280
289
 
281
- .layout {
282
- display: flex;
283
- min-height: calc(100vh - var(--header-height));
284
- padding-top: var(--header-height);
285
- width: 100%;
286
- max-width: var(--layout-max-width);
287
- margin: 0 auto;
288
- position: relative;
289
- }
290
-
291
290
  .sidebar {
292
291
  width: var(--sidebar-width);
293
292
  height: calc(100vh - var(--header-height));
@@ -424,7 +423,7 @@ main.content {
424
423
  }
425
424
 
426
425
  .layout {
427
- padding-top: calc(var(--header-height) + 3rem);
426
+ padding-top: calc(var(--header-height) + var(--secondary-header-height));
428
427
  flex-direction: column;
429
428
  }
430
429
 
@@ -157,6 +157,7 @@
157
157
  --toc-width: 17.5rem;
158
158
  --header-height: 4rem;
159
159
  --tab-bar-height: 3rem;
160
+ --secondary-header-height: 3rem;
160
161
  --content-max-width: 50rem;
161
162
  --layout-max-width: 88rem;
162
163
 
@@ -7,7 +7,7 @@
7
7
  <style>
8
8
  @font-face {
9
9
  font-family: 'Inter';
10
- src: url('/_docyard/fonts/Inter-Variable.ttf') format('truetype');
10
+ src: url('<%= base_url %>_docyard/fonts/Inter-Variable.ttf') format('truetype');
11
11
  font-weight: 100 900;
12
12
  font-style: normal;
13
13
  }
@@ -31,6 +31,17 @@
31
31
  --primary-foreground: oklch(0.30 0.05 230);
32
32
  }
33
33
 
34
+ <% if @primary_color && (@primary_color[:light] || @primary_color[:dark]) %>
35
+ <% light_color = @primary_color[:light] %>
36
+ <% dark_color = @primary_color[:dark] || light_color %>
37
+ <% if light_color %>
38
+ :root { --primary: <%= light_color %>; }
39
+ .dark { --primary: <%= dark_color %>; }
40
+ <% elsif dark_color %>
41
+ .dark { --primary: <%= dark_color %>; }
42
+ <% end %>
43
+ <% end %>
44
+
34
45
  * {
35
46
  margin: 0;
36
47
  padding: 0;
@@ -116,7 +127,7 @@
116
127
  <p class="error-code">404</p>
117
128
  <h1 class="error-title">Page not found</h1>
118
129
  <p class="error-message">This page doesn't exist or has been moved.</p>
119
- <a href="/" class="btn">
130
+ <a href="<%= base_url %>" class="btn">
120
131
  <%= icon(:house_line) %>
121
132
  Back to home
122
133
  </a>
@@ -7,7 +7,7 @@
7
7
  <style>
8
8
  @font-face {
9
9
  font-family: 'Inter';
10
- src: url('/_docyard/fonts/Inter-Variable.ttf') format('truetype');
10
+ src: url('<%= base_url %>_docyard/fonts/Inter-Variable.ttf') format('truetype');
11
11
  font-weight: 100 900;
12
12
  font-style: normal;
13
13
  }
@@ -35,6 +35,17 @@
35
35
  --destructive: #f87171;
36
36
  }
37
37
 
38
+ <% if @primary_color && (@primary_color[:light] || @primary_color[:dark]) %>
39
+ <% light_color = @primary_color[:light] %>
40
+ <% dark_color = @primary_color[:dark] || light_color %>
41
+ <% if light_color %>
42
+ :root { --primary: <%= light_color %>; }
43
+ .dark { --primary: <%= dark_color %>; }
44
+ <% elsif dark_color %>
45
+ .dark { --primary: <%= dark_color %>; }
46
+ <% end %>
47
+ <% end %>
48
+
38
49
  * {
39
50
  margin: 0;
40
51
  padding: 0;
@@ -166,7 +177,7 @@
166
177
  <p class="error-code">500</p>
167
178
  <h1 class="error-title">Something went wrong</h1>
168
179
  <p class="error-message">We encountered an unexpected error. Please try again.</p>
169
- <a href="/" class="btn">
180
+ <a href="<%= base_url %>" class="btn">
170
181
  <%= icon(:house_line) %>
171
182
  Back to home
172
183
  </a>
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta http-equiv="refresh" content="0;url=<%= @target_url %>">
6
+ <title>Redirecting...</title>
7
+ <script>window.location.href = "<%= @target_url %>";</script>
8
+ </head>
9
+ <body>
10
+ <p>Redirecting to <a href="<%= @target_url %>"><%= @target_url %></a>...</p>
11
+ </body>
12
+ </html>
@@ -32,7 +32,7 @@
32
32
  <% end %>
33
33
  <% end %>
34
34
 
35
- <link rel="preload" href="/_docyard/fonts/Inter-Variable.ttf" as="font" type="font/ttf" crossorigin>
35
+ <link rel="preload" href="<%= asset_path('_docyard/fonts/Inter-Variable.ttf') %>" as="font" type="font/ttf" crossorigin>
36
36
 
37
37
  <%= render_partial('_icon_library') %>
38
38
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Docyard
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docyard
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sanif Himani
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-22 00:00:00.000000000 Z
11
+ date: 2026-01-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cssminify
@@ -178,7 +178,7 @@ dependencies:
178
178
  - - "~>"
179
179
  - !ruby/object:Gem::Version
180
180
  version: '0.18'
181
- description: Beautiful, zero-config documentation sites. Built with Ruby.
181
+ description: Generate beautiful documentation sites from Markdown
182
182
  email:
183
183
  - sanifhimani92@gmail.com
184
184
  executables:
@@ -192,8 +192,11 @@ files:
192
192
  - exe/docyard
193
193
  - lib/docyard.rb
194
194
  - lib/docyard/build/asset_bundler.rb
195
+ - lib/docyard/build/error_page_generator.rb
195
196
  - lib/docyard/build/file_copier.rb
197
+ - lib/docyard/build/file_writer.rb
196
198
  - lib/docyard/build/llms_txt_generator.rb
199
+ - lib/docyard/build/root_fallback_generator.rb
197
200
  - lib/docyard/build/sitemap_generator.rb
198
201
  - lib/docyard/build/static_generator.rb
199
202
  - lib/docyard/builder.rb
@@ -347,6 +350,7 @@ files:
347
350
  - lib/docyard/templates/config/docyard.yml.erb
348
351
  - lib/docyard/templates/errors/404.html.erb
349
352
  - lib/docyard/templates/errors/500.html.erb
353
+ - lib/docyard/templates/errors/redirect.html.erb
350
354
  - lib/docyard/templates/init/_sidebar.yml
351
355
  - lib/docyard/templates/init/docyard.yml
352
356
  - lib/docyard/templates/init/pages/components.md
@@ -399,13 +403,16 @@ files:
399
403
  - lib/docyard/utils/text_formatter.rb
400
404
  - lib/docyard/utils/url_helpers.rb
401
405
  - lib/docyard/version.rb
402
- homepage: https://github.com/sanifhimani/docyard
406
+ homepage: https://docyard.dev
403
407
  licenses:
404
408
  - MIT
405
409
  metadata:
406
410
  allowed_push_host: https://rubygems.org
407
- homepage_uri: https://github.com/sanifhimani/docyard
411
+ homepage_uri: https://docyard.dev
408
412
  source_code_uri: https://github.com/sanifhimani/docyard
413
+ changelog_uri: https://github.com/sanifhimani/docyard/blob/main/CHANGELOG.md
414
+ documentation_uri: https://docyard.dev
415
+ bug_tracker_uri: https://github.com/sanifhimani/docyard/issues
409
416
  rubygems_mfa_required: 'true'
410
417
  post_install_message:
411
418
  rdoc_options: []
@@ -425,5 +432,5 @@ requirements: []
425
432
  rubygems_version: 3.5.22
426
433
  signing_key:
427
434
  specification_version: 4
428
- summary: Documentation generator for Ruby
435
+ summary: Generate beautiful documentation sites from Markdown
429
436
  test_files: []