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