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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Doctor
5
+ module ComponentCheckers
6
+ class TabsChecker < Base
7
+ TAB_ITEM_PATTERN = /^==\s+.+/
8
+
9
+ private
10
+
11
+ def docs_url
12
+ "https://docyard.dev/write-content/components/tabs/"
13
+ end
14
+
15
+ def process_content(content, relative_file)
16
+ blocks = parse_blocks(content)
17
+
18
+ filter_blocks(blocks, "tabs").filter_map do |block|
19
+ validate_tabs(block, content, relative_file)
20
+ end
21
+ end
22
+
23
+ def validate_tabs(block, content, relative_file)
24
+ return build_unclosed_diagnostic("TABS", block, relative_file) unless block[:closed]
25
+
26
+ block_content = extract_block_content(content, block[:line])
27
+ return nil if block_content&.match?(TAB_ITEM_PATTERN)
28
+
29
+ build_diagnostic("TABS_EMPTY", "empty tabs block, add '== Tab Name' to define tabs", relative_file,
30
+ block[:line])
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Doctor
5
+ module ComponentCheckers
6
+ class TooltipChecker < Base
7
+ TOOLTIP_PATTERN = /:tooltip\[([^\]]+)\]\{([^}]*)\}/
8
+ VALID_ATTRS = %w[description link link_text].freeze
9
+
10
+ private
11
+
12
+ def docs_url
13
+ "https://docyard.dev/write-content/components/tooltips/"
14
+ end
15
+
16
+ def process_content(content, relative_file)
17
+ each_line_outside_code_blocks(content).flat_map do |line, line_number|
18
+ check_tooltips_in_line(strip_inline_code(line), relative_file, line_number)
19
+ end
20
+ end
21
+
22
+ def check_tooltips_in_line(line, relative_file, line_number)
23
+ line.scan(TOOLTIP_PATTERN).flat_map do |_term, attrs_string|
24
+ validate_tooltip_attrs(attrs_string, relative_file, line_number)
25
+ end
26
+ end
27
+
28
+ def validate_tooltip_attrs(attrs_string, relative_file, line_number)
29
+ attrs = parse_attrs(attrs_string)
30
+ diagnostics = []
31
+
32
+ diagnostics << missing_description_diagnostic(relative_file, line_number) unless attrs.key?("description")
33
+ diagnostics.concat(check_unknown_attrs(attrs.keys, relative_file, line_number))
34
+
35
+ diagnostics
36
+ end
37
+
38
+ def parse_attrs(attrs_string)
39
+ attrs = {}
40
+ attrs_string.scan(/(\w+)="([^"]*)"/) do |key, value|
41
+ attrs[key] = value
42
+ end
43
+ attrs
44
+ end
45
+
46
+ def missing_description_diagnostic(relative_file, line_number)
47
+ build_diagnostic(
48
+ "TOOLTIP_MISSING_DESCRIPTION",
49
+ "tooltip missing required 'description' attribute",
50
+ relative_file,
51
+ line_number
52
+ )
53
+ end
54
+
55
+ def check_unknown_attrs(attr_names, relative_file, line_number)
56
+ (attr_names - VALID_ATTRS).map do |attr|
57
+ sug = suggest(attr, VALID_ATTRS)
58
+ msg = "unknown tooltip attribute '#{attr}'"
59
+ msg += ", did you mean '#{sug}'?" if sug
60
+ fix = sug ? { type: :line_replace, from: "#{attr}=", to: "#{sug}=" } : nil
61
+ build_diagnostic("TOOLTIP_UNKNOWN_ATTR", msg, relative_file, line_number, fix: fix)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Doctor
5
+ module ComponentCheckers
6
+ class UnknownTypeChecker < Base
7
+ CALLOUT_TYPES = %w[note tip important warning danger].freeze
8
+ KNOWN_COMPONENTS = %w[tabs cards steps code-group details].freeze
9
+ ALL_CONTAINER_TYPES = (CALLOUT_TYPES + KNOWN_COMPONENTS).freeze
10
+
11
+ private
12
+
13
+ def process_content(content, relative_file)
14
+ each_line_outside_code_blocks(content).filter_map do |line, line_number|
15
+ next unless (m = line.match(CONTAINER_PATTERN))
16
+
17
+ type = m[1].downcase
18
+ next if ALL_CONTAINER_TYPES.include?(type)
19
+
20
+ build_unknown_type_diagnostic(type, m[1], relative_file, line_number)
21
+ end
22
+ end
23
+
24
+ def build_unknown_type_diagnostic(type, original_type, relative_file, line_number)
25
+ sug = suggest(type, ALL_CONTAINER_TYPES)
26
+ msg = "unknown component ':::#{type}'"
27
+ msg += ", did you mean ':::#{sug}'?" if sug
28
+ fix = sug ? { type: :line_replace, from: ":::#{original_type}", to: ":::#{sug}" } : nil
29
+ build_diagnostic("COMPONENT_UNKNOWN_TYPE", msg, relative_file, line_number, fix: fix)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Doctor
5
+ class ConfigChecker
6
+ attr_reader :config
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def check
13
+ validator = Config::Validator.new(config.data, source_dir: config.source)
14
+ validator.validate_all
15
+ validator.diagnostics
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Doctor
5
+ class ConfigFixer
6
+ attr_reader :fixed_issues
7
+
8
+ def initialize(config_path = "docyard.yml")
9
+ @config_path = config_path
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?(@config_path)
17
+
18
+ lines = File.readlines(@config_path)
19
+ fixable.each { |issue| attempt_fix(lines, issue) }
20
+ File.write(@config_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
+ case issue.fix[:type]
31
+ when :replace then fix_replace(lines, issue)
32
+ when :rename then fix_rename(lines, issue)
33
+ end
34
+ end
35
+
36
+ def fix_replace(lines, issue)
37
+ key = issue.field.split(".").last
38
+ old_val = normalize_value(extract_got_value(issue))
39
+ new_val = format_value(issue.fix[:value])
40
+
41
+ index = find_line_index(lines, key, old_val)
42
+ return unless index
43
+
44
+ lines[index] = lines[index].sub(/:\s*.*$/, ": #{new_val}\n")
45
+ @fixed_issues << issue
46
+ end
47
+
48
+ def extract_got_value(diagnostic)
49
+ diagnostic.details&.dig(:got)
50
+ end
51
+
52
+ def fix_rename(lines, issue)
53
+ from_key = issue.fix[:from]
54
+ to_key = issue.fix[:to]
55
+
56
+ index = lines.find_index { |line| line =~ /^(\s*)#{Regexp.escape(from_key)}:/ }
57
+ return unless index
58
+
59
+ lines[index] = lines[index].sub(from_key, to_key)
60
+ @fixed_issues << issue
61
+ end
62
+
63
+ def find_line_index(lines, key, old_val)
64
+ lines.find_index do |line|
65
+ line =~ /^(\s*)#{Regexp.escape(key)}:\s*/ && line.include?(old_val.to_s)
66
+ end
67
+ end
68
+
69
+ def normalize_value(val)
70
+ return val unless val.is_a?(String)
71
+
72
+ val.gsub(/^"|"$/, "")
73
+ end
74
+
75
+ def format_value(val)
76
+ case val
77
+ when true then "true"
78
+ when false then "false"
79
+ when String
80
+ val.start_with?("/") ? "\"#{val}\"" : val
81
+ else
82
+ val.to_s
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Doctor
5
+ class ContentChecker
6
+ FRONTMATTER_REGEX = /\A---\s*\n(.*?\n)---\s*\n/m
7
+ INCLUDE_PATTERN = /<!--\s*@include:\s*([^\s]+)\s*-->/
8
+ SNIPPET_PATTERN = %r{^<<<\s+@/([^\s{#]+)(?:#([\w-]+))?(?:\{([^}]+)\})?\s*$}
9
+ CODE_FENCE_REGEX = /^(`{3,}|~{3,})/
10
+ MARKDOWN_EXTENSIONS = %w[.md .markdown .mdx].freeze
11
+
12
+ FRONTMATTER_DOCS_URL = "https://docyard.dev/reference/frontmatter/"
13
+ INCLUDES_DOCS_URL = "https://docyard.dev/write-content/includes/"
14
+ SNIPPETS_DOCS_URL = "https://docyard.dev/write-content/components/code-blocks/"
15
+
16
+ attr_reader :docs_path
17
+
18
+ def initialize(docs_path)
19
+ @docs_path = docs_path
20
+ end
21
+
22
+ def check_file(content, file_path)
23
+ relative_file = file_path.delete_prefix("#{docs_path}/")
24
+
25
+ [
26
+ check_frontmatter(content, relative_file),
27
+ check_includes(content, file_path, relative_file),
28
+ check_snippets(content, relative_file)
29
+ ].flatten
30
+ end
31
+
32
+ private
33
+
34
+ def check_frontmatter(content, relative_file)
35
+ match = content.match(FRONTMATTER_REGEX)
36
+ return [] unless match
37
+
38
+ YAML.safe_load(match[1])
39
+ []
40
+ rescue Psych::SyntaxError => e
41
+ [build_frontmatter_diagnostic(relative_file, e)]
42
+ end
43
+
44
+ def build_frontmatter_diagnostic(file, error)
45
+ Diagnostic.new(
46
+ severity: :error,
47
+ category: :CONTENT,
48
+ code: "FRONTMATTER_INVALID_YAML",
49
+ message: "invalid YAML: #{error.problem}",
50
+ file: file,
51
+ line: error.line ? error.line + 1 : nil,
52
+ doc_url: FRONTMATTER_DOCS_URL
53
+ )
54
+ end
55
+
56
+ def check_includes(content, file_path, relative_file)
57
+ each_line_outside_code_blocks(content).filter_map do |line_content, line_number|
58
+ match = line_content.match(INCLUDE_PATTERN)
59
+ next unless match
60
+
61
+ validate_include(match[1], file_path, relative_file, line_number)
62
+ end
63
+ end
64
+
65
+ def each_line_outside_code_blocks(content)
66
+ return enum_for(__method__, content) unless block_given?
67
+
68
+ in_code_block = false
69
+ content.each_line.with_index(1) do |line, line_number|
70
+ in_code_block = !in_code_block if line.match?(CODE_FENCE_REGEX)
71
+ yield(line, line_number) unless in_code_block
72
+ end
73
+ end
74
+
75
+ def validate_include(include_path, file_path, relative_file, line_number)
76
+ full_path = resolve_include_path(include_path, file_path)
77
+
78
+ unless file_exists?(full_path)
79
+ return build_include_diagnostic(relative_file, line_number, include_path,
80
+ "file not found")
81
+ end
82
+ unless markdown_file?(include_path)
83
+ return build_include_diagnostic(relative_file, line_number, include_path,
84
+ "non-markdown file")
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ def file_exists?(path)
91
+ path && File.exist?(path)
92
+ end
93
+
94
+ def resolve_include_path(include_path, current_file)
95
+ if include_path.start_with?("./", "../")
96
+ File.expand_path(include_path, File.dirname(current_file))
97
+ else
98
+ File.join(docs_path, include_path)
99
+ end
100
+ end
101
+
102
+ def markdown_file?(filepath)
103
+ MARKDOWN_EXTENSIONS.include?(File.extname(filepath).downcase)
104
+ end
105
+
106
+ def build_include_diagnostic(file, line, include_path, message)
107
+ Diagnostic.new(
108
+ severity: :error,
109
+ category: :CONTENT,
110
+ code: "INCLUDE_ERROR",
111
+ message: "include '#{include_path}': #{message}",
112
+ file: file,
113
+ line: line,
114
+ doc_url: INCLUDES_DOCS_URL
115
+ )
116
+ end
117
+
118
+ def check_snippets(content, relative_file)
119
+ each_line_outside_code_blocks(content).filter_map do |line_content, line_number|
120
+ match = line_content.match(SNIPPET_PATTERN)
121
+ next unless match
122
+
123
+ validate_snippet(match, relative_file, line_number)
124
+ end
125
+ end
126
+
127
+ def validate_snippet(match, relative_file, line_number)
128
+ filepath = match[1]
129
+ region = match[2]
130
+ full_path = File.join(docs_path, filepath)
131
+
132
+ unless File.exist?(full_path)
133
+ return build_snippet_diagnostic(relative_file, line_number, filepath,
134
+ "file not found")
135
+ end
136
+ if region && !region_exists?(
137
+ full_path, region
138
+ )
139
+ return build_snippet_diagnostic(relative_file, line_number, filepath,
140
+ "region '#{region}' not found")
141
+ end
142
+
143
+ nil
144
+ end
145
+
146
+ def region_exists?(file_path, region_name)
147
+ content = File.read(file_path)
148
+ content.match?(%r{^[ \t]*(?://|#|/\*)\s*#region\s+#{Regexp.escape(region_name)}\b})
149
+ end
150
+
151
+ def build_snippet_diagnostic(file, line, snippet_path, message)
152
+ Diagnostic.new(
153
+ severity: :error,
154
+ category: :CONTENT,
155
+ code: "SNIPPET_ERROR",
156
+ message: "snippet '#{snippet_path}': #{message}",
157
+ file: file,
158
+ line: line,
159
+ doc_url: SNIPPETS_DOCS_URL
160
+ )
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Doctor
5
+ class FileScanner
6
+ THREAD_COUNT = [Etc.nprocessors, 8].min
7
+
8
+ attr_reader :docs_path, :files_scanned, :links_checked, :images_checked
9
+
10
+ def initialize(docs_path)
11
+ @docs_path = docs_path
12
+ @files_scanned = 0
13
+ @links_checked = 0
14
+ @images_checked = 0
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def scan
19
+ files = markdown_files
20
+ return [] if files.empty?
21
+
22
+ files.size < THREAD_COUNT ? scan_sequential(files) : scan_parallel(files)
23
+ end
24
+
25
+ private
26
+
27
+ def scan_sequential(files)
28
+ checkers = build_checkers
29
+ diagnostics = []
30
+
31
+ files.each do |file_path|
32
+ diagnostics.concat(process_file(file_path, checkers))
33
+ end
34
+
35
+ @files_scanned = files.size
36
+ collect_stats([checkers])
37
+ diagnostics
38
+ end
39
+
40
+ def scan_parallel(files)
41
+ queue = build_work_queue(files)
42
+ results = run_worker_threads(queue)
43
+
44
+ @files_scanned = files.size
45
+ collect_stats(results[:checkers])
46
+ results[:diagnostics]
47
+ end
48
+
49
+ def build_work_queue(files)
50
+ queue = Queue.new
51
+ files.each { |f| queue << f }
52
+ THREAD_COUNT.times { queue << :done }
53
+ queue
54
+ end
55
+
56
+ def run_worker_threads(queue)
57
+ all_checkers = []
58
+ all_diagnostics = []
59
+
60
+ threads = THREAD_COUNT.times.map { create_worker_thread(queue, all_checkers, all_diagnostics) }
61
+ threads.each(&:join)
62
+
63
+ { checkers: all_checkers, diagnostics: all_diagnostics }
64
+ end
65
+
66
+ def create_worker_thread(queue, all_checkers, all_diagnostics)
67
+ Thread.new do
68
+ checkers = build_checkers
69
+ thread_diagnostics = process_queue(queue, checkers)
70
+
71
+ @mutex.synchronize do
72
+ all_checkers << checkers
73
+ all_diagnostics.concat(thread_diagnostics)
74
+ end
75
+ end
76
+ end
77
+
78
+ def process_queue(queue, checkers)
79
+ diagnostics = []
80
+ while (file_path = queue.pop) != :done
81
+ diagnostics.concat(process_file(file_path, checkers))
82
+ end
83
+ diagnostics
84
+ end
85
+
86
+ def process_file(file_path, checkers)
87
+ content = File.read(file_path)
88
+ checkers.flat_map { |checker| checker.check_file(content, file_path) }
89
+ end
90
+
91
+ def markdown_files
92
+ Dir.glob(File.join(docs_path, "**", "*.md"))
93
+ end
94
+
95
+ def build_checkers
96
+ [
97
+ ContentChecker.new(docs_path),
98
+ ComponentChecker.new(docs_path),
99
+ CodeBlockChecker.new(docs_path),
100
+ LinkChecker.new(docs_path),
101
+ ImageChecker.new(docs_path)
102
+ ]
103
+ end
104
+
105
+ def collect_stats(checker_sets)
106
+ checker_sets.flatten.each do |checker|
107
+ @links_checked += checker.links_checked if checker.respond_to?(:links_checked)
108
+ @images_checked += checker.images_checked if checker.respond_to?(:images_checked)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../diagnostic_context"
4
+
5
+ module Docyard
6
+ class Doctor
7
+ class ImageChecker
8
+ MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/
9
+ HTML_IMAGE_REGEX = /<img[^>]+src=["']([^"']+)["']/
10
+ CODE_FENCE_REGEX = /^(`{3,}|~{3,})/
11
+ IMAGES_DOCS_URL = "https://docyard.dev/write-content/images-and-videos/"
12
+
13
+ attr_reader :docs_path, :images_checked
14
+
15
+ def initialize(docs_path)
16
+ @docs_path = docs_path
17
+ @images_checked = 0
18
+ end
19
+
20
+ def check_file(content, file_path)
21
+ relative_file = file_path.delete_prefix("#{docs_path}/")
22
+ file_dir = File.dirname(file_path)
23
+ diagnostics = []
24
+
25
+ each_line_outside_code_blocks(content) do |line, line_number|
26
+ diagnostics.concat(check_line_for_images(line, line_number, relative_file, file_dir))
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_images(line, line_number, relative_file, file_dir)
44
+ extract_image_paths(line).filter_map do |image_path|
45
+ next if external_url?(image_path)
46
+
47
+ @images_checked += 1
48
+ next if image_exists?(image_path, file_dir)
49
+
50
+ build_diagnostic(relative_file, line_number, image_path)
51
+ end
52
+ end
53
+
54
+ def build_diagnostic(file, line, target)
55
+ full_path = File.join(docs_path, file)
56
+ source_context = DiagnosticContext.extract_source_context(full_path, line)
57
+
58
+ Diagnostic.new(
59
+ severity: :warning,
60
+ category: :IMAGE,
61
+ code: "IMAGE_MISSING",
62
+ message: "Missing image '#{target}'",
63
+ file: file,
64
+ line: line,
65
+ field: target,
66
+ doc_url: IMAGES_DOCS_URL,
67
+ source_context: source_context
68
+ )
69
+ end
70
+
71
+ def extract_image_paths(line)
72
+ paths = []
73
+ line.scan(MARKDOWN_IMAGE_REGEX) { |_alt, src| paths << src }
74
+ line.scan(HTML_IMAGE_REGEX) { |src| paths << src.first }
75
+ paths
76
+ end
77
+
78
+ def external_url?(path)
79
+ path.start_with?("http://", "https://", "//")
80
+ end
81
+
82
+ def image_exists?(image_path, file_dir)
83
+ if image_path.start_with?("/")
84
+ absolute_image_exists?(image_path)
85
+ else
86
+ relative_image_exists?(image_path, file_dir)
87
+ end
88
+ end
89
+
90
+ def absolute_image_exists?(image_path)
91
+ clean_path = image_path.delete_prefix("/")
92
+ [
93
+ File.join(docs_path, clean_path),
94
+ File.join(docs_path, "public", clean_path)
95
+ ].any? { |f| File.exist?(f) }
96
+ end
97
+
98
+ def relative_image_exists?(image_path, file_dir)
99
+ File.exist?(File.expand_path(image_path, file_dir))
100
+ end
101
+ end
102
+ end
103
+ end