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,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "component_checkers/base"
|
|
4
|
+
require_relative "component_checkers/callout_checker"
|
|
5
|
+
require_relative "component_checkers/tabs_checker"
|
|
6
|
+
require_relative "component_checkers/cards_checker"
|
|
7
|
+
require_relative "component_checkers/steps_checker"
|
|
8
|
+
require_relative "component_checkers/code_group_checker"
|
|
9
|
+
require_relative "component_checkers/details_checker"
|
|
10
|
+
require_relative "component_checkers/badge_checker"
|
|
11
|
+
require_relative "component_checkers/icon_checker"
|
|
12
|
+
require_relative "component_checkers/tooltip_checker"
|
|
13
|
+
require_relative "component_checkers/abbreviation_checker"
|
|
14
|
+
require_relative "component_checkers/image_attrs_checker"
|
|
15
|
+
require_relative "component_checkers/space_after_colons_checker"
|
|
16
|
+
require_relative "component_checkers/unknown_type_checker"
|
|
17
|
+
|
|
18
|
+
module Docyard
|
|
19
|
+
class Doctor
|
|
20
|
+
class ComponentChecker
|
|
21
|
+
CHECKER_CLASSES = [
|
|
22
|
+
ComponentCheckers::CalloutChecker,
|
|
23
|
+
ComponentCheckers::TabsChecker,
|
|
24
|
+
ComponentCheckers::CardsChecker,
|
|
25
|
+
ComponentCheckers::StepsChecker,
|
|
26
|
+
ComponentCheckers::CodeGroupChecker,
|
|
27
|
+
ComponentCheckers::DetailsChecker,
|
|
28
|
+
ComponentCheckers::BadgeChecker,
|
|
29
|
+
ComponentCheckers::IconChecker,
|
|
30
|
+
ComponentCheckers::TooltipChecker,
|
|
31
|
+
ComponentCheckers::AbbreviationChecker,
|
|
32
|
+
ComponentCheckers::ImageAttrsChecker,
|
|
33
|
+
ComponentCheckers::SpaceAfterColonsChecker,
|
|
34
|
+
ComponentCheckers::UnknownTypeChecker
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :docs_path
|
|
38
|
+
|
|
39
|
+
def initialize(docs_path)
|
|
40
|
+
@docs_path = docs_path
|
|
41
|
+
@checkers = CHECKER_CLASSES.map { |klass| klass.new(docs_path) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def check_file(content, file_path)
|
|
45
|
+
@checkers.flat_map { |checker| checker.check_file(content, file_path) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class AbbreviationChecker < Base
|
|
7
|
+
DEFINITION_PATTERN = /^\*\[([^\]]+)\]:\s*(.+)$/
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def docs_url
|
|
12
|
+
"https://docyard.dev/write-content/markdown/#abbreviations"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def process_content(content, relative_file)
|
|
16
|
+
content_outside_code = content_without_code_blocks(content)
|
|
17
|
+
|
|
18
|
+
definitions = extract_definitions(content_outside_code)
|
|
19
|
+
return [] if definitions.empty?
|
|
20
|
+
|
|
21
|
+
check_duplicates(definitions, relative_file) +
|
|
22
|
+
check_unused(definitions, content_outside_code, relative_file)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def content_without_code_blocks(content)
|
|
26
|
+
each_line_outside_code_blocks(content).map { |line, _| line }.join
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def extract_definitions(content)
|
|
30
|
+
definitions = []
|
|
31
|
+
content.each_line.with_index(1) do |line, line_number|
|
|
32
|
+
next unless (match = line.match(DEFINITION_PATTERN))
|
|
33
|
+
|
|
34
|
+
definitions << { term: match[1], definition: match[2].strip, line: line_number }
|
|
35
|
+
end
|
|
36
|
+
definitions
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def check_duplicates(definitions, relative_file)
|
|
40
|
+
seen = {}
|
|
41
|
+
definitions.filter_map do |defn|
|
|
42
|
+
if seen.key?(defn[:term])
|
|
43
|
+
build_diagnostic(
|
|
44
|
+
"ABBR_DUPLICATE",
|
|
45
|
+
"abbreviation '#{defn[:term]}' already defined on line #{seen[defn[:term]]}",
|
|
46
|
+
relative_file,
|
|
47
|
+
defn[:line]
|
|
48
|
+
)
|
|
49
|
+
else
|
|
50
|
+
seen[defn[:term]] = defn[:line]
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check_unused(definitions, content, relative_file)
|
|
57
|
+
content_without_definitions = content.gsub(DEFINITION_PATTERN, "")
|
|
58
|
+
|
|
59
|
+
definitions.filter_map do |defn|
|
|
60
|
+
pattern = /(?<![<\w])#{Regexp.escape(defn[:term])}(?![>\w])/
|
|
61
|
+
next if content_without_definitions.match?(pattern)
|
|
62
|
+
|
|
63
|
+
build_diagnostic(
|
|
64
|
+
"ABBR_UNUSED",
|
|
65
|
+
"abbreviation '#{defn[:term]}' defined but never used",
|
|
66
|
+
relative_file,
|
|
67
|
+
defn[:line]
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class BadgeChecker < Base
|
|
7
|
+
BADGE_PATTERN = /:badge\[([^\]]*)\](?:\{([^}]*)\})?/
|
|
8
|
+
VALID_TYPES = %w[default success warning danger].freeze
|
|
9
|
+
VALID_ATTRS = %w[type].freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def docs_url
|
|
14
|
+
"https://docyard.dev/write-content/components/badges/"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def process_content(content, relative_file)
|
|
18
|
+
each_line_outside_code_blocks(content).flat_map do |line, line_number|
|
|
19
|
+
check_badges_in_line(strip_inline_code(line), relative_file, line_number)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def check_badges_in_line(line, relative_file, line_number)
|
|
24
|
+
line.scan(BADGE_PATTERN).flat_map do |_text, attrs_string|
|
|
25
|
+
next [] if attrs_string.nil? || attrs_string.empty?
|
|
26
|
+
|
|
27
|
+
validate_badge_attrs(attrs_string, relative_file, line_number)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_badge_attrs(attrs_string, relative_file, line_number)
|
|
32
|
+
attrs = parse_attrs(attrs_string)
|
|
33
|
+
diagnostics = []
|
|
34
|
+
|
|
35
|
+
diagnostics.concat(check_unknown_attrs(attrs, relative_file, line_number))
|
|
36
|
+
diagnostics.concat(check_unknown_type(attrs["type"], relative_file, line_number)) if attrs.key?("type")
|
|
37
|
+
|
|
38
|
+
diagnostics
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_attrs(attrs_string)
|
|
42
|
+
attrs = {}
|
|
43
|
+
attrs_string.scan(/(\w+)=["']([^"']*)["']/) do |key, value|
|
|
44
|
+
attrs[key] = value
|
|
45
|
+
end
|
|
46
|
+
attrs
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_unknown_attrs(attrs, relative_file, line_number)
|
|
50
|
+
(attrs.keys - VALID_ATTRS).map do |attr|
|
|
51
|
+
sug = suggest(attr, VALID_ATTRS)
|
|
52
|
+
msg = "unknown badge attribute '#{attr}'"
|
|
53
|
+
msg += ", did you mean '#{sug}'?" if sug
|
|
54
|
+
fix = sug ? { type: :line_replace, from: "#{attr}=", to: "#{sug}=" } : nil
|
|
55
|
+
build_diagnostic("BADGE_UNKNOWN_ATTR", msg, relative_file, line_number, fix: fix)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_unknown_type(type_value, relative_file, line_number)
|
|
60
|
+
return [] if VALID_TYPES.include?(type_value)
|
|
61
|
+
|
|
62
|
+
sug = suggest(type_value, VALID_TYPES)
|
|
63
|
+
msg = "unknown badge type '#{type_value}'"
|
|
64
|
+
msg += ", did you mean '#{sug}'?" if sug
|
|
65
|
+
fix = sug ? { type: :line_replace, from: "type=\"#{type_value}\"", to: "type=\"#{sug}\"" } : nil
|
|
66
|
+
[build_diagnostic("BADGE_UNKNOWN_TYPE", msg, relative_file, line_number, fix: fix)]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../diagnostic_context"
|
|
4
|
+
|
|
5
|
+
module Docyard
|
|
6
|
+
class Doctor
|
|
7
|
+
module ComponentCheckers
|
|
8
|
+
class Base
|
|
9
|
+
CONTAINER_PATTERN = /^:::(\w[\w-]*)/
|
|
10
|
+
CLOSE_PATTERN = /^:::\s*$/
|
|
11
|
+
CODE_FENCE_REGEX = /^(`{3,}|~{3,})/
|
|
12
|
+
|
|
13
|
+
COMPONENTS_DOCS_URL = "https://docyard.dev/write-content/components/"
|
|
14
|
+
|
|
15
|
+
attr_reader :docs_path
|
|
16
|
+
|
|
17
|
+
def initialize(docs_path)
|
|
18
|
+
@docs_path = docs_path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def check_file(content, file_path)
|
|
22
|
+
@current_file_path = file_path
|
|
23
|
+
relative_file = file_path.delete_prefix("#{docs_path}/")
|
|
24
|
+
process_content(content, relative_file)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def process_content(_content, _relative_file)
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse_blocks(content)
|
|
34
|
+
blocks = []
|
|
35
|
+
open_blocks = []
|
|
36
|
+
in_code_block = false
|
|
37
|
+
|
|
38
|
+
content.each_line.with_index(1) do |line, line_number|
|
|
39
|
+
in_code_block = !in_code_block if line.match?(CODE_FENCE_REGEX)
|
|
40
|
+
next if in_code_block
|
|
41
|
+
|
|
42
|
+
process_block_line(line, line_number, open_blocks, blocks)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
open_blocks.each { |b| b[:closed] = false }
|
|
46
|
+
blocks + open_blocks
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def process_block_line(line, line_number, open_blocks, blocks)
|
|
50
|
+
if (match = line.match(CONTAINER_PATTERN))
|
|
51
|
+
open_blocks.push({ type: match[1].downcase, line: line_number, closed: true })
|
|
52
|
+
elsif line.match?(CLOSE_PATTERN) && open_blocks.any?
|
|
53
|
+
blocks.push(open_blocks.pop)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_block_content(content, start_line)
|
|
58
|
+
lines = content.lines
|
|
59
|
+
end_idx = lines[start_line..].find_index { |l| l.match?(CLOSE_PATTERN) }
|
|
60
|
+
end_idx ? lines[start_line...(start_line + end_idx)].join : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def each_line_outside_code_blocks(content)
|
|
64
|
+
return enum_for(__method__, content) unless block_given?
|
|
65
|
+
|
|
66
|
+
in_code_block = false
|
|
67
|
+
content.each_line.with_index(1) do |line, line_number|
|
|
68
|
+
in_code_block = !in_code_block if line.match?(CODE_FENCE_REGEX)
|
|
69
|
+
yield line, line_number unless in_code_block
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def filter_blocks(blocks, type_filter)
|
|
74
|
+
blocks.select { |b| type_filter.is_a?(Array) ? type_filter.include?(b[:type]) : b[:type] == type_filter }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def suggest(value, dictionary)
|
|
78
|
+
DidYouMean::SpellChecker.new(dictionary: dictionary).correct(value).first
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def strip_inline_code(line)
|
|
82
|
+
line.gsub(/`[^`]+`/, "")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_unclosed_diagnostic(prefix, block, relative_file)
|
|
86
|
+
build_diagnostic("#{prefix}_UNCLOSED", "unclosed :::#{block[:type]} block", relative_file, block[:line])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_diagnostic(code, message, file, line, fix: nil)
|
|
90
|
+
source_context = DiagnosticContext.extract_source_context(@current_file_path, line)
|
|
91
|
+
|
|
92
|
+
Diagnostic.new(
|
|
93
|
+
severity: :warning,
|
|
94
|
+
category: :COMPONENT,
|
|
95
|
+
code: code,
|
|
96
|
+
message: message,
|
|
97
|
+
file: file,
|
|
98
|
+
line: line,
|
|
99
|
+
fix: fix,
|
|
100
|
+
doc_url: docs_url,
|
|
101
|
+
source_context: source_context
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def docs_url
|
|
106
|
+
COMPONENTS_DOCS_URL
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class CalloutChecker < Base
|
|
7
|
+
CALLOUT_TYPES = %w[note tip important warning danger].freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def docs_url
|
|
12
|
+
"https://docyard.dev/write-content/components/callouts/"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def process_content(content, relative_file)
|
|
16
|
+
blocks = parse_blocks(content)
|
|
17
|
+
|
|
18
|
+
filter_blocks(blocks, CALLOUT_TYPES).filter_map do |block|
|
|
19
|
+
validate_callout(block, content, relative_file)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_callout(block, content, relative_file)
|
|
24
|
+
return build_unclosed_diagnostic("CALLOUT", block, relative_file) unless block[:closed]
|
|
25
|
+
|
|
26
|
+
block_content = extract_block_content(content, block[:line])
|
|
27
|
+
return nil unless block_content&.strip&.empty?
|
|
28
|
+
|
|
29
|
+
build_diagnostic("CALLOUT_EMPTY", "empty callout block", relative_file, block[:line])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class CardsChecker < Base
|
|
7
|
+
CARD_ITEM_PATTERN = /^::card\{/
|
|
8
|
+
CARD_ATTR_PATTERN = /^::card\{([^}]*)\}/
|
|
9
|
+
CARD_VALID_ATTRS = %w[title icon href].freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def docs_url
|
|
14
|
+
"https://docyard.dev/write-content/components/cards/"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def process_content(content, relative_file)
|
|
18
|
+
blocks = parse_blocks(content)
|
|
19
|
+
|
|
20
|
+
block_diagnostics = filter_blocks(blocks, "cards").filter_map do |block|
|
|
21
|
+
validate_cards(block, content, relative_file)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
block_diagnostics + check_card_attributes(content, relative_file)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_cards(block, content, relative_file)
|
|
28
|
+
return build_unclosed_diagnostic("CARDS", block, relative_file) unless block[:closed]
|
|
29
|
+
|
|
30
|
+
block_content = extract_block_content(content, block[:line])
|
|
31
|
+
return nil if block_content&.match?(CARD_ITEM_PATTERN)
|
|
32
|
+
|
|
33
|
+
build_diagnostic("CARDS_EMPTY", "empty cards block, add '::card{title=\"...\"}' to define cards",
|
|
34
|
+
relative_file, block[:line])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_card_attributes(content, relative_file)
|
|
38
|
+
each_line_outside_code_blocks(content).flat_map do |line, line_number|
|
|
39
|
+
next [] unless (match = line.match(CARD_ATTR_PATTERN))
|
|
40
|
+
|
|
41
|
+
validate_attrs(match[1], relative_file, line_number)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_attrs(attr_string, relative_file, line_number)
|
|
46
|
+
unknown = attr_string.scan(/(\w+)=/).flatten - CARD_VALID_ATTRS
|
|
47
|
+
unknown.map do |attr|
|
|
48
|
+
sug = suggest(attr, CARD_VALID_ATTRS)
|
|
49
|
+
msg = "unknown card attribute '#{attr}'"
|
|
50
|
+
msg += ", did you mean '#{sug}'?" if sug
|
|
51
|
+
build_diagnostic("CARD_UNKNOWN_ATTR", msg, relative_file, line_number)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class CodeGroupChecker < Base
|
|
7
|
+
CODE_BLOCK_WITH_LABEL = /^```\w*\s*\[/
|
|
8
|
+
CODE_BLOCK_START = /^```(\w+)/
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def docs_url
|
|
13
|
+
"https://docyard.dev/write-content/components/code-groups/"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def process_content(content, relative_file)
|
|
17
|
+
blocks = parse_blocks(content)
|
|
18
|
+
|
|
19
|
+
filter_blocks(blocks, "code-group").flat_map do |block|
|
|
20
|
+
validate_code_group(block, content, relative_file)
|
|
21
|
+
end.compact
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate_code_group(block, content, relative_file)
|
|
25
|
+
return build_unclosed_diagnostic("CODE_GROUP", block, relative_file) unless block[:closed]
|
|
26
|
+
|
|
27
|
+
block_content = extract_block_content(content, block[:line])
|
|
28
|
+
unless block_content&.match?(CODE_BLOCK_WITH_LABEL)
|
|
29
|
+
return build_diagnostic("CODE_GROUP_EMPTY", "empty code-group, add code blocks with [labels]",
|
|
30
|
+
relative_file, block[:line])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
check_unlabeled_code_blocks(block_content, relative_file, block[:line])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def check_unlabeled_code_blocks(block_content, relative_file, start_line)
|
|
37
|
+
block_content.each_line.with_index(1).filter_map do |line, offset|
|
|
38
|
+
next unless line.match?(CODE_BLOCK_START) && !line.match?(CODE_BLOCK_WITH_LABEL)
|
|
39
|
+
|
|
40
|
+
build_diagnostic("CODE_GROUP_MISSING_LABEL", "code block missing label", relative_file,
|
|
41
|
+
start_line + offset)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class DetailsChecker < Base
|
|
7
|
+
DETAILS_ATTR_PATTERN = /^:::details\{([^}]*)\}/
|
|
8
|
+
DETAILS_VALID_ATTRS = %w[title open].freeze
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def docs_url
|
|
13
|
+
"https://docyard.dev/write-content/components/accordions/"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def process_content(content, relative_file)
|
|
17
|
+
blocks = parse_blocks(content)
|
|
18
|
+
|
|
19
|
+
unclosed = filter_blocks(blocks, "details").reject { |b| b[:closed] }.map do |block|
|
|
20
|
+
build_unclosed_diagnostic("DETAILS", block, relative_file)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
unclosed + check_details_attributes(content, relative_file)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_details_attributes(content, relative_file)
|
|
27
|
+
each_line_outside_code_blocks(content).flat_map do |line, line_number|
|
|
28
|
+
next [] unless (match = line.match(DETAILS_ATTR_PATTERN))
|
|
29
|
+
|
|
30
|
+
validate_attrs(match[1], relative_file, line_number)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_attrs(attr_string, relative_file, line_number)
|
|
35
|
+
attrs = extract_attr_names(attr_string) - DETAILS_VALID_ATTRS
|
|
36
|
+
attrs.map do |attr|
|
|
37
|
+
sug = suggest(attr, DETAILS_VALID_ATTRS)
|
|
38
|
+
msg = "unknown details attribute '#{attr}'"
|
|
39
|
+
msg += ", did you mean '#{sug}'?" if sug
|
|
40
|
+
build_diagnostic("DETAILS_UNKNOWN_ATTR", msg, relative_file, line_number)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def extract_attr_names(attr_string)
|
|
45
|
+
cleaned = attr_string.gsub(/"[^"]*"/, "")
|
|
46
|
+
cleaned.scan(/(\w+)(?:=|\s|$)/).flatten
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class IconChecker < Base
|
|
7
|
+
ICON_PATTERN = /:([a-z][a-z0-9-]*):([a-z]+):/i
|
|
8
|
+
VALID_WEIGHTS = %w[regular bold fill light thin duotone].freeze
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def docs_url
|
|
13
|
+
"https://docyard.dev/write-content/components/icons/"
|
|
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_icons_in_line(strip_inline_code(line), relative_file, line_number)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def check_icons_in_line(line, relative_file, line_number)
|
|
23
|
+
line.scan(ICON_PATTERN).filter_map do |icon_name, weight|
|
|
24
|
+
next if VALID_WEIGHTS.include?(weight.downcase)
|
|
25
|
+
|
|
26
|
+
sug = suggest(weight.downcase, VALID_WEIGHTS)
|
|
27
|
+
msg = "unknown icon weight '#{weight}'"
|
|
28
|
+
msg += ", did you mean '#{sug}'?" if sug
|
|
29
|
+
fix = sug ? { type: :line_replace, from: ":#{icon_name}:#{weight}:", to: ":#{icon_name}:#{sug}:" } : nil
|
|
30
|
+
build_diagnostic("ICON_UNKNOWN_WEIGHT", msg, relative_file, line_number, fix: fix)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class ImageAttrsChecker < Base
|
|
7
|
+
IMAGE_ATTRS_PATTERN = /!\[[^\]]*\]\([^)]+\)\{([^}]+)\}/
|
|
8
|
+
VALID_ATTRS = %w[caption width height nozoom].freeze
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def docs_url
|
|
13
|
+
"https://docyard.dev/write-content/images-and-videos/"
|
|
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_images_in_line(strip_inline_code(line), relative_file, line_number)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def check_images_in_line(line, relative_file, line_number)
|
|
23
|
+
line.scan(IMAGE_ATTRS_PATTERN).flat_map do |attrs_string,|
|
|
24
|
+
check_unknown_attrs(attrs_string, relative_file, line_number)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check_unknown_attrs(attrs_string, relative_file, line_number)
|
|
29
|
+
attrs = extract_attr_names(attrs_string)
|
|
30
|
+
(attrs - VALID_ATTRS).map do |attr|
|
|
31
|
+
sug = suggest(attr, VALID_ATTRS)
|
|
32
|
+
msg = "unknown image attribute '#{attr}'"
|
|
33
|
+
msg += ", did you mean '#{sug}'?" if sug
|
|
34
|
+
fix = sug ? { type: :line_replace, from: "#{attr}=", to: "#{sug}=" } : nil
|
|
35
|
+
build_diagnostic("IMAGE_UNKNOWN_ATTR", msg, relative_file, line_number, fix: fix)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def extract_attr_names(attrs_string)
|
|
40
|
+
cleaned = attrs_string.gsub(/"[^"]*"/, "")
|
|
41
|
+
cleaned.scan(/(\w+)(?:=|\s|$)/).flatten
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class SpaceAfterColonsChecker < Base
|
|
7
|
+
SPACE_AFTER_COLONS_PATTERN = /^(:::\s+)(\w[\w-]*)/
|
|
8
|
+
|
|
9
|
+
CALLOUT_TYPES = %w[note tip important warning danger].freeze
|
|
10
|
+
KNOWN_COMPONENTS = %w[tabs cards steps code-group details].freeze
|
|
11
|
+
ALL_CONTAINER_TYPES = (CALLOUT_TYPES + KNOWN_COMPONENTS).freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def process_content(content, relative_file)
|
|
16
|
+
each_line_outside_code_blocks(content).filter_map do |line, line_number|
|
|
17
|
+
next unless (match = line.match(SPACE_AFTER_COLONS_PATTERN))
|
|
18
|
+
|
|
19
|
+
component = match[2].downcase
|
|
20
|
+
build_space_diagnostic(component, match[1], relative_file, line_number)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_space_diagnostic(component, matched_prefix, relative_file, line_number)
|
|
25
|
+
if ALL_CONTAINER_TYPES.include?(component)
|
|
26
|
+
fix = { type: :line_replace, from: matched_prefix, to: ":::" }
|
|
27
|
+
build_diagnostic(
|
|
28
|
+
"COMPONENT_SPACE_AFTER_COLONS",
|
|
29
|
+
"invalid syntax '::: #{component}', did you mean ':::#{component}'?",
|
|
30
|
+
relative_file,
|
|
31
|
+
line_number,
|
|
32
|
+
fix: fix
|
|
33
|
+
)
|
|
34
|
+
else
|
|
35
|
+
sug = suggest(component, ALL_CONTAINER_TYPES)
|
|
36
|
+
msg = "unknown component '#{component}'"
|
|
37
|
+
msg += ", did you mean ':::#{sug}'?" if sug
|
|
38
|
+
fix = sug ? { type: :line_replace, from: "#{matched_prefix}#{component}", to: ":::#{sug}" } : nil
|
|
39
|
+
build_diagnostic("COMPONENT_UNKNOWN_TYPE", msg, relative_file, line_number, fix: fix)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Doctor
|
|
5
|
+
module ComponentCheckers
|
|
6
|
+
class StepsChecker < Base
|
|
7
|
+
STEP_ITEM_PATTERN = /^###\s+.+/
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def docs_url
|
|
12
|
+
"https://docyard.dev/write-content/components/steps/"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def process_content(content, relative_file)
|
|
16
|
+
blocks = parse_blocks(content)
|
|
17
|
+
|
|
18
|
+
filter_blocks(blocks, "steps").filter_map do |block|
|
|
19
|
+
validate_steps(block, content, relative_file)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_steps(block, content, relative_file)
|
|
24
|
+
return build_unclosed_diagnostic("STEPS", block, relative_file) unless block[:closed]
|
|
25
|
+
|
|
26
|
+
block_content = extract_block_content(content, block[:line])
|
|
27
|
+
return nil if block_content&.match?(STEP_ITEM_PATTERN)
|
|
28
|
+
|
|
29
|
+
build_diagnostic("STEPS_EMPTY", "empty steps block, add '### Step Title' to define steps", relative_file,
|
|
30
|
+
block[:line])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|