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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Config
5
+ module TypeValidators
6
+ def validate_string(value, definition, field)
7
+ unless value.is_a?(String)
8
+ add_type_issue(field, "string", value)
9
+ return
10
+ end
11
+
12
+ validate_format(value, definition[:format], field) if definition[:format]
13
+ end
14
+
15
+ def validate_format(value, format, field)
16
+ case format
17
+ when :no_slashes
18
+ return unless value.include?("/") || value.include?("\\")
19
+
20
+ add_diagnostic(:error, field, "cannot contain slashes", got: value,
21
+ expected: "simple directory name like 'dist'")
22
+ when :starts_with_slash
23
+ return if value.start_with?("/")
24
+
25
+ add_diagnostic(:error, field, "must start with /", got: value,
26
+ fix: { type: :replace, value: "/#{value}" })
27
+ end
28
+ end
29
+
30
+ def validate_boolean(value, field)
31
+ return if [true, false].include?(value)
32
+
33
+ fix = nil
34
+ if %w[true false yes no].include?(value.to_s.downcase)
35
+ fix = { type: :replace, value: %w[true yes].include?(value.to_s.downcase) }
36
+ end
37
+
38
+ add_diagnostic(:error, field, "must be true or false", got: value.inspect, fix: fix)
39
+ end
40
+
41
+ def validate_url(value, field)
42
+ return if value.nil?
43
+ return add_type_issue(field, "URL string", value) unless value.is_a?(String)
44
+ return if value.match?(%r{\Ahttps?://})
45
+
46
+ add_diagnostic(:warning, field, "should be a valid URL starting with http:// or https://", got: value)
47
+ end
48
+
49
+ def validate_enum(value, definition, field)
50
+ valid_values = definition[:values]
51
+ return if valid_values.include?(value)
52
+
53
+ suggestion = find_suggestion(value.to_s, valid_values)
54
+ fix = suggestion ? { type: :replace, value: suggestion } : nil
55
+
56
+ add_diagnostic(:error, field, "invalid value", got: value,
57
+ expected: valid_values.join(", "), fix: fix)
58
+ end
59
+
60
+ def validate_hash(value, definition, field)
61
+ return add_type_issue(field, "hash/object", value) unless value.is_a?(Hash)
62
+ return unless definition[:keys]
63
+
64
+ validate_structure(value, definition[:keys], field)
65
+ end
66
+
67
+ def validate_array(value, definition, field)
68
+ return add_type_issue(field, "array", value) unless value.is_a?(Array)
69
+
70
+ validate_array_max_items(value, definition, field)
71
+ validate_array_items(value, definition, field)
72
+ end
73
+
74
+ def validate_array_max_items(value, definition, field)
75
+ return unless definition[:max_items] && value.size > definition[:max_items]
76
+
77
+ add_diagnostic(:error, field, "has too many items", got: "#{value.size} items",
78
+ expected: "maximum #{definition[:max_items]} items")
79
+ end
80
+
81
+ def validate_array_items(value, definition, field)
82
+ return unless definition[:items]
83
+
84
+ value.each_with_index do |item, index|
85
+ validate_field(item, definition[:items], "#{field}[#{index}]")
86
+ end
87
+ end
88
+
89
+ def validate_file_or_url(value, field)
90
+ return if value.nil?
91
+ return add_type_issue(field, "file path or URL", value) unless value.is_a?(String)
92
+ return if value.match?(%r{\Ahttps?://})
93
+ return if File.exist?(value)
94
+
95
+ public_dir = File.join(@source_dir, "public")
96
+ file_path = File.join(public_dir, value)
97
+ return if File.exist?(file_path)
98
+
99
+ add_diagnostic(:error, field, "file not found", got: value,
100
+ expected: "file in #{public_dir}/ or a URL")
101
+ end
102
+
103
+ def validate_hex_color(value, field)
104
+ return if value.nil?
105
+ return add_type_issue(field, "hex color string", value) unless value.is_a?(String)
106
+ return if value.match?(/\A#[0-9a-fA-F]{3}\z/) || value.match?(/\A#[0-9a-fA-F]{6}\z/)
107
+
108
+ add_diagnostic(:error, field, "invalid hex color format", got: value,
109
+ expected: "#RGB or #RRGGBB format (e.g., #3b82f6)")
110
+ end
111
+
112
+ def validate_color(value, field)
113
+ return if value.nil?
114
+ return if value.is_a?(String)
115
+ return if value.is_a?(Hash)
116
+
117
+ add_type_issue(field, "color string or {light:, dark:} hash", value)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -1,102 +1,177 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "validation_helpers"
4
3
  require_relative "schema"
5
- require_relative "key_validator"
6
- require_relative "validators/section"
7
- require_relative "validators/navigation"
4
+ require_relative "type_validators"
5
+ require_relative "../diagnostic_context"
8
6
 
9
7
  module Docyard
10
8
  class Config
11
9
  class Validator
12
- include ValidationHelpers
13
- include Validators::Section
14
- include Validators::Navigation
10
+ include TypeValidators
15
11
 
16
- def initialize(config_data)
17
- @config = config_data
18
- @errors = []
19
- @key_errors = []
12
+ VALIDATORS_WITH_DEFINITION = %i[string enum hash array].freeze
13
+ CONFIG_DOCS_URL = "https://docyard.dev/reference/configuration/"
14
+
15
+ attr_reader :diagnostics
16
+
17
+ def initialize(data, source_dir: "docs")
18
+ @data = data
19
+ @source_dir = source_dir
20
+ @config_path = File.join(source_dir, "docyard.yml")
21
+ @config_path = "docyard.yml" unless File.exist?(@config_path)
22
+ @diagnostics = []
23
+ end
24
+
25
+ def validate_all
26
+ @diagnostics = []
27
+ validate_unknown_keys(@data, Schema::DEFINITION, "docyard.yml")
28
+ validate_structure(@data, Schema::DEFINITION, "")
29
+ validate_cross_field_rules
30
+ @diagnostics
20
31
  end
21
32
 
22
- def validate!
23
- validate_unknown_keys
24
- validate_top_level
25
- validate_branding_section
26
- validate_socials_section
27
- validate_tabs_section
28
- validate_sidebar_setting
29
- validate_build_section
30
- validate_search_section
31
- validate_navigation_section
32
- validate_announcement_section
33
- validate_feedback_section
33
+ def errors
34
+ @diagnostics.select(&:error?)
35
+ end
36
+
37
+ def warnings
38
+ @diagnostics.select(&:warning?)
39
+ end
34
40
 
35
- raise_key_errors if @key_errors.any?
36
- raise ConfigError, format_errors if @errors.any?
41
+ def fixable_issues
42
+ @diagnostics.select(&:fixable?)
37
43
  end
38
44
 
39
45
  private
40
46
 
41
- def validate_unknown_keys
42
- validate_top_level_keys
43
- validate_section_keys
44
- validate_array_item_keys
47
+ def validate_unknown_keys(data, schema, context, allow_extra: false)
48
+ return unless data.is_a?(Hash)
49
+
50
+ check_unknown_keys(data, schema, context, allow_extra)
51
+ validate_nested_hash_keys(data, schema, context)
45
52
  end
46
53
 
47
- def validate_top_level_keys
48
- @key_errors.concat(KeyValidator.validate(@config, Schema::TOP_LEVEL, context: "docyard.yml"))
54
+ def check_unknown_keys(data, schema, context, allow_extra)
55
+ return if allow_extra
56
+
57
+ valid_keys = schema.keys.map(&:to_s)
58
+ data.each_key do |key|
59
+ next if valid_keys.include?(key.to_s)
60
+
61
+ add_unknown_key_issue(context, key, valid_keys)
62
+ end
49
63
  end
50
64
 
51
- def validate_section_keys
52
- Schema::SECTIONS.each do |section, valid_keys|
53
- next unless @config[section].is_a?(Hash)
65
+ def validate_nested_hash_keys(data, schema, context)
66
+ schema.each do |key, definition|
67
+ next unless nested_hash_definition?(definition)
68
+ next unless data[key.to_s].is_a?(Hash)
54
69
 
55
- @key_errors.concat(KeyValidator.validate(@config[section], valid_keys, context: section))
70
+ nested_context = build_context(context, key)
71
+ nested_allow_extra = definition[:allow_extra_keys] || false
72
+ validate_unknown_keys(data[key.to_s], definition[:keys], nested_context, allow_extra: nested_allow_extra)
56
73
  end
57
74
  end
58
75
 
59
- def validate_array_item_keys
60
- validate_tabs_keys
61
- validate_cta_keys
62
- validate_announcement_button_keys
76
+ def nested_hash_definition?(definition)
77
+ definition.is_a?(Hash) && definition[:type] == :hash && definition[:keys]
63
78
  end
64
79
 
65
- def validate_tabs_keys
66
- tabs = @config["tabs"]
67
- return unless tabs.is_a?(Array)
80
+ def build_context(prefix, key)
81
+ prefix.empty? || prefix == "docyard.yml" ? key.to_s : "#{prefix}.#{key}"
82
+ end
83
+
84
+ def validate_structure(data, schema, prefix)
85
+ schema.each do |key, definition|
86
+ field = build_context(prefix, key)
87
+ value = data.is_a?(Hash) ? data[key.to_s] : nil
88
+ validate_field(value, definition, field)
89
+ end
90
+ end
68
91
 
69
- tabs.each_with_index do |tab, idx|
70
- @key_errors.concat(KeyValidator.validate(tab, Schema::TAB, context: "tabs[#{idx}]"))
92
+ def validate_field(value, definition, field)
93
+ if value.nil? && definition[:required]
94
+ add_diagnostic(:error, field, "is required")
95
+ return
71
96
  end
97
+
98
+ return if value.nil?
99
+
100
+ validate_type(value, definition, field)
72
101
  end
73
102
 
74
- def validate_cta_keys
75
- cta = @config.dig("navigation", "cta")
76
- return unless cta.is_a?(Array)
103
+ def validate_type(value, definition, field)
104
+ return if value.nil?
105
+
106
+ type = definition[:type]
107
+ validator_method = :"validate_#{type}"
108
+ return unless respond_to?(validator_method, true)
77
109
 
78
- cta.each_with_index do |item, idx|
79
- @key_errors.concat(KeyValidator.validate(item, Schema::CTA, context: "navigation.cta[#{idx}]"))
110
+ if VALIDATORS_WITH_DEFINITION.include?(type)
111
+ send(validator_method, value, definition, field)
112
+ else
113
+ send(validator_method, value, field)
80
114
  end
81
115
  end
82
116
 
83
- def validate_announcement_button_keys
84
- button = @config.dig("announcement", "button")
85
- return unless button.is_a?(Hash)
117
+ def validate_cross_field_rules
118
+ validate_feedback_requires_analytics
119
+ end
120
+
121
+ def validate_feedback_requires_analytics
122
+ feedback = @data["feedback"]
123
+ return unless feedback.is_a?(Hash) && feedback["enabled"] == true
124
+
125
+ analytics = @data["analytics"]
126
+ return if analytics.is_a?(Hash) && analytics.values.any?
127
+
128
+ add_diagnostic(:error, "feedback.enabled", "requires analytics to be configured",
129
+ expected: "configure google, plausible, fathom, or script in analytics section")
130
+ end
131
+
132
+ def add_diagnostic(severity, field, message, got: nil, expected: nil, fix: nil)
133
+ line = DiagnosticContext.find_yaml_key_line(@config_path, field)
134
+ source_context = DiagnosticContext.extract_source_context(@config_path, line) if line
135
+
136
+ @diagnostics << Diagnostic.new(
137
+ severity: severity,
138
+ category: :CONFIG,
139
+ code: "CONFIG_VALIDATION",
140
+ file: "docyard.yml",
141
+ line: line,
142
+ field: field,
143
+ message: message,
144
+ details: build_details(got, expected),
145
+ fix: fix,
146
+ doc_url: CONFIG_DOCS_URL,
147
+ source_context: source_context
148
+ )
149
+ end
86
150
 
87
- @key_errors.concat(KeyValidator.validate(button, Schema::ANNOUNCEMENT_BUTTON, context: "announcement.button"))
151
+ def build_details(got, expected)
152
+ details = {}
153
+ details[:got] = got if got
154
+ details[:expected] = expected if expected
155
+ details.empty? ? nil : details
88
156
  end
89
157
 
90
- def raise_key_errors
91
- messages = @key_errors.map { |e| "#{e[:context]}: #{e[:message]}" }
92
- raise ConfigError, "Error in docyard.yml:\n#{messages.join("\n")}"
158
+ def add_type_issue(field, expected_type, value)
159
+ add_diagnostic(:error, field, "must be a #{expected_type}", got: value.class.name)
160
+ end
161
+
162
+ def add_unknown_key_issue(context, key, valid_keys)
163
+ field = context == "docyard.yml" ? key.to_s : "#{context}.#{key}"
164
+ suggestion = find_suggestion(key.to_s, valid_keys.map(&:to_s))
165
+ message = "unknown key"
166
+ message += ", did you mean '#{suggestion}'?" if suggestion
167
+ fix = suggestion ? { type: :rename, from: key.to_s, to: suggestion } : nil
168
+
169
+ add_diagnostic(:error, field, message, fix: fix)
93
170
  end
94
171
 
95
- def format_errors
96
- errors_text = @errors.map do |err|
97
- " Field: #{err[:field]}\n Error: #{err[:error]}\n Got: #{err[:got]}\n Fix: #{err[:fix]}"
98
- end.join("\n\n")
99
- "Error in docyard.yml:\n\n#{errors_text}"
172
+ def find_suggestion(key, valid_keys)
173
+ checker = DidYouMean::SpellChecker.new(dictionary: valid_keys)
174
+ checker.correct(key).first
100
175
  end
101
176
  end
102
177
  end
@@ -2,15 +2,12 @@
2
2
 
3
3
  require "yaml"
4
4
  require_relative "config/section"
5
- require_relative "config/schema"
6
5
  require_relative "config/validator"
7
6
  require_relative "constants"
8
7
  require_relative "utils/hash_utils"
9
8
 
10
9
  module Docyard
11
10
  class Config
12
- SIDEBAR_MODES = %w[config auto distributed].freeze
13
-
14
11
  DEFAULT_CONFIG = {
15
12
  "title" => Constants::DEFAULT_SITE_TITLE,
16
13
  "description" => "",
@@ -22,7 +19,7 @@ module Docyard
22
19
  "socials" => {},
23
20
  "tabs" => [],
24
21
  "sidebar" => "config",
25
- "build" => { "output" => "dist", "base" => "/" },
22
+ "build" => { "output" => "dist", "base" => "/", "strict" => false },
26
23
  "search" => { "enabled" => true, "placeholder" => "Search...", "exclude" => [] },
27
24
  "navigation" => { "cta" => [], "breadcrumbs" => true },
28
25
  "announcement" => nil,
@@ -42,7 +39,6 @@ module Docyard
42
39
  @project_root = project_root
43
40
  @file_path = File.join(project_root, "docyard.yml")
44
41
  @data = load_config_data
45
- validate!
46
42
  end
47
43
 
48
44
  def file_exists?
@@ -60,10 +56,6 @@ module Docyard
60
56
  def tabs = data["tabs"]
61
57
  def sidebar = data["sidebar"]
62
58
 
63
- def sidebar_config? = sidebar == "config"
64
- def sidebar_auto? = sidebar == "auto"
65
- def sidebar_distributed? = sidebar == "distributed"
66
-
67
59
  def branding = @branding ||= Section.new(data["branding"])
68
60
  def build = @build ||= Section.new(data["build"])
69
61
  def search = @search ||= Section.new(data["search"])
@@ -96,9 +88,5 @@ module Docyard
96
88
  message += " at line #{error.line}" if error.respond_to?(:line)
97
89
  message
98
90
  end
99
-
100
- def validate!
101
- Validator.new(data).validate!
102
- end
103
91
  end
104
92
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Diagnostic
5
+ CATEGORIES = %i[CONFIG SIDEBAR CONTENT COMPONENT SYNTAX LINK IMAGE ORPHAN].freeze
6
+ SEVERITIES = %i[error warning].freeze
7
+
8
+ attr_reader :severity, :category, :code, :message, :file, :line, :field, :details, :fix,
9
+ :doc_url, :source_context
10
+
11
+ def initialize(severity:, category:, code:, message:, file: nil, line: nil, field: nil,
12
+ details: nil, fix: nil, doc_url: nil, source_context: nil)
13
+ validate_severity!(severity)
14
+ validate_category!(category)
15
+
16
+ @severity = severity.to_sym
17
+ @category = category.to_sym
18
+ @code = code.to_s
19
+ @message = message
20
+ @file = file
21
+ @line = line
22
+ @field = field
23
+ @details = details
24
+ @fix = fix
25
+ @doc_url = doc_url
26
+ @source_context = source_context
27
+
28
+ freeze
29
+ end
30
+
31
+ def error?
32
+ severity == :error
33
+ end
34
+
35
+ def warning?
36
+ severity == :warning
37
+ end
38
+
39
+ def fixable?
40
+ fix.is_a?(Hash) && !fix[:type].nil?
41
+ end
42
+
43
+ def location
44
+ return "#{file}:#{field}" if file && field
45
+ return "#{file}:#{line}" if file && line
46
+ return field if field
47
+ return file if file
48
+
49
+ nil
50
+ end
51
+
52
+ def format_line
53
+ loc = location&.ljust(26) || (" " * 26)
54
+ prefix = error? ? "error" : "warn "
55
+ suffix = fixable? ? " [fixable]" : ""
56
+ " #{prefix} #{loc} #{message}#{suffix}"
57
+ end
58
+
59
+ def to_h
60
+ {
61
+ severity: severity,
62
+ category: category,
63
+ code: code,
64
+ message: message,
65
+ file: file,
66
+ line: line,
67
+ field: field,
68
+ details: details,
69
+ fix: fix,
70
+ doc_url: doc_url,
71
+ source_context: source_context
72
+ }.compact
73
+ end
74
+
75
+ private
76
+
77
+ def validate_severity!(severity)
78
+ return if SEVERITIES.include?(severity.to_sym)
79
+
80
+ raise ArgumentError, "Invalid severity: #{severity}. Must be one of: #{SEVERITIES.join(', ')}"
81
+ end
82
+
83
+ def validate_category!(category)
84
+ return if CATEGORIES.include?(category.to_sym)
85
+
86
+ raise ArgumentError, "Invalid category: #{category}. Must be one of: #{CATEGORIES.join(', ')}"
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module DiagnosticContext
5
+ CONTEXT_LINES = 2
6
+
7
+ class << self
8
+ def find_yaml_key_line(file_path, key_path)
9
+ return nil unless File.exist?(file_path)
10
+
11
+ lines = File.readlines(file_path)
12
+ target_key = key_path.to_s.split(".").last
13
+
14
+ lines.each_with_index do |line, index|
15
+ next if line.strip.empty? || line.strip.start_with?("#")
16
+
17
+ return index + 1 if line_contains_key?(line, target_key)
18
+ end
19
+
20
+ nil
21
+ end
22
+
23
+ def extract_source_context(file_path, line_number, context_lines: CONTEXT_LINES)
24
+ return nil unless file_path && line_number && File.exist?(file_path)
25
+
26
+ lines = File.readlines(file_path)
27
+ return nil if line_number < 1 || line_number > lines.length
28
+
29
+ start_line = [line_number - context_lines, 1].max
30
+ end_line = [line_number + context_lines, lines.length].min
31
+
32
+ (start_line..end_line).map do |num|
33
+ {
34
+ line: num,
35
+ content: lines[num - 1].chomp,
36
+ highlighted: num == line_number
37
+ }
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def line_contains_key?(line, key)
44
+ line.match?(/^\s*#{Regexp.escape(key)}:/) || line.match?(/^\s*-\s*#{Regexp.escape(key)}:?(\s|$)/)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Doctor
5
+ class CodeBlockChecker
6
+ CODE_FENCE_START = /^(`{3,}|~{3,})(\w*)(.*)$/
7
+ CODE_FENCE_END = /^(`{3,}|~{3,})\s*$/
8
+ VALID_OPTIONS = %w[line-numbers no-line-numbers].freeze
9
+ OPTION_PATTERN = /:(\S+)/
10
+ HIGHLIGHT_PATTERN = /\{([^}]+)\}/
11
+ VALID_HIGHLIGHT = /^\d+(-\d+)?(,\s*\d+(-\d+)?)*$/
12
+ INLINE_MARKER_PATTERN = /\[!code\s+([^\]]+)\]/
13
+ VALID_INLINE_MARKERS = %w[++ -- focus error warning].freeze
14
+
15
+ CODE_BLOCKS_DOCS_URL = "https://docyard.dev/write-content/components/code-blocks/"
16
+
17
+ attr_reader :docs_path
18
+
19
+ def initialize(docs_path)
20
+ @docs_path = docs_path
21
+ end
22
+
23
+ def check_file(content, file_path)
24
+ relative_file = file_path.delete_prefix("#{docs_path}/")
25
+
26
+ [
27
+ check_fence_options(content, relative_file),
28
+ check_inline_markers(content, relative_file)
29
+ ].flatten
30
+ end
31
+
32
+ private
33
+
34
+ def check_fence_options(content, relative_file)
35
+ diagnostics = []
36
+ in_code_block = false
37
+
38
+ content.each_line.with_index(1) do |line, line_number|
39
+ if !in_code_block && (match = line.match(CODE_FENCE_START))
40
+ in_code_block = true
41
+ diagnostics.concat(validate_fence_line(match, relative_file, line_number))
42
+ elsif in_code_block && line.match?(CODE_FENCE_END)
43
+ in_code_block = false
44
+ end
45
+ end
46
+
47
+ diagnostics
48
+ end
49
+
50
+ def validate_fence_line(match, relative_file, line_number)
51
+ options_part = match[3]
52
+
53
+ [
54
+ validate_option(options_part, relative_file, line_number),
55
+ validate_highlights(options_part, relative_file, line_number)
56
+ ].flatten
57
+ end
58
+
59
+ def validate_option(options_part, relative_file, line_number)
60
+ match = options_part.match(OPTION_PATTERN)
61
+ return [] unless match
62
+
63
+ option = match[1]
64
+ base_option = option.split("=").first
65
+ return [] if valid_option?(base_option)
66
+
67
+ suggestion = suggest(base_option, VALID_OPTIONS)
68
+ message = "unknown code block option ':#{base_option}'"
69
+ message += ", did you mean ':#{suggestion}'?" if suggestion
70
+
71
+ [build_diagnostic("CODE_BLOCK_UNKNOWN_OPTION", message, relative_file, line_number)]
72
+ end
73
+
74
+ def valid_option?(option)
75
+ VALID_OPTIONS.include?(option) || option.match?(/^line-numbers=\d+$/)
76
+ end
77
+
78
+ def validate_highlights(options_part, relative_file, line_number)
79
+ match = options_part.match(HIGHLIGHT_PATTERN)
80
+ return [] unless match
81
+
82
+ highlight_content = match[1].gsub(/\s/, "")
83
+ return [] if highlight_content.match?(VALID_HIGHLIGHT)
84
+
85
+ [build_diagnostic("CODE_BLOCK_INVALID_HIGHLIGHT", "invalid highlight syntax '{#{match[1]}}'", relative_file,
86
+ line_number)]
87
+ end
88
+
89
+ def check_inline_markers(content, relative_file)
90
+ diagnostics = []
91
+ in_code_block = false
92
+
93
+ content.each_line.with_index(1) do |line, line_number|
94
+ if !in_code_block && line.match?(CODE_FENCE_START)
95
+ in_code_block = true
96
+ elsif in_code_block && line.match?(CODE_FENCE_END)
97
+ in_code_block = false
98
+ elsif in_code_block
99
+ diagnostics.concat(validate_inline_markers(line, relative_file, line_number))
100
+ end
101
+ end
102
+
103
+ diagnostics
104
+ end
105
+
106
+ def validate_inline_markers(line, relative_file, line_number)
107
+ line.scan(INLINE_MARKER_PATTERN).filter_map do |match|
108
+ marker = match[0].strip
109
+ next if VALID_INLINE_MARKERS.include?(marker)
110
+
111
+ suggestion = suggest(marker, VALID_INLINE_MARKERS)
112
+ message = "unknown inline marker '[!code #{marker}]'"
113
+ message += ", did you mean '[!code #{suggestion}]'?" if suggestion
114
+
115
+ build_diagnostic("CODE_BLOCK_UNKNOWN_MARKER", message, relative_file, line_number)
116
+ end
117
+ end
118
+
119
+ def suggest(value, dictionary)
120
+ DidYouMean::SpellChecker.new(dictionary: dictionary).correct(value).first
121
+ end
122
+
123
+ def build_diagnostic(code, message, file, line)
124
+ Diagnostic.new(
125
+ severity: :warning,
126
+ category: :COMPONENT,
127
+ code: code,
128
+ message: message,
129
+ file: file,
130
+ line: line,
131
+ doc_url: CODE_BLOCKS_DOCS_URL
132
+ )
133
+ end
134
+ end
135
+ end
136
+ end