docyard 1.0.1 → 1.1.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -1
  3. data/lib/docyard/build/asset_bundler.rb +9 -34
  4. data/lib/docyard/build/file_copier.rb +7 -15
  5. data/lib/docyard/build/llms_txt_generator.rb +0 -2
  6. data/lib/docyard/build/sitemap_generator.rb +1 -1
  7. data/lib/docyard/build/static_generator.rb +32 -33
  8. data/lib/docyard/build/step_runner.rb +88 -0
  9. data/lib/docyard/build/validator.rb +98 -0
  10. data/lib/docyard/builder.rb +82 -55
  11. data/lib/docyard/cli.rb +36 -4
  12. data/lib/docyard/components/aliases.rb +0 -4
  13. data/lib/docyard/components/processors/callout_processor.rb +1 -1
  14. data/lib/docyard/components/processors/code_block_diff_preprocessor.rb +1 -1
  15. data/lib/docyard/components/processors/code_block_focus_preprocessor.rb +1 -1
  16. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +2 -2
  17. data/lib/docyard/components/processors/code_group_processor.rb +1 -1
  18. data/lib/docyard/components/processors/icon_processor.rb +2 -2
  19. data/lib/docyard/components/processors/tabs_processor.rb +1 -1
  20. data/lib/docyard/config/schema/definition.rb +29 -0
  21. data/lib/docyard/config/schema/sections.rb +63 -0
  22. data/lib/docyard/config/schema/simple_sections.rb +78 -0
  23. data/lib/docyard/config/schema.rb +28 -31
  24. data/lib/docyard/config/type_validators.rb +121 -0
  25. data/lib/docyard/config/validator.rb +136 -61
  26. data/lib/docyard/config.rb +1 -13
  27. data/lib/docyard/diagnostic.rb +89 -0
  28. data/lib/docyard/diagnostic_context.rb +48 -0
  29. data/lib/docyard/doctor/code_block_checker.rb +136 -0
  30. data/lib/docyard/doctor/component_checker.rb +49 -0
  31. data/lib/docyard/doctor/component_checkers/abbreviation_checker.rb +74 -0
  32. data/lib/docyard/doctor/component_checkers/badge_checker.rb +71 -0
  33. data/lib/docyard/doctor/component_checkers/base.rb +111 -0
  34. data/lib/docyard/doctor/component_checkers/callout_checker.rb +34 -0
  35. data/lib/docyard/doctor/component_checkers/cards_checker.rb +57 -0
  36. data/lib/docyard/doctor/component_checkers/code_group_checker.rb +47 -0
  37. data/lib/docyard/doctor/component_checkers/details_checker.rb +51 -0
  38. data/lib/docyard/doctor/component_checkers/icon_checker.rb +36 -0
  39. data/lib/docyard/doctor/component_checkers/image_attrs_checker.rb +46 -0
  40. data/lib/docyard/doctor/component_checkers/space_after_colons_checker.rb +45 -0
  41. data/lib/docyard/doctor/component_checkers/steps_checker.rb +35 -0
  42. data/lib/docyard/doctor/component_checkers/tabs_checker.rb +35 -0
  43. data/lib/docyard/doctor/component_checkers/tooltip_checker.rb +67 -0
  44. data/lib/docyard/doctor/component_checkers/unknown_type_checker.rb +34 -0
  45. data/lib/docyard/doctor/config_checker.rb +19 -0
  46. data/lib/docyard/doctor/config_fixer.rb +87 -0
  47. data/lib/docyard/doctor/content_checker.rb +164 -0
  48. data/lib/docyard/doctor/file_scanner.rb +113 -0
  49. data/lib/docyard/doctor/image_checker.rb +103 -0
  50. data/lib/docyard/doctor/link_checker.rb +91 -0
  51. data/lib/docyard/doctor/markdown_fixer.rb +62 -0
  52. data/lib/docyard/doctor/orphan_checker.rb +82 -0
  53. data/lib/docyard/doctor/reporter.rb +152 -0
  54. data/lib/docyard/doctor/sidebar_checker.rb +127 -0
  55. data/lib/docyard/doctor/sidebar_fixer.rb +47 -0
  56. data/lib/docyard/doctor.rb +178 -0
  57. data/lib/docyard/editor_launcher.rb +119 -0
  58. data/lib/docyard/errors.rb +0 -49
  59. data/lib/docyard/initializer.rb +32 -39
  60. data/lib/docyard/navigation/page_navigation_builder.rb +5 -3
  61. data/lib/docyard/navigation/prev_next_builder.rb +4 -3
  62. data/lib/docyard/navigation/sidebar/local_config_loader.rb +44 -21
  63. data/lib/docyard/rendering/icon_helpers.rb +1 -3
  64. data/lib/docyard/rendering/renderer.rb +17 -2
  65. data/lib/docyard/search/build_indexer.rb +39 -24
  66. data/lib/docyard/search/dev_indexer.rb +9 -23
  67. data/lib/docyard/server/dev_server.rb +55 -13
  68. data/lib/docyard/server/error_overlay.rb +73 -0
  69. data/lib/docyard/server/file_watcher.rb +0 -1
  70. data/lib/docyard/server/page_diagnostics.rb +27 -0
  71. data/lib/docyard/server/preview_server.rb +20 -8
  72. data/lib/docyard/server/rack_application.rb +64 -3
  73. data/lib/docyard/server/resolution_result.rb +0 -4
  74. data/lib/docyard/server/static_file_app.rb +20 -3
  75. data/lib/docyard/templates/assets/css/error-overlay.css +669 -0
  76. data/lib/docyard/templates/assets/css/variables.css +1 -1
  77. data/lib/docyard/templates/assets/fonts/Inter-Variable.woff2 +0 -0
  78. data/lib/docyard/templates/assets/js/components/relative-time.js +42 -0
  79. data/lib/docyard/templates/assets/js/error-overlay.js +547 -0
  80. data/lib/docyard/templates/assets/js/hot-reload.js +35 -7
  81. data/lib/docyard/templates/errors/404.html.erb +1 -1
  82. data/lib/docyard/templates/errors/500.html.erb +1 -1
  83. data/lib/docyard/templates/partials/_head.html.erb +1 -1
  84. data/lib/docyard/templates/partials/_page_actions.html.erb +1 -1
  85. data/lib/docyard/ui.rb +80 -0
  86. data/lib/docyard/utils/logging.rb +5 -1
  87. data/lib/docyard/utils/text_formatter.rb +0 -6
  88. data/lib/docyard/version.rb +1 -1
  89. data/lib/docyard.rb +4 -0
  90. metadata +54 -29
  91. data/lib/docyard/config/key_validator.rb +0 -30
  92. data/lib/docyard/config/validation_helpers.rb +0 -83
  93. data/lib/docyard/config/validators/navigation.rb +0 -43
  94. data/lib/docyard/config/validators/section.rb +0 -114
  95. data/lib/docyard/templates/assets/fonts/Inter-Variable.ttf +0 -0
@@ -18,39 +18,26 @@ module Docyard
18
18
  end
19
19
 
20
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
21
+ return [0, nil] unless search_enabled?
22
+ return [0, nil] unless pagefind_available?
29
23
 
30
24
  run_pagefind
31
25
  end
32
26
 
33
27
  private
34
28
 
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
29
  def run_pagefind
42
30
  args = build_pagefind_args(output_dir)
43
- log "Running: npx #{args.join(' ')}" if verbose
44
31
 
45
32
  stdout, stderr, status = Open3.capture3(PAGEFIND_COMMAND, *args)
46
33
 
47
34
  if status.success?
48
- page_count = extract_page_count(stdout)
49
- log "[+] Generated search index (#{page_count} pages indexed)"
50
- page_count
35
+ count = extract_page_count(stdout)
36
+ details = verbose ? collect_index_details : nil
37
+ [count, details]
51
38
  else
52
- log_warning "[!] Search indexing failed: #{stderr}"
53
- 0
39
+ Docyard.logger.warn("Search indexing failed: #{stderr}") if verbose
40
+ [0, nil]
54
41
  end
55
42
  end
56
43
 
@@ -62,12 +49,40 @@ module Docyard
62
49
  end
63
50
  end
64
51
 
65
- def log(message)
66
- Docyard.logger.info(message)
52
+ def collect_index_details
53
+ indexed, excluded = classify_pages
54
+ format_index_details(indexed, excluded)
55
+ end
56
+
57
+ def classify_pages
58
+ indexed = []
59
+ excluded = []
60
+
61
+ Dir.glob(File.join(output_dir, "**", "index.html")).each do |file|
62
+ path = extract_page_path(file)
63
+ classify_page(File.read(file), path, indexed, excluded)
64
+ end
65
+
66
+ [indexed, excluded]
67
+ end
68
+
69
+ def extract_page_path(file)
70
+ path = file.delete_prefix("#{output_dir}/").delete_suffix("/index.html")
71
+ path.empty? ? "/" : path
72
+ end
73
+
74
+ def classify_page(content, path, indexed, excluded)
75
+ if content.include?("data-pagefind-body")
76
+ indexed << path
77
+ elsif content.include?("data-pagefind-ignore")
78
+ excluded << path
79
+ end
67
80
  end
68
81
 
69
- def log_warning(message)
70
- Docyard.logger.warn(message)
82
+ def format_index_details(indexed, excluded)
83
+ details = indexed.sort
84
+ excluded.sort.each { |path| details << "(excluded: #{path})" } if excluded.any?
85
+ details
71
86
  end
72
87
  end
73
88
  end
@@ -4,7 +4,6 @@ require "fileutils"
4
4
  require "tmpdir"
5
5
  require "open3"
6
6
  require "parallel"
7
- require "tty-progressbar"
8
7
  require_relative "../utils/path_utils"
9
8
 
10
9
  module Docyard
@@ -14,13 +13,15 @@ module Docyard
14
13
 
15
14
  PARALLEL_THRESHOLD = 10
16
15
 
17
- attr_reader :docs_path, :config, :temp_dir, :pagefind_path
16
+ attr_reader :docs_path, :config, :temp_dir, :pagefind_path, :page_count, :total_pages
18
17
 
19
18
  def initialize(docs_path:, config:)
20
19
  @docs_path = docs_path
21
20
  @config = config
22
21
  @temp_dir = nil
23
22
  @pagefind_path = nil
23
+ @page_count = 0
24
+ @total_pages = 0
24
25
  end
25
26
 
26
27
  def generate
@@ -29,10 +30,9 @@ module Docyard
29
30
 
30
31
  @temp_dir = Dir.mktmpdir("docyard-search-")
31
32
  generate_html_files
32
- page_count = run_pagefind
33
+ @page_count = run_pagefind
33
34
  @pagefind_path = File.join(temp_dir, "_docyard", "pagefind")
34
35
 
35
- log_success(page_count)
36
36
  pagefind_path
37
37
  rescue StandardError => e
38
38
  Docyard.logger.warn("Search index generation failed: #{e.message}")
@@ -58,36 +58,27 @@ module Docyard
58
58
  markdown_files = Dir.glob(File.join(docs_path, "**", "*.md"))
59
59
  markdown_files = filter_excluded_files(markdown_files)
60
60
  markdown_files = filter_non_indexable_files(markdown_files)
61
-
62
- progress = TTY::ProgressBar.new(
63
- "Indexing search [:bar] :current/:total (:percent)",
64
- total: markdown_files.size,
65
- width: 50
66
- )
67
- mutex = Mutex.new
61
+ @total_pages = markdown_files.size
68
62
 
69
63
  Logging.start_buffering
70
64
  if markdown_files.size >= PARALLEL_THRESHOLD
71
- generate_files_in_parallel(markdown_files, progress, mutex)
65
+ generate_files_in_parallel(markdown_files)
72
66
  else
73
- generate_files_sequentially(markdown_files, progress)
67
+ generate_files_sequentially(markdown_files)
74
68
  end
75
- Logging.flush_warnings
76
69
  end
77
70
 
78
- def generate_files_in_parallel(markdown_files, progress, mutex)
71
+ def generate_files_in_parallel(markdown_files)
79
72
  Parallel.each(markdown_files, in_threads: Parallel.processor_count) do |file_path|
80
73
  renderer = thread_local_renderer
81
74
  generate_html_file(file_path, renderer)
82
- mutex.synchronize { progress.advance }
83
75
  end
84
76
  end
85
77
 
86
- def generate_files_sequentially(markdown_files, progress)
78
+ def generate_files_sequentially(markdown_files)
87
79
  renderer = Renderer.new(base_url: "/", config: config)
88
80
  markdown_files.each do |file_path|
89
81
  generate_html_file(file_path, renderer)
90
- progress.advance
91
82
  end
92
83
  end
93
84
 
@@ -153,11 +144,6 @@ module Docyard
153
144
  match = output.match(/Indexed (\d+) page/i)
154
145
  match ? match[1].to_i : 0
155
146
  end
156
-
157
- def log_success(page_count)
158
- Docyard.logger.info("* Search index generated (#{page_count} pages indexed)")
159
- Docyard.logger.debug("* Temp directory: #{temp_dir}")
160
- end
161
147
  end
162
148
  end
163
149
  end
@@ -9,13 +9,15 @@ require_relative "sse_server"
9
9
  require_relative "file_watcher"
10
10
  require_relative "../config"
11
11
  require_relative "../navigation/sidebar/cache"
12
+ require_relative "../doctor/config_checker"
13
+ require_relative "../doctor/sidebar_checker"
12
14
 
13
15
  module Docyard
14
16
  class DevServer
15
17
  DEFAULT_PORT = 4200
16
18
  DEFAULT_HOST = "localhost"
17
19
 
18
- attr_reader :port, :host, :docs_path, :config, :search_enabled
20
+ attr_reader :port, :host, :docs_path, :config, :search_enabled, :global_diagnostics
19
21
 
20
22
  def initialize(port: DEFAULT_PORT, host: DEFAULT_HOST, docs_path: "docs", search: false)
21
23
  @port = port
@@ -28,12 +30,13 @@ module Docyard
28
30
  @file_watcher = nil
29
31
  @launcher = nil
30
32
  @sidebar_cache = nil
33
+ @global_diagnostics = []
31
34
  end
32
35
 
33
36
  def start
34
37
  validate_docs_directory!
35
38
  build_sidebar_cache
36
- generate_search_index if @search_enabled
39
+ collect_global_diagnostics
37
40
  setup_hot_reload
38
41
  print_server_info
39
42
  run_server
@@ -51,6 +54,14 @@ module Docyard
51
54
  @sidebar_cache.build
52
55
  end
53
56
 
57
+ def collect_global_diagnostics
58
+ @global_diagnostics.clear
59
+ @global_diagnostics.concat(Doctor::ConfigChecker.new(@config).check)
60
+ @global_diagnostics.concat(Doctor::SidebarChecker.new(File.expand_path(docs_path)).check)
61
+ rescue StandardError => e
62
+ Docyard.logger.warn("Diagnostic collection failed: #{e.message}")
63
+ end
64
+
54
65
  def generate_search_index
55
66
  @search_indexer = Search::DevIndexer.new(
56
67
  docs_path: File.expand_path(docs_path),
@@ -75,11 +86,22 @@ module Docyard
75
86
  end
76
87
 
77
88
  def handle_file_change(change_type)
78
- invalidate_sidebar_cache if change_type == :full
89
+ if change_type == :full
90
+ reload_config
91
+ invalidate_sidebar_cache
92
+ collect_global_diagnostics
93
+ @sse_server.broadcast("diagnostics", { global: @global_diagnostics.map(&:to_h) })
94
+ end
79
95
  log_file_change(change_type)
80
96
  @sse_server.broadcast("reload", { type: change_type.to_s })
81
97
  end
82
98
 
99
+ def reload_config
100
+ @config = Config.load
101
+ rescue ConfigError => e
102
+ Docyard.logger.warn("Config reload failed: #{e.message}")
103
+ end
104
+
83
105
  def invalidate_sidebar_cache
84
106
  @sidebar_cache&.invalidate
85
107
  @sidebar_cache&.build
@@ -88,7 +110,7 @@ module Docyard
88
110
  def log_file_change(change_type)
89
111
  message = case change_type
90
112
  when :content then "Content changed, reloading..."
91
- when :config then "Config changed, full reload..."
113
+ when :full then "Config changed, full reload..."
92
114
  when :asset then "Asset changed, reloading..."
93
115
  else "File changed, reloading..."
94
116
  end
@@ -104,17 +126,36 @@ module Docyard
104
126
  def validate_docs_directory!
105
127
  return if File.directory?(docs_path)
106
128
 
107
- abort "Error: #{docs_path}/ directory not found.\n" \
129
+ abort "#{UI.error('Error:')} #{docs_path}/ directory not found.\n" \
108
130
  "Run `docyard init` first to create the docs structure."
109
131
  end
110
132
 
111
- def print_server_info
112
- Docyard.logger.info("Starting Docyard server...")
113
- Docyard.logger.info("* Version: #{Docyard::VERSION}")
114
- Docyard.logger.info("* Running at: http://#{host}:#{port}")
115
- Docyard.logger.info("* Hot reload: ws://127.0.0.1:#{sse_port}")
116
- Docyard.logger.info("* Search: #{@search_enabled ? 'enabled' : 'disabled (use --search to enable)'}")
117
- Docyard.logger.info("Use Ctrl+C to stop\n")
133
+ def print_server_info # rubocop:disable Metrics/AbcSize
134
+ search_status = @search_enabled ? UI.green("enabled") : UI.dim("disabled")
135
+
136
+ puts
137
+ puts " #{UI.bold('Docyard')} v#{Docyard::VERSION}"
138
+ puts
139
+ puts " Serving #{docs_path}/"
140
+ puts " #{UI.cyan("http://#{host}:#{port}")}"
141
+ puts
142
+ puts " Hot reload: #{UI.cyan("ws://#{host}:#{sse_port}")}"
143
+ puts " Search: #{search_status}"
144
+ print_indexing_status if @search_enabled
145
+ puts
146
+ puts " #{UI.dim('Press Ctrl+C to stop')}"
147
+ puts
148
+ end
149
+
150
+ def print_indexing_status
151
+ print " Indexing: #{UI.dim('in progress')}"
152
+ $stdout.flush
153
+ generate_search_index
154
+ count = @search_indexer&.page_count || 0
155
+ total = @search_indexer&.total_pages || 0
156
+ print "\r Indexing: #{UI.green("done (#{count}/#{total} pages)")}\n"
157
+ $stdout.flush
158
+ Logging.flush_warnings
118
159
  end
119
160
 
120
161
  def run_server
@@ -132,7 +173,8 @@ module Docyard
132
173
  config: @config,
133
174
  pagefind_path: @search_indexer&.pagefind_path,
134
175
  sse_port: sse_port,
135
- sidebar_cache: @sidebar_cache
176
+ sidebar_cache: @sidebar_cache,
177
+ global_diagnostics: @global_diagnostics
136
178
  )
137
179
  end
138
180
 
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../editor_launcher"
5
+
6
+ module Docyard
7
+ class ErrorOverlay
8
+ CATEGORY_LABELS = {
9
+ CONFIG: "Configuration",
10
+ SIDEBAR: "Sidebar",
11
+ CONTENT: "Content",
12
+ COMPONENT: "Component",
13
+ LINK: "Links",
14
+ IMAGE: "Images",
15
+ SYNTAX: "Syntax",
16
+ ORPHAN: "Orphan Pages"
17
+ }.freeze
18
+
19
+ GLOBAL_CATEGORIES = %i[CONFIG SIDEBAR ORPHAN].freeze
20
+
21
+ class << self
22
+ def render(diagnostics:, current_file:, sse_port:)
23
+ return "" if diagnostics.empty?
24
+
25
+ attrs = build_data_attributes(diagnostics, current_file, sse_port)
26
+ render_overlay_html(attrs)
27
+ end
28
+
29
+ private
30
+
31
+ def build_data_attributes(diagnostics, current_file, sse_port)
32
+ global_count = diagnostics.count { |d| GLOBAL_CATEGORIES.include?(d.category) }
33
+ page_count = diagnostics.length - global_count
34
+
35
+ {
36
+ diagnostics: escape_json(diagnostics),
37
+ current_file: escape_html(current_file),
38
+ error_count: diagnostics.count(&:error?),
39
+ warning_count: diagnostics.count(&:warning?),
40
+ global_count: global_count,
41
+ page_count: page_count,
42
+ sse_port: sse_port,
43
+ editor_available: EditorLauncher.available?
44
+ }
45
+ end
46
+
47
+ def render_overlay_html(attrs)
48
+ <<~HTML
49
+ <div id="docyard-error-overlay" class="docyard-error-overlay"
50
+ data-diagnostics='#{attrs[:diagnostics]}'
51
+ data-current-file="#{attrs[:current_file]}"
52
+ data-error-count="#{attrs[:error_count]}"
53
+ data-warning-count="#{attrs[:warning_count]}"
54
+ data-global-count="#{attrs[:global_count]}"
55
+ data-page-count="#{attrs[:page_count]}"
56
+ data-sse-port="#{attrs[:sse_port]}"
57
+ data-editor-available="#{attrs[:editor_available]}">
58
+ </div>
59
+ <link rel="stylesheet" href="/_docyard/error-overlay.css">
60
+ <script src="/_docyard/error-overlay.js"></script>
61
+ HTML
62
+ end
63
+
64
+ def escape_json(diagnostics)
65
+ JSON.generate(diagnostics.map(&:to_h)).gsub("'", "&#39;")
66
+ end
67
+
68
+ def escape_html(str)
69
+ str.to_s.gsub("&", "&amp;").gsub('"', "&quot;").gsub("<", "&lt;").gsub(">", "&gt;")
70
+ end
71
+ end
72
+ end
73
+ end
@@ -5,7 +5,6 @@ require "listen"
5
5
  module Docyard
6
6
  class FileWatcher
7
7
  DEBOUNCE_DELAY = 0.1
8
- ROOT_CONFIG_FILE = "docyard.yml"
9
8
  CONFIG_FILES = %w[docyard.yml _sidebar.yml].freeze
10
9
  CONTENT_EXTENSIONS = %w[.md .markdown].freeze
11
10
  ASSET_EXTENSIONS = %w[.css .js .html .erb].freeze
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../doctor/content_checker"
4
+ require_relative "../doctor/component_checker"
5
+ require_relative "../doctor/link_checker"
6
+ require_relative "../doctor/image_checker"
7
+
8
+ module Docyard
9
+ class PageDiagnostics
10
+ def initialize(docs_path)
11
+ @docs_path = docs_path
12
+ @content_checker = Doctor::ContentChecker.new(docs_path)
13
+ @component_checker = Doctor::ComponentChecker.new(docs_path)
14
+ @link_checker = Doctor::LinkChecker.new(docs_path)
15
+ @image_checker = Doctor::ImageChecker.new(docs_path)
16
+ end
17
+
18
+ def check(content, file_path)
19
+ [
20
+ @content_checker.check_file(content, file_path),
21
+ @component_checker.check_file(content, file_path),
22
+ @link_checker.check_file(content, file_path),
23
+ @image_checker.check_file(content, file_path)
24
+ ].flatten
25
+ end
26
+ end
27
+ end
@@ -9,14 +9,17 @@ require_relative "static_file_app"
9
9
 
10
10
  module Docyard
11
11
  class PreviewServer
12
+ include Utils::UrlHelpers
13
+
12
14
  DEFAULT_PORT = 4000
13
15
 
14
- attr_reader :port, :output_dir
16
+ attr_reader :port, :output_dir, :base_url
15
17
 
16
18
  def initialize(port: DEFAULT_PORT)
17
19
  @port = port
18
20
  @config = Config.load
19
21
  @output_dir = File.expand_path(@config.build.output)
22
+ @base_url = normalize_base_url(@config.build.base)
20
23
  @launcher = nil
21
24
  end
22
25
 
@@ -29,21 +32,30 @@ module Docyard
29
32
  private
30
33
 
31
34
  def validate_output_directory!
32
- return if File.directory?(output_dir)
35
+ unless File.directory?(output_dir)
36
+ abort "#{UI.error('Error:')} #{output_dir}/ directory not found.\n" \
37
+ "Run `docyard build` first to build the site."
38
+ end
39
+
40
+ return if Dir.glob(File.join(output_dir, "**", "*")).any? { |f| File.file?(f) }
33
41
 
34
- abort "Error: #{output_dir}/ directory not found.\n" \
42
+ abort "#{UI.error('Error:')} #{output_dir}/ is empty.\n" \
35
43
  "Run `docyard build` first to build the site."
36
44
  end
37
45
 
38
46
  def print_server_info
39
- Docyard.logger.info("Starting preview server...")
40
- Docyard.logger.info("* Version: #{Docyard::VERSION}")
41
- Docyard.logger.info("* Running at: http://localhost:#{port}")
42
- Docyard.logger.info("Use Ctrl+C to stop\n")
47
+ puts
48
+ puts " #{UI.bold('Docyard')} v#{Docyard::VERSION}"
49
+ puts
50
+ puts " Previewing #{output_dir}/"
51
+ puts " #{UI.cyan("http://localhost:#{port}#{base_url}")}"
52
+ puts
53
+ puts " #{UI.dim('Press Ctrl+C to stop')}"
54
+ puts
43
55
  end
44
56
 
45
57
  def run_server
46
- app = StaticFileApp.new(output_dir)
58
+ app = StaticFileApp.new(output_dir, base_path: base_url)
47
59
  puma_config = build_puma_config(app)
48
60
  log_writer = Puma::LogWriter.strings
49
61
 
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "rack"
5
4
  require_relative "../navigation/page_navigation_builder"
6
5
  require_relative "../navigation/sidebar_builder"
@@ -9,20 +8,29 @@ require_relative "../constants"
9
8
  require_relative "../rendering/template_resolver"
10
9
  require_relative "../routing/fallback_resolver"
11
10
  require_relative "pagefind_handler"
11
+ require_relative "page_diagnostics"
12
+ require_relative "error_overlay"
13
+ require_relative "../editor_launcher"
12
14
 
13
15
  module Docyard
14
16
  class RackApplication
15
- def initialize(docs_path:, config: nil, pagefind_path: nil, sse_port: nil, sidebar_cache: nil)
17
+ OVERLAY_RESET_SCRIPT = "<script>try{sessionStorage.setItem('docyard-error-overlay'," \
18
+ "'{\"dismissed\":false,\"lastTotalCount\":0}')}catch(e){}</script>"
19
+
20
+ def initialize(docs_path:, config: nil, pagefind_path: nil, sse_port: nil, sidebar_cache: nil,
21
+ global_diagnostics: [])
16
22
  @docs_path = docs_path
17
23
  @config = config
18
24
  @sse_port = sse_port
19
25
  @dev_mode = !sse_port.nil?
20
26
  @sidebar_cache = sidebar_cache
27
+ @global_diagnostics = global_diagnostics
21
28
  @router = Router.new(docs_path: docs_path)
22
29
  @renderer = Renderer.new(base_url: "/", config: config, dev_mode: @dev_mode,
23
30
  sse_port: sse_port)
24
31
  @asset_handler = AssetHandler.new(public_dir: config&.public_dir || "docs/public")
25
32
  @pagefind_handler = PagefindHandler.new(pagefind_path: pagefind_path, config: config)
33
+ @page_diagnostics = PageDiagnostics.new(docs_path) if @dev_mode
26
34
  end
27
35
 
28
36
  def call(env)
@@ -36,6 +44,8 @@ module Docyard
36
44
  def handle_request(env)
37
45
  path = env["PATH_INFO"]
38
46
 
47
+ return handle_open_in_editor(env) if path == "/__docyard/open-in-editor"
48
+ return serve_overlay_asset(path) if path.start_with?("/_docyard/error-overlay")
39
49
  return pagefind_handler.serve(path) if path.start_with?(Constants::PAGEFIND_PREFIX)
40
50
  return asset_handler.serve_docyard_assets(path) if path.start_with?(Constants::DOCYARD_ASSETS_PREFIX)
41
51
 
@@ -94,7 +104,8 @@ module Docyard
94
104
  end
95
105
 
96
106
  def render_documentation_page(file_path, current_path)
97
- markdown = Markdown.new(File.read(file_path))
107
+ content = File.read(file_path)
108
+ markdown = Markdown.new(content)
98
109
  template_resolver = TemplateResolver.new(markdown.frontmatter, @config&.data)
99
110
  branding = branding_options
100
111
 
@@ -103,6 +114,8 @@ module Docyard
103
114
  template_options: template_resolver.to_options,
104
115
  current_path: current_path)
105
116
 
117
+ html = inject_error_overlay(html, content, file_path) if dev_mode
118
+
106
119
  [Constants::STATUS_OK, { "Content-Type" => Constants::CONTENT_TYPE_HTML }, [html]]
107
120
  end
108
121
 
@@ -156,5 +169,53 @@ module Docyard
156
169
  user_agent = env["HTTP_USER_AGENT"]&.slice(0, 50)
157
170
  user_agent ? "#{method} #{path} - #{user_agent}" : "#{method} #{path}"
158
171
  end
172
+
173
+ def inject_error_overlay(html, content, file_path)
174
+ page_diags = @page_diagnostics.check(content, file_path)
175
+ all_diagnostics = @global_diagnostics + page_diags
176
+ return html.sub("</body>", "#{OVERLAY_RESET_SCRIPT}</body>") if all_diagnostics.empty?
177
+
178
+ current_file = file_path.delete_prefix("#{@docs_path}/")
179
+ overlay = ErrorOverlay.render(
180
+ diagnostics: all_diagnostics,
181
+ current_file: current_file,
182
+ sse_port: @sse_port
183
+ )
184
+
185
+ html.sub("</body>", "#{overlay}</body>")
186
+ end
187
+
188
+ def handle_open_in_editor(env)
189
+ params = Rack::Utils.parse_query(env["QUERY_STRING"])
190
+ file = params["file"]
191
+ line = params["line"]&.to_i || 1
192
+
193
+ return [400, {}, ["Missing file parameter"]] unless file
194
+ return [404, {}, ["No editor detected"]] unless EditorLauncher.available?
195
+
196
+ full_path = File.join(@docs_path, file)
197
+ EditorLauncher.open(full_path, line)
198
+ [200, {}, ["OK"]]
199
+ end
200
+
201
+ def serve_overlay_asset(path)
202
+ asset_path = resolve_overlay_asset_path(path)
203
+ return [404, {}, ["Not found"]] unless asset_path && File.exist?(asset_path)
204
+
205
+ content_type = path.end_with?(".css") ? "text/css" : "application/javascript"
206
+ [200, { "Content-Type" => content_type }, [File.read(asset_path)]]
207
+ end
208
+
209
+ def resolve_overlay_asset_path(path)
210
+ asset_name = path.delete_prefix("/_docyard/")
211
+ css_path = File.join(templates_path, "assets", asset_name.sub("error-overlay", "css/error-overlay"))
212
+ return css_path if File.exist?(css_path)
213
+
214
+ File.join(templates_path, "assets", asset_name.sub("error-overlay", "js/error-overlay"))
215
+ end
216
+
217
+ def templates_path
218
+ File.expand_path("../templates", __dir__)
219
+ end
159
220
  end
160
221
  end
@@ -21,9 +21,5 @@ module Docyard
21
21
  def found?
22
22
  status == :found
23
23
  end
24
-
25
- def not_found?
26
- status == :not_found
27
- end
28
24
  end
29
25
  end
@@ -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")