docyard 1.0.2 → 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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -1
  3. data/lib/docyard/build/asset_bundler.rb +7 -33
  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 +30 -32
  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/sidebar/local_config_loader.rb +44 -21
  61. data/lib/docyard/rendering/icon_helpers.rb +1 -3
  62. data/lib/docyard/search/build_indexer.rb +39 -24
  63. data/lib/docyard/search/dev_indexer.rb +9 -23
  64. data/lib/docyard/server/dev_server.rb +55 -13
  65. data/lib/docyard/server/error_overlay.rb +73 -0
  66. data/lib/docyard/server/file_watcher.rb +0 -1
  67. data/lib/docyard/server/page_diagnostics.rb +27 -0
  68. data/lib/docyard/server/preview_server.rb +17 -13
  69. data/lib/docyard/server/rack_application.rb +64 -3
  70. data/lib/docyard/server/resolution_result.rb +0 -4
  71. data/lib/docyard/templates/assets/css/error-overlay.css +669 -0
  72. data/lib/docyard/templates/assets/css/variables.css +1 -1
  73. data/lib/docyard/templates/assets/fonts/Inter-Variable.woff2 +0 -0
  74. data/lib/docyard/templates/assets/js/components/relative-time.js +42 -0
  75. data/lib/docyard/templates/assets/js/error-overlay.js +547 -0
  76. data/lib/docyard/templates/assets/js/hot-reload.js +35 -7
  77. data/lib/docyard/templates/errors/404.html.erb +1 -1
  78. data/lib/docyard/templates/errors/500.html.erb +1 -1
  79. data/lib/docyard/templates/partials/_head.html.erb +1 -1
  80. data/lib/docyard/templates/partials/_page_actions.html.erb +1 -1
  81. data/lib/docyard/ui.rb +80 -0
  82. data/lib/docyard/utils/logging.rb +5 -1
  83. data/lib/docyard/utils/text_formatter.rb +0 -6
  84. data/lib/docyard/version.rb +1 -1
  85. data/lib/docyard.rb +4 -0
  86. metadata +47 -25
  87. data/lib/docyard/config/key_validator.rb +0 -30
  88. data/lib/docyard/config/validation_helpers.rb +0 -83
  89. data/lib/docyard/config/validators/navigation.rb +0 -43
  90. data/lib/docyard/config/validators/section.rb +0 -114
  91. data/lib/docyard/templates/assets/fonts/Inter-Variable.ttf +0 -0
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "doctor/config_checker"
4
+ require_relative "doctor/sidebar_checker"
5
+ require_relative "doctor/content_checker"
6
+ require_relative "doctor/component_checker"
7
+ require_relative "doctor/code_block_checker"
8
+ require_relative "doctor/link_checker"
9
+ require_relative "doctor/image_checker"
10
+ require_relative "doctor/orphan_checker"
11
+ require_relative "doctor/file_scanner"
12
+ require_relative "doctor/config_fixer"
13
+ require_relative "doctor/sidebar_fixer"
14
+ require_relative "doctor/markdown_fixer"
15
+ require_relative "doctor/reporter"
16
+
17
+ module Docyard
18
+ class Doctor
19
+ attr_reader :config, :docs_path, :fix
20
+
21
+ def initialize(fix: false)
22
+ @fix = fix
23
+ @config = load_config_safely
24
+ @docs_path = config&.source || "docs"
25
+ end
26
+
27
+ def run
28
+ fix ? run_with_fix : run_check_only
29
+ end
30
+
31
+ private
32
+
33
+ def run_check_only
34
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ diagnostics, stats = collect_diagnostics
36
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
37
+
38
+ reporter = Reporter.new(diagnostics, stats, duration: duration)
39
+ reporter.print
40
+ reporter.exit_code
41
+ end
42
+
43
+ def run_with_fix
44
+ config_fixer = ConfigFixer.new
45
+ sidebar_fixer = SidebarFixer.new(docs_path)
46
+ markdown_fixer = MarkdownFixer.new(docs_path)
47
+
48
+ run_config_fix_loop(config_fixer)
49
+ run_sidebar_fix(sidebar_fixer)
50
+ run_markdown_fix(markdown_fixer)
51
+
52
+ print_fix_results(config_fixer, sidebar_fixer, markdown_fixer)
53
+
54
+ diagnostics_after, _stats_after = collect_diagnostics
55
+ remaining_errors = diagnostics_after.count(&:error?)
56
+
57
+ print_remaining_issues(diagnostics_after) if remaining_errors.positive?
58
+ remaining_errors.positive? ? 1 : 0
59
+ end
60
+
61
+ def run_config_fix_loop(fixer)
62
+ max_iterations = 10
63
+ max_iterations.times do
64
+ reload_config
65
+ diagnostics, _stats = collect_diagnostics
66
+ config_diagnostics = diagnostics.select { |d| d.category == :CONFIG && d.fixable? }
67
+ break if config_diagnostics.empty?
68
+
69
+ count_before = fixer.fixed_count
70
+ fixer.fix(config_diagnostics)
71
+ break if fixer.fixed_count == count_before # no progress made
72
+ end
73
+ end
74
+
75
+ def run_sidebar_fix(fixer)
76
+ diagnostics, _stats = collect_diagnostics
77
+ sidebar_diagnostics = diagnostics.select { |d| d.category == :SIDEBAR && d.fixable? }
78
+ fixer.fix(sidebar_diagnostics)
79
+ end
80
+
81
+ def run_markdown_fix(fixer)
82
+ diagnostics, _stats = collect_diagnostics
83
+ component_diagnostics = diagnostics.select { |d| d.category == :COMPONENT && d.fixable? }
84
+ fixer.fix(component_diagnostics)
85
+ end
86
+
87
+ def reload_config
88
+ @config = load_config_safely
89
+ end
90
+
91
+ def print_fix_results(config_fixer, sidebar_fixer, markdown_fixer)
92
+ print_fix_header
93
+ fixers = [
94
+ [config_fixer, "docyard.yml"],
95
+ [sidebar_fixer, "_sidebar.yml"],
96
+ [markdown_fixer, "markdown files"]
97
+ ]
98
+ total_fixed = fixers.sum { |f, _| f.fixed_count }
99
+
100
+ total_fixed.positive? ? print_all_fixes(fixers, total_fixed) : print_no_fixes
101
+ end
102
+
103
+ def print_all_fixes(fixers, total_fixed)
104
+ fixers.each { |fixer, name| print_fixer_results(fixer, name) }
105
+ puts " #{UI.success("Fixed #{total_fixed} issue(s) total")}"
106
+ end
107
+
108
+ def print_fixer_results(fixer, name)
109
+ return unless fixer.fixed_count.positive?
110
+
111
+ puts " Fixed #{fixer.fixed_count} issue(s) in #{name}:"
112
+ print_fixed_issues(fixer) if fixer.respond_to?(:fixed_issues)
113
+ puts
114
+ end
115
+
116
+ def print_fixed_issues(fixer)
117
+ fixer.fixed_issues.each { |d| puts " #{UI.dim(d.location)}: #{describe_fix(d)}" }
118
+ end
119
+
120
+ def print_no_fixes
121
+ puts " #{UI.yellow('No issues were auto-fixed.')}"
122
+ end
123
+
124
+ def print_fix_header
125
+ puts
126
+ puts " #{UI.bold('Docyard')} v#{VERSION}"
127
+ puts
128
+ end
129
+
130
+ def describe_fix(diagnostic)
131
+ case diagnostic.fix[:type]
132
+ when :rename then "renamed '#{diagnostic.fix[:from]}' to '#{diagnostic.fix[:to]}'"
133
+ when :replace then "changed to #{diagnostic.fix[:value].inspect}"
134
+ when :line_replace then "replaced '#{diagnostic.fix[:from]}' with '#{diagnostic.fix[:to]}'"
135
+ else "fixed"
136
+ end
137
+ end
138
+
139
+ def print_remaining_issues(diagnostics)
140
+ puts
141
+ puts " Remaining issues:"
142
+ diagnostics.reject(&:fixable?).each { |d| puts " #{d.location}: #{d.message}" }
143
+ puts
144
+ end
145
+
146
+ def load_config_safely
147
+ Config.load(Dir.pwd)
148
+ rescue ConfigError
149
+ nil
150
+ end
151
+
152
+ def collect_diagnostics
153
+ file_scanner = FileScanner.new(docs_path)
154
+ scanner_diagnostics = file_scanner.scan
155
+
156
+ diagnostics = [
157
+ collect_config_and_sidebar_diagnostics,
158
+ scanner_diagnostics,
159
+ config ? OrphanChecker.new(docs_path, config).check : []
160
+ ].flatten
161
+
162
+ stats = {
163
+ files: file_scanner.files_scanned,
164
+ links: file_scanner.links_checked,
165
+ images: file_scanner.images_checked
166
+ }
167
+
168
+ [diagnostics, stats]
169
+ end
170
+
171
+ def collect_config_and_sidebar_diagnostics
172
+ diagnostics = []
173
+ diagnostics.concat(ConfigChecker.new(config).check) if config
174
+ diagnostics.concat(SidebarChecker.new(docs_path).check)
175
+ diagnostics
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module EditorLauncher
5
+ EDITORS = {
6
+ vscode: {
7
+ patterns: ["code", "Code", "Visual Studio Code"],
8
+ command: ->(f, l) { ["code", "--goto", "#{f}:#{l}"] }
9
+ },
10
+ cursor: { patterns: %w[cursor Cursor], command: ->(f, l) { ["cursor", "--goto", "#{f}:#{l}"] } },
11
+ zed: { patterns: %w[zed Zed], command: ->(f, l) { ["zed", "#{f}:#{l}"] } },
12
+ webstorm: { patterns: %w[webstorm idea], command: ->(f, l) { ["webstorm", "--line", l.to_s, f] } },
13
+ rubymine: { patterns: %w[rubymine mine], command: ->(f, l) { ["rubymine", "--line", l.to_s, f] } },
14
+ vim: { patterns: %w[vim nvim], command: ->(f, l) { [detect_vim, "+#{l}", f] } },
15
+ emacs: { patterns: %w[emacs], command: ->(f, l) { ["emacs", "+#{l}", f] } }
16
+ }.freeze
17
+
18
+ class << self
19
+ def available?
20
+ !detect.nil?
21
+ end
22
+
23
+ def detect
24
+ detect_from_env || detect_from_process
25
+ end
26
+
27
+ def open(file, line = 1)
28
+ editor = detect
29
+ return false unless editor
30
+
31
+ command = EDITORS[editor][:command].call(file, line)
32
+ spawn(*command, %i[out err] => File::NULL)
33
+ true
34
+ rescue StandardError => e
35
+ Docyard.logger.warn("Failed to open editor: #{e.message}")
36
+ false
37
+ end
38
+
39
+ private
40
+
41
+ def detect_from_env
42
+ %i[VISUAL EDITOR].each do |var|
43
+ editor_cmd = ENV.fetch(var.to_s, nil)
44
+ next unless editor_cmd
45
+
46
+ EDITORS.each do |name, config|
47
+ return name if config[:patterns].any? { |p| editor_cmd.include?(p) }
48
+ end
49
+ end
50
+ nil
51
+ end
52
+
53
+ def detect_from_process
54
+ return detect_windows_apps if Gem.win_platform?
55
+ return nil unless command_exists?("pgrep")
56
+
57
+ EDITORS.each do |name, config|
58
+ config[:patterns].each do |pattern|
59
+ return name if process_running?(pattern)
60
+ end
61
+ end
62
+
63
+ detect_macos_apps
64
+ end
65
+
66
+ def process_running?(pattern)
67
+ system("pgrep", "-x", pattern, out: File::NULL, err: File::NULL)
68
+ end
69
+
70
+ def detect_macos_apps
71
+ return nil unless RUBY_PLATFORM.include?("darwin")
72
+
73
+ macos_apps = {
74
+ vscode: "Visual Studio Code",
75
+ cursor: "Cursor",
76
+ zed: "Zed"
77
+ }
78
+
79
+ macos_apps.each do |editor, app_name|
80
+ return editor if macos_app_running?(app_name)
81
+ end
82
+
83
+ nil
84
+ end
85
+
86
+ def macos_app_running?(app_name)
87
+ system("pgrep", "-f", app_name, out: File::NULL, err: File::NULL)
88
+ end
89
+
90
+ def detect_windows_apps
91
+ windows_apps = {
92
+ vscode: "Code.exe",
93
+ cursor: "Cursor.exe",
94
+ zed: "zed.exe",
95
+ webstorm: "webstorm64.exe",
96
+ rubymine: "rubymine64.exe"
97
+ }
98
+
99
+ windows_apps.each do |editor, exe|
100
+ return editor if windows_process_running?(exe)
101
+ end
102
+
103
+ nil
104
+ end
105
+
106
+ def windows_process_running?(exe)
107
+ system("tasklist /FI \"IMAGENAME eq #{exe}\" 2>NUL | find /I \"#{exe}\" >NUL")
108
+ end
109
+
110
+ def command_exists?(cmd)
111
+ system("which", cmd, out: File::NULL, err: File::NULL)
112
+ end
113
+
114
+ def detect_vim
115
+ command_exists?("nvim") ? "nvim" : "vim"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -7,54 +7,5 @@ module Docyard
7
7
 
8
8
  class SidebarConfigError < Error; end
9
9
 
10
- class FileNotFoundError < Error
11
- attr_reader :path
12
-
13
- def initialize(path)
14
- @path = path
15
- super("File not found: #{path}")
16
- end
17
- end
18
-
19
- class InvalidPathError < Error; end
20
-
21
- class MarkdownParseError < Error
22
- attr_reader :file_path, :original_error
23
-
24
- def initialize(file_path, original_error)
25
- @file_path = file_path
26
- @original_error = original_error
27
- super("Failed to parse markdown file #{file_path}: #{original_error.message}")
28
- end
29
- end
30
-
31
- class TemplateRenderError < Error
32
- attr_reader :template_path, :original_error
33
-
34
- def initialize(template_path, original_error)
35
- @template_path = template_path
36
- @original_error = original_error
37
- super("Failed to render template #{template_path}: #{original_error.message}")
38
- end
39
- end
40
-
41
- class ReloadCheckError < Error
42
- attr_reader :original_error
43
-
44
- def initialize(original_error)
45
- @original_error = original_error
46
- super("Reload check failed: #{original_error.message}")
47
- end
48
- end
49
-
50
- class AssetNotFoundError < Error
51
- attr_reader :asset_path
52
-
53
- def initialize(asset_path)
54
- @asset_path = asset_path
55
- super("Asset not found: #{asset_path}")
56
- end
57
- end
58
-
59
10
  class BuildError < Error; end
60
11
  end
@@ -47,21 +47,23 @@ module Docyard
47
47
  end
48
48
 
49
49
  def user_confirms_overwrite?
50
- print "\nOverwrite existing files? [y/N] "
50
+ puts
51
+ print " Overwrite existing files? [y/N] "
51
52
  response = $stdin.gets&.strip&.downcase
52
53
  %w[y yes].include?(response)
53
54
  end
54
55
 
55
56
  def print_existing_files_warning
56
- puts ""
57
- puts "\e[33mWarning:\e[0m Existing files found:"
58
- puts " - #{docs_path}/" if File.exist?(docs_path)
59
- puts " - #{config_path}" if File.exist?(config_path)
57
+ puts
58
+ puts " #{UI.yellow('Warning:')} Existing files found:"
59
+ puts " #{docs_path}/" if File.exist?(docs_path)
60
+ puts " #{config_path}" if File.exist?(config_path)
60
61
  end
61
62
 
62
63
  def print_abort_message
63
- puts ""
64
- puts "Aborted. Use \e[1m--force\e[0m to overwrite existing files."
64
+ puts
65
+ puts " #{UI.yellow('Aborted.')} Use --force to overwrite existing files."
66
+ puts
65
67
  end
66
68
 
67
69
  def create_project_directory
@@ -103,48 +105,39 @@ module Docyard
103
105
  end
104
106
 
105
107
  def print_success
106
- puts ""
107
- puts "\e[32m#{success_icon} Docyard project initialized successfully!\e[0m"
108
- puts ""
108
+ puts
109
+ puts " #{UI.bold('Docyard')} v#{VERSION}"
110
+ puts
111
+ puts " #{UI.success('Project initialized')}"
112
+ puts
109
113
  print_created_structure
110
114
  print_next_steps
111
115
  end
112
116
 
113
- def success_icon
114
- "\u2714"
115
- end
116
-
117
- def print_created_structure
118
- puts "Created:"
119
- puts ""
117
+ def print_created_structure # rubocop:disable Metrics/AbcSize
118
+ puts " #{UI.bold('Created:')}"
120
119
  if project_name
121
- puts " \e[1m#{project_name}/\e[0m"
122
- puts " \u251C\u2500\u2500 docyard.yml"
123
- puts " \u2514\u2500\u2500 docs/"
120
+ puts UI.dim(" #{project_name}/")
121
+ puts UI.dim(" docyard.yml")
122
+ puts UI.dim(" docs/")
124
123
  else
125
- puts " \e[1mdocyard.yml\e[0m"
126
- puts " \e[1mdocs/\e[0m"
124
+ puts UI.dim(" docyard.yml")
125
+ puts UI.dim(" docs/")
127
126
  end
128
- puts " \u251C\u2500\u2500 _sidebar.yml"
129
- puts " \u251C\u2500\u2500 index.md"
130
- puts " \u251C\u2500\u2500 getting-started.md"
131
- puts " \u251C\u2500\u2500 components.md"
132
- puts " \u2514\u2500\u2500 public/"
133
- puts ""
127
+ puts UI.dim(" _sidebar.yml")
128
+ puts UI.dim(" index.md")
129
+ puts UI.dim(" getting-started.md")
130
+ puts UI.dim(" components.md")
131
+ puts UI.dim(" public/")
132
+ puts
134
133
  end
135
134
 
136
135
  def print_next_steps
137
- puts "Next steps:"
138
- puts ""
139
- if project_name
140
- puts " \e[1mcd #{project_name}\e[0m"
141
- puts ""
142
- end
143
- puts " Start the development server:"
144
- puts " \e[1m$ docyard serve\e[0m"
145
- puts ""
146
- puts " Then open \e[4mhttp://localhost:4200\e[0m in your browser"
147
- puts ""
136
+ puts " #{UI.bold('Next steps:')}"
137
+ puts " cd #{project_name}" if project_name
138
+ puts " docyard serve"
139
+ puts " Open #{UI.cyan('http://localhost:4200')}"
140
+ puts
148
141
  end
149
142
  end
150
143
  end
@@ -7,10 +7,11 @@ module Docyard
7
7
  class LocalConfigLoader
8
8
  SIDEBAR_CONFIG_FILE = "_sidebar.yml"
9
9
 
10
- attr_reader :docs_path
10
+ attr_reader :docs_path, :key_errors
11
11
 
12
- def initialize(docs_path)
12
+ def initialize(docs_path, validate: true)
13
13
  @docs_path = docs_path
14
+ @validate = validate
14
15
  @key_errors = []
15
16
  end
16
17
 
@@ -56,18 +57,18 @@ module Docyard
56
57
  def validate_items(items, path_prefix: "")
57
58
  return unless items.is_a?(Array)
58
59
 
59
- items.each_with_index do |item, idx|
60
- validate_item(item, "#{path_prefix}[#{idx}]")
60
+ items.each do |item|
61
+ validate_item(item, path_prefix)
61
62
  end
62
63
  end
63
64
 
64
- def validate_item(item, context)
65
+ def validate_item(item, path_prefix)
65
66
  return unless item.is_a?(Hash)
66
67
 
67
68
  if external_link?(item)
68
- validate_external_link(item, context)
69
+ validate_external_link(item, path_prefix)
69
70
  else
70
- validate_sidebar_item(item, context)
71
+ validate_sidebar_item(item, path_prefix)
71
72
  end
72
73
  end
73
74
 
@@ -75,39 +76,61 @@ module Docyard
75
76
  item.key?("link") || item.key?(:link)
76
77
  end
77
78
 
78
- def validate_external_link(item, context)
79
- errors = Config::KeyValidator.validate(item, Config::Schema::SIDEBAR_EXTERNAL_LINK, context: context)
79
+ def validate_external_link(item, path_prefix)
80
+ link_text = item["text"] || item[:text] || item["link"] || item[:link]
81
+ context = build_context(path_prefix, link_text)
82
+ errors = Config::Schema.validate_keys(item, Config::Schema::SIDEBAR_EXTERNAL_LINK_KEYS, context: context)
80
83
  @key_errors.concat(errors)
81
84
  end
82
85
 
83
- def validate_sidebar_item(item, context)
86
+ def validate_sidebar_item(item, path_prefix)
84
87
  slug, options = extract_slug_and_options(item)
88
+
89
+ if slug.nil?
90
+ @key_errors << build_invalid_format_error(item, path_prefix)
91
+ return
92
+ end
93
+
85
94
  return unless options.is_a?(Hash)
86
95
 
87
- errors = Config::KeyValidator.validate(options, Config::Schema::SIDEBAR_ITEM, context: context)
96
+ context = build_context(path_prefix, slug)
97
+ errors = Config::Schema.validate_keys(options, Config::Schema::SIDEBAR_ITEM_KEYS, context: context)
88
98
  @key_errors.concat(errors)
89
- validate_nested_items(options, slug, context)
99
+ validate_nested_items(options, context)
100
+ end
101
+
102
+ def build_invalid_format_error(item, path_prefix)
103
+ item_desc = item.keys.first&.to_s || "item"
104
+ context = build_context(path_prefix, item_desc)
105
+ {
106
+ context: context,
107
+ message: "invalid format, expected slug-based key (e.g., 'page-name:' or '- page-name')"
108
+ }
90
109
  end
91
110
 
92
111
  def extract_slug_and_options(item)
93
- first_key = item.keys.first
94
- if first_key.is_a?(String) && !external_link?(item)
95
- [first_key, item[first_key]]
96
- else
97
- [nil, item]
98
- end
112
+ return [nil, item] if item.keys.size != 1
113
+
114
+ slug = item.keys.first
115
+ slug.is_a?(String) ? [slug, item[slug]] : [nil, item]
116
+ end
117
+
118
+ def build_context(prefix, name)
119
+ return name.to_s if prefix.empty?
120
+
121
+ "#{prefix}.#{name}"
99
122
  end
100
123
 
101
- def validate_nested_items(options, slug, context)
124
+ def validate_nested_items(options, context)
102
125
  nested = options["items"] || options[:items]
103
126
  return unless nested
104
127
 
105
- nested_context = slug ? "#{context}.#{slug}" : context
106
- validate_items(nested, path_prefix: nested_context)
128
+ validate_items(nested, path_prefix: context)
107
129
  end
108
130
 
109
131
  def report_key_errors
110
132
  return if @key_errors.empty?
133
+ return unless @validate
111
134
 
112
135
  messages = @key_errors.map { |e| "#{e[:context]}: #{e[:message]}" }
113
136
  raise ConfigError, "Error in #{config_file_path}:\n#{messages.join("\n")}"
@@ -2,15 +2,13 @@
2
2
 
3
3
  module Docyard
4
4
  module IconHelpers
5
- VALID_WEIGHTS = %w[regular bold fill light thin duotone].freeze
6
-
7
5
  def icon(name, weight = "regular")
8
6
  name = name.to_s
9
7
  return name if name.strip.start_with?("<svg")
10
8
 
11
9
  name = name.tr("_", "-")
12
10
  weight = weight.to_s
13
- weight = "regular" unless VALID_WEIGHTS.include?(weight)
11
+ weight = "regular" unless Icons::VALID_WEIGHTS.include?(weight)
14
12
  weight_class = weight == "regular" ? "ph" : "ph-#{weight}"
15
13
  %(<i class="#{weight_class} ph-#{name}" aria-hidden="true"></i>)
16
14
  end
@@ -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