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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -1
- data/lib/docyard/build/asset_bundler.rb +7 -33
- data/lib/docyard/build/file_copier.rb +7 -15
- data/lib/docyard/build/llms_txt_generator.rb +0 -2
- data/lib/docyard/build/sitemap_generator.rb +1 -1
- data/lib/docyard/build/static_generator.rb +30 -32
- data/lib/docyard/build/step_runner.rb +88 -0
- data/lib/docyard/build/validator.rb +98 -0
- data/lib/docyard/builder.rb +82 -55
- data/lib/docyard/cli.rb +36 -4
- data/lib/docyard/components/aliases.rb +0 -4
- data/lib/docyard/components/processors/callout_processor.rb +1 -1
- data/lib/docyard/components/processors/code_block_diff_preprocessor.rb +1 -1
- data/lib/docyard/components/processors/code_block_focus_preprocessor.rb +1 -1
- data/lib/docyard/components/processors/code_block_options_preprocessor.rb +2 -2
- data/lib/docyard/components/processors/code_group_processor.rb +1 -1
- data/lib/docyard/components/processors/icon_processor.rb +2 -2
- data/lib/docyard/components/processors/tabs_processor.rb +1 -1
- data/lib/docyard/config/schema/definition.rb +29 -0
- data/lib/docyard/config/schema/sections.rb +63 -0
- data/lib/docyard/config/schema/simple_sections.rb +78 -0
- data/lib/docyard/config/schema.rb +28 -31
- data/lib/docyard/config/type_validators.rb +121 -0
- data/lib/docyard/config/validator.rb +136 -61
- data/lib/docyard/config.rb +1 -13
- data/lib/docyard/diagnostic.rb +89 -0
- data/lib/docyard/diagnostic_context.rb +48 -0
- data/lib/docyard/doctor/code_block_checker.rb +136 -0
- data/lib/docyard/doctor/component_checker.rb +49 -0
- data/lib/docyard/doctor/component_checkers/abbreviation_checker.rb +74 -0
- data/lib/docyard/doctor/component_checkers/badge_checker.rb +71 -0
- data/lib/docyard/doctor/component_checkers/base.rb +111 -0
- data/lib/docyard/doctor/component_checkers/callout_checker.rb +34 -0
- data/lib/docyard/doctor/component_checkers/cards_checker.rb +57 -0
- data/lib/docyard/doctor/component_checkers/code_group_checker.rb +47 -0
- data/lib/docyard/doctor/component_checkers/details_checker.rb +51 -0
- data/lib/docyard/doctor/component_checkers/icon_checker.rb +36 -0
- data/lib/docyard/doctor/component_checkers/image_attrs_checker.rb +46 -0
- data/lib/docyard/doctor/component_checkers/space_after_colons_checker.rb +45 -0
- data/lib/docyard/doctor/component_checkers/steps_checker.rb +35 -0
- data/lib/docyard/doctor/component_checkers/tabs_checker.rb +35 -0
- data/lib/docyard/doctor/component_checkers/tooltip_checker.rb +67 -0
- data/lib/docyard/doctor/component_checkers/unknown_type_checker.rb +34 -0
- data/lib/docyard/doctor/config_checker.rb +19 -0
- data/lib/docyard/doctor/config_fixer.rb +87 -0
- data/lib/docyard/doctor/content_checker.rb +164 -0
- data/lib/docyard/doctor/file_scanner.rb +113 -0
- data/lib/docyard/doctor/image_checker.rb +103 -0
- data/lib/docyard/doctor/link_checker.rb +91 -0
- data/lib/docyard/doctor/markdown_fixer.rb +62 -0
- data/lib/docyard/doctor/orphan_checker.rb +82 -0
- data/lib/docyard/doctor/reporter.rb +152 -0
- data/lib/docyard/doctor/sidebar_checker.rb +127 -0
- data/lib/docyard/doctor/sidebar_fixer.rb +47 -0
- data/lib/docyard/doctor.rb +178 -0
- data/lib/docyard/editor_launcher.rb +119 -0
- data/lib/docyard/errors.rb +0 -49
- data/lib/docyard/initializer.rb +32 -39
- data/lib/docyard/navigation/sidebar/local_config_loader.rb +44 -21
- data/lib/docyard/rendering/icon_helpers.rb +1 -3
- data/lib/docyard/search/build_indexer.rb +39 -24
- data/lib/docyard/search/dev_indexer.rb +9 -23
- data/lib/docyard/server/dev_server.rb +55 -13
- data/lib/docyard/server/error_overlay.rb +73 -0
- data/lib/docyard/server/file_watcher.rb +0 -1
- data/lib/docyard/server/page_diagnostics.rb +27 -0
- data/lib/docyard/server/preview_server.rb +17 -13
- data/lib/docyard/server/rack_application.rb +64 -3
- data/lib/docyard/server/resolution_result.rb +0 -4
- data/lib/docyard/templates/assets/css/error-overlay.css +669 -0
- data/lib/docyard/templates/assets/css/variables.css +1 -1
- data/lib/docyard/templates/assets/fonts/Inter-Variable.woff2 +0 -0
- data/lib/docyard/templates/assets/js/components/relative-time.js +42 -0
- data/lib/docyard/templates/assets/js/error-overlay.js +547 -0
- data/lib/docyard/templates/assets/js/hot-reload.js +35 -7
- data/lib/docyard/templates/errors/404.html.erb +1 -1
- data/lib/docyard/templates/errors/500.html.erb +1 -1
- data/lib/docyard/templates/partials/_head.html.erb +1 -1
- data/lib/docyard/templates/partials/_page_actions.html.erb +1 -1
- data/lib/docyard/ui.rb +80 -0
- data/lib/docyard/utils/logging.rb +5 -1
- data/lib/docyard/utils/text_formatter.rb +0 -6
- data/lib/docyard/version.rb +1 -1
- data/lib/docyard.rb +4 -0
- metadata +47 -25
- data/lib/docyard/config/key_validator.rb +0 -30
- data/lib/docyard/config/validation_helpers.rb +0 -83
- data/lib/docyard/config/validators/navigation.rb +0 -43
- data/lib/docyard/config/validators/section.rb +0 -114
- 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 "
|
|
6
|
-
require_relative "
|
|
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
|
|
13
|
-
include Validators::Section
|
|
14
|
-
include Validators::Navigation
|
|
10
|
+
include TypeValidators
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
41
|
+
def fixable_issues
|
|
42
|
+
@diagnostics.select(&:fixable?)
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
private
|
|
40
46
|
|
|
41
|
-
def validate_unknown_keys
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
48
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
next unless
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
data/lib/docyard/config.rb
CHANGED
|
@@ -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
|