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,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
|