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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../diagnostic_context"
|
|
4
|
+
|
|
5
|
+
module Docyard
|
|
6
|
+
class Doctor
|
|
7
|
+
class LinkChecker
|
|
8
|
+
MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]+)\)/
|
|
9
|
+
INTERNAL_LINK_REGEX = %r{^/[^/]}
|
|
10
|
+
IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .svg .webp .ico .bmp].freeze
|
|
11
|
+
CODE_FENCE_REGEX = /^(`{3,}|~{3,})/
|
|
12
|
+
LINKS_DOCS_URL = nil
|
|
13
|
+
|
|
14
|
+
attr_reader :docs_path, :links_checked
|
|
15
|
+
|
|
16
|
+
def initialize(docs_path)
|
|
17
|
+
@docs_path = docs_path
|
|
18
|
+
@links_checked = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def check_file(content, file_path)
|
|
22
|
+
relative_file = file_path.delete_prefix("#{docs_path}/")
|
|
23
|
+
diagnostics = []
|
|
24
|
+
|
|
25
|
+
each_line_outside_code_blocks(content) do |line, line_number|
|
|
26
|
+
diagnostics.concat(check_line_for_links(line, line_number, relative_file))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
diagnostics
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def each_line_outside_code_blocks(content)
|
|
35
|
+
in_code_block = false
|
|
36
|
+
|
|
37
|
+
content.each_line.with_index(1) do |line, line_number|
|
|
38
|
+
in_code_block = !in_code_block if line.match?(CODE_FENCE_REGEX)
|
|
39
|
+
yield(line, line_number) unless in_code_block
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_line_for_links(line, line_number, relative_file)
|
|
44
|
+
line.scan(MARKDOWN_LINK_REGEX).filter_map do |_text, url|
|
|
45
|
+
next unless internal_link?(url)
|
|
46
|
+
next if image_path?(url)
|
|
47
|
+
|
|
48
|
+
@links_checked += 1
|
|
49
|
+
target_path = url.split("#").first
|
|
50
|
+
next if file_exists?(target_path)
|
|
51
|
+
|
|
52
|
+
build_diagnostic(relative_file, line_number, target_path)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_diagnostic(file, line, target)
|
|
57
|
+
full_path = File.join(docs_path, file)
|
|
58
|
+
source_context = DiagnosticContext.extract_source_context(full_path, line)
|
|
59
|
+
|
|
60
|
+
Diagnostic.new(
|
|
61
|
+
severity: :warning,
|
|
62
|
+
category: :LINK,
|
|
63
|
+
code: "LINK_BROKEN",
|
|
64
|
+
message: "Broken link to '#{target}'",
|
|
65
|
+
file: file,
|
|
66
|
+
line: line,
|
|
67
|
+
field: target,
|
|
68
|
+
doc_url: LINKS_DOCS_URL,
|
|
69
|
+
source_context: source_context
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def internal_link?(url)
|
|
74
|
+
url.match?(INTERNAL_LINK_REGEX)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def image_path?(url)
|
|
78
|
+
IMAGE_EXTENSIONS.any? { |ext| url.downcase.end_with?(ext) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def file_exists?(url_path)
|
|
82
|
+
clean_path = url_path.chomp("/")
|
|
83
|
+
[
|
|
84
|
+
File.join(docs_path, "#{clean_path}.md"),
|
|
85
|
+
File.join(docs_path, clean_path, "index.md"),
|
|
86
|
+
File.join(docs_path, "#{clean_path}.html")
|
|
87
|
+
].any? { |f| File.exist?(f) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
class MarkdownFixer
|
|
6
|
+
attr_reader :fixed_issues
|
|
7
|
+
|
|
8
|
+
def initialize(docs_path)
|
|
9
|
+
@docs_path = docs_path
|
|
10
|
+
@fixed_issues = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fixed_count
|
|
14
|
+
@fixed_issues.size
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fix(diagnostics)
|
|
18
|
+
fixable = diagnostics.select(&:fixable?)
|
|
19
|
+
return if fixable.empty?
|
|
20
|
+
|
|
21
|
+
fixable.group_by(&:file).each do |file, file_diagnostics|
|
|
22
|
+
fix_file(file, file_diagnostics)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def fix_file(relative_file, diagnostics)
|
|
29
|
+
file_path = File.join(@docs_path, relative_file)
|
|
30
|
+
return unless File.exist?(file_path)
|
|
31
|
+
|
|
32
|
+
lines = File.readlines(file_path)
|
|
33
|
+
apply_all_fixes(lines, diagnostics)
|
|
34
|
+
File.write(file_path, lines.join)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def apply_all_fixes(lines, diagnostics)
|
|
38
|
+
diagnostics.group_by(&:line).each do |line_number, line_diagnostics|
|
|
39
|
+
next unless valid_line_number?(line_number, lines.size)
|
|
40
|
+
|
|
41
|
+
line_diagnostics.each { |d| apply_fix(lines, line_number, d) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def valid_line_number?(line_number, total_lines)
|
|
46
|
+
line_number&.positive? && line_number <= total_lines
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def apply_fix(lines, line_number, diagnostic)
|
|
50
|
+
fix = diagnostic.fix
|
|
51
|
+
return unless fix[:type] == :line_replace
|
|
52
|
+
|
|
53
|
+
index = line_number - 1
|
|
54
|
+
original_line = lines[index]
|
|
55
|
+
return unless original_line.include?(fix[:from])
|
|
56
|
+
|
|
57
|
+
lines[index] = original_line.sub(fix[:from], fix[:to])
|
|
58
|
+
@fixed_issues << diagnostic
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
class OrphanChecker
|
|
6
|
+
attr_reader :docs_path, :config
|
|
7
|
+
|
|
8
|
+
def initialize(docs_path, config)
|
|
9
|
+
@docs_path = docs_path
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def check
|
|
14
|
+
return [] if auto_sidebar?
|
|
15
|
+
|
|
16
|
+
sidebar_pages = collect_sidebar_pages
|
|
17
|
+
return [] unless sidebar_pages
|
|
18
|
+
|
|
19
|
+
all_pages = collect_all_pages
|
|
20
|
+
orphans = all_pages - sidebar_pages
|
|
21
|
+
|
|
22
|
+
orphans.map { |file| build_diagnostic(file) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build_diagnostic(file)
|
|
28
|
+
Diagnostic.new(
|
|
29
|
+
severity: :warning,
|
|
30
|
+
category: :ORPHAN,
|
|
31
|
+
code: "ORPHAN_PAGE",
|
|
32
|
+
message: "not linked from sidebar",
|
|
33
|
+
file: file
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def auto_sidebar?
|
|
38
|
+
config.sidebar == "auto"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def collect_all_pages
|
|
42
|
+
Dir.glob(File.join(docs_path, "**", "*.md"))
|
|
43
|
+
.map { |f| f.delete_prefix("#{docs_path}/") }
|
|
44
|
+
.reject { |f| f.start_with?("_") || f == "index.md" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def collect_sidebar_pages
|
|
48
|
+
config_items = load_sidebar_config
|
|
49
|
+
return nil unless config_items
|
|
50
|
+
|
|
51
|
+
sidebar_tree = Sidebar::ConfigBuilder.new(config_items, current_path: "/").build
|
|
52
|
+
extract_paths_from_tree(sidebar_tree)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_sidebar_config
|
|
58
|
+
Sidebar::LocalConfigLoader.new(docs_path, validate: false).load
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_paths_from_tree(items, collected = [])
|
|
62
|
+
items.each do |item|
|
|
63
|
+
path = item[:path]
|
|
64
|
+
collected << path_to_file(path) if path && !item[:link]
|
|
65
|
+
extract_paths_from_tree(item[:children] || [], collected)
|
|
66
|
+
end
|
|
67
|
+
collected
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def path_to_file(url_path)
|
|
71
|
+
clean_path = url_path.delete_prefix("/").chomp("/")
|
|
72
|
+
return "index.md" if clean_path.empty?
|
|
73
|
+
|
|
74
|
+
if File.exist?(File.join(docs_path, clean_path, "index.md"))
|
|
75
|
+
"#{clean_path}/index.md"
|
|
76
|
+
else
|
|
77
|
+
"#{clean_path}.md"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
class Reporter
|
|
6
|
+
CATEGORY_LABELS = {
|
|
7
|
+
CONFIG: "Configuration",
|
|
8
|
+
SIDEBAR: "Sidebar",
|
|
9
|
+
CONTENT: "Content",
|
|
10
|
+
COMPONENT: "Components",
|
|
11
|
+
SYNTAX: "Syntax",
|
|
12
|
+
LINK: "Broken links",
|
|
13
|
+
IMAGE: "Missing images",
|
|
14
|
+
ORPHAN: "Orphan pages"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
CATEGORY_ORDER = %i[CONFIG SIDEBAR CONTENT COMPONENT LINK IMAGE ORPHAN SYNTAX].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :diagnostics, :stats, :fixed, :duration
|
|
20
|
+
|
|
21
|
+
def initialize(diagnostics, stats = {}, fixed: false, duration: nil)
|
|
22
|
+
@diagnostics = diagnostics
|
|
23
|
+
@stats = stats
|
|
24
|
+
@fixed = fixed
|
|
25
|
+
@duration = duration
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def print
|
|
29
|
+
print_header
|
|
30
|
+
print_categories
|
|
31
|
+
print_summary
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def exit_code
|
|
35
|
+
error_count.positive? ? 1 : 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def print_header
|
|
41
|
+
puts
|
|
42
|
+
puts " #{UI.bold('Docyard')} v#{VERSION}"
|
|
43
|
+
puts
|
|
44
|
+
puts " Checking docs..."
|
|
45
|
+
puts
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def print_categories
|
|
49
|
+
CATEGORY_ORDER.each do |category|
|
|
50
|
+
print_category(category)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def print_category(category)
|
|
55
|
+
items = diagnostics_for(category)
|
|
56
|
+
return if items.empty?
|
|
57
|
+
|
|
58
|
+
print_category_header(category, items)
|
|
59
|
+
items.each { |d| puts format_diagnostic(d) }
|
|
60
|
+
puts
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def diagnostics_for(category)
|
|
64
|
+
diagnostics.select { |d| d.category == category }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def print_category_header(category, items)
|
|
68
|
+
label = CATEGORY_LABELS[category] || category.to_s
|
|
69
|
+
counts = issue_counts(items.count(&:error?), items.count(&:warning?))
|
|
70
|
+
puts " #{UI.bold(label.ljust(28))} #{counts}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def format_diagnostic(diagnostic)
|
|
74
|
+
prefix = diagnostic.error? ? UI.red("error") : UI.yellow("warn ")
|
|
75
|
+
location = diagnostic.location&.ljust(26) || (" " * 26)
|
|
76
|
+
suffix = diagnostic.fixable? ? " #{UI.cyan('[fixable]')}" : ""
|
|
77
|
+
" #{prefix} #{location} #{diagnostic.message}#{suffix}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def print_summary
|
|
81
|
+
puts " #{stats_summary}"
|
|
82
|
+
|
|
83
|
+
if error_count.zero? && warning_count.zero?
|
|
84
|
+
puts " #{UI.success('No issues found')}"
|
|
85
|
+
else
|
|
86
|
+
puts " #{build_issue_summary}"
|
|
87
|
+
print_fixable_hint
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
puts " #{format_duration}" if duration
|
|
91
|
+
puts
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def format_duration
|
|
95
|
+
if duration < 1
|
|
96
|
+
UI.dim("Finished in #{(duration * 1000).round}ms")
|
|
97
|
+
else
|
|
98
|
+
UI.dim("Finished in #{duration.round(2)}s")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def print_fixable_hint
|
|
103
|
+
return if fixed
|
|
104
|
+
|
|
105
|
+
fixable = diagnostics.count(&:fixable?)
|
|
106
|
+
return if fixable.zero?
|
|
107
|
+
|
|
108
|
+
puts
|
|
109
|
+
puts " #{UI.cyan("Run with --fix to auto-fix #{pluralize(fixable, 'issue')}.")}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def issue_counts(errors, warnings)
|
|
113
|
+
parts = []
|
|
114
|
+
parts << UI.red(pluralize(errors, "error")) if errors.positive?
|
|
115
|
+
parts << UI.yellow(pluralize(warnings, "warning")) if warnings.positive?
|
|
116
|
+
parts.join(", ")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def stats_summary
|
|
120
|
+
stats_parts = [
|
|
121
|
+
stat_part(:files, "file"),
|
|
122
|
+
stat_part(:links, "link"),
|
|
123
|
+
stat_part(:images, "image")
|
|
124
|
+
].compact
|
|
125
|
+
"Checked #{stats_parts.join(', ')}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def stat_part(key, word)
|
|
129
|
+
pluralize(stats[key], word) if stats[key]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_issue_summary
|
|
133
|
+
parts = []
|
|
134
|
+
parts << UI.red(pluralize(error_count, "error")) if error_count.positive?
|
|
135
|
+
parts << UI.yellow(pluralize(warning_count, "warning")) if warning_count.positive?
|
|
136
|
+
"Found #{parts.join(', ')}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def pluralize(count, word)
|
|
140
|
+
count == 1 ? "#{count} #{word}" : "#{count} #{word}s"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def error_count
|
|
144
|
+
diagnostics.count(&:error?)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def warning_count
|
|
148
|
+
diagnostics.count(&:warning?)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../diagnostic_context"
|
|
4
|
+
|
|
5
|
+
module Docyard
|
|
6
|
+
class Doctor
|
|
7
|
+
class SidebarChecker
|
|
8
|
+
SIDEBAR_DOCS_URL = "https://docyard.dev/customize/sidebar/"
|
|
9
|
+
|
|
10
|
+
attr_reader :docs_path
|
|
11
|
+
|
|
12
|
+
def initialize(docs_path)
|
|
13
|
+
@docs_path = docs_path
|
|
14
|
+
@sidebar_path = File.join(docs_path, "_sidebar.yml")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check
|
|
18
|
+
loader = Sidebar::LocalConfigLoader.new(docs_path, validate: false)
|
|
19
|
+
items = loader.load
|
|
20
|
+
diagnostics = convert_key_errors(loader.key_errors)
|
|
21
|
+
diagnostics.concat(check_missing_files(items)) if items
|
|
22
|
+
diagnostics
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def convert_key_errors(key_errors)
|
|
28
|
+
key_errors.map do |error|
|
|
29
|
+
Diagnostic.new(
|
|
30
|
+
severity: :error,
|
|
31
|
+
category: :SIDEBAR,
|
|
32
|
+
code: "SIDEBAR_VALIDATION",
|
|
33
|
+
field: "_sidebar.yml: #{error[:context]}",
|
|
34
|
+
message: error[:message],
|
|
35
|
+
fix: error[:fix]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def check_missing_files(items, path_prefix: "")
|
|
41
|
+
diagnostics = []
|
|
42
|
+
items.each do |item|
|
|
43
|
+
diagnostics.concat(check_item_file(item, path_prefix))
|
|
44
|
+
end
|
|
45
|
+
diagnostics
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def check_item_file(item, path_prefix)
|
|
49
|
+
diagnostics = []
|
|
50
|
+
slug, options = extract_slug_and_options(item)
|
|
51
|
+
return diagnostics unless slug
|
|
52
|
+
|
|
53
|
+
if requires_file?(options)
|
|
54
|
+
file_path = build_file_path(path_prefix, slug, options)
|
|
55
|
+
diagnostics << build_missing_file_diagnostic(slug, path_prefix, file_path) unless file_exists?(file_path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if options.is_a?(Hash) && options["items"]
|
|
59
|
+
nested_prefix = options["group"] ? path_prefix : File.join(path_prefix, slug)
|
|
60
|
+
diagnostics.concat(check_missing_files(options["items"], path_prefix: nested_prefix))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
diagnostics
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def requires_file?(options)
|
|
67
|
+
return true unless options.is_a?(Hash)
|
|
68
|
+
return true unless options["items"]
|
|
69
|
+
|
|
70
|
+
options["index"]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extract_slug_and_options(item)
|
|
74
|
+
return [item, nil] if item.is_a?(String)
|
|
75
|
+
return [nil, nil] unless item.is_a?(Hash)
|
|
76
|
+
return [nil, nil] if external_link?(item)
|
|
77
|
+
return [nil, nil] if item.keys.size != 1
|
|
78
|
+
|
|
79
|
+
slug = item.keys.first
|
|
80
|
+
slug.is_a?(String) ? [slug, item[slug]] : [nil, nil]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_file_path(path_prefix, slug, options)
|
|
84
|
+
base = File.join(path_prefix, slug)
|
|
85
|
+
options.is_a?(Hash) && options["index"] ? File.join(base, "index") : base
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def file_exists?(relative_path)
|
|
89
|
+
md_path = File.join(docs_path, "#{relative_path}.md")
|
|
90
|
+
index_path = File.join(docs_path, relative_path, "index.md")
|
|
91
|
+
File.exist?(md_path) || File.exist?(index_path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_missing_file_diagnostic(slug, path_prefix, file_path)
|
|
95
|
+
context = path_prefix.empty? ? slug : "#{path_prefix}/#{slug}"
|
|
96
|
+
line = find_sidebar_line(slug)
|
|
97
|
+
source_context = DiagnosticContext.extract_source_context(@sidebar_path, line) if line
|
|
98
|
+
|
|
99
|
+
Diagnostic.new(
|
|
100
|
+
severity: :error,
|
|
101
|
+
category: :SIDEBAR,
|
|
102
|
+
code: "SIDEBAR_MISSING_FILE",
|
|
103
|
+
file: "_sidebar.yml",
|
|
104
|
+
line: line,
|
|
105
|
+
field: context,
|
|
106
|
+
message: "references missing file '#{file_path}.md'",
|
|
107
|
+
doc_url: SIDEBAR_DOCS_URL,
|
|
108
|
+
source_context: source_context
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def find_sidebar_line(slug)
|
|
113
|
+
return nil unless File.exist?(@sidebar_path)
|
|
114
|
+
|
|
115
|
+
lines = File.readlines(@sidebar_path)
|
|
116
|
+
lines.each_with_index do |line, index|
|
|
117
|
+
return index + 1 if line.include?("- #{slug}") || line.include?("#{slug}:")
|
|
118
|
+
end
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def external_link?(item)
|
|
123
|
+
item.key?("link") || item.key?(:link)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
class SidebarFixer
|
|
6
|
+
attr_reader :fixed_issues
|
|
7
|
+
|
|
8
|
+
def initialize(docs_path)
|
|
9
|
+
@sidebar_path = File.join(docs_path, "_sidebar.yml")
|
|
10
|
+
@fixed_issues = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fix(issues)
|
|
14
|
+
fixable = issues.select(&:fixable?)
|
|
15
|
+
return if fixable.empty?
|
|
16
|
+
return unless File.exist?(@sidebar_path)
|
|
17
|
+
|
|
18
|
+
lines = File.readlines(@sidebar_path)
|
|
19
|
+
fixable.each { |issue| attempt_fix(lines, issue) }
|
|
20
|
+
File.write(@sidebar_path, lines.join)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fixed_count
|
|
24
|
+
@fixed_issues.size
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def attempt_fix(lines, issue)
|
|
30
|
+
return unless issue.fix[:type] == :rename
|
|
31
|
+
|
|
32
|
+
fix_rename(lines, issue)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fix_rename(lines, issue)
|
|
36
|
+
from_key = issue.fix[:from]
|
|
37
|
+
to_key = issue.fix[:to]
|
|
38
|
+
|
|
39
|
+
index = lines.find_index { |line| line =~ /^(\s*)#{Regexp.escape(from_key)}:/ }
|
|
40
|
+
return unless index
|
|
41
|
+
|
|
42
|
+
lines[index] = lines[index].sub(/#{Regexp.escape(from_key)}:/, "#{to_key}:")
|
|
43
|
+
@fixed_issues << issue
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|