docyard 0.2.0 → 0.4.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/LICENSE.vscode-icons +42 -0
  4. data/README.md +86 -23
  5. data/lib/docyard/asset_handler.rb +33 -0
  6. data/lib/docyard/build/asset_bundler.rb +139 -0
  7. data/lib/docyard/build/file_copier.rb +105 -0
  8. data/lib/docyard/build/sitemap_generator.rb +57 -0
  9. data/lib/docyard/build/static_generator.rb +141 -0
  10. data/lib/docyard/builder.rb +104 -0
  11. data/lib/docyard/cli.rb +19 -0
  12. data/lib/docyard/components/base_processor.rb +24 -0
  13. data/lib/docyard/components/callout_processor.rb +121 -0
  14. data/lib/docyard/components/code_block_processor.rb +55 -0
  15. data/lib/docyard/components/code_detector.rb +59 -0
  16. data/lib/docyard/components/icon_detector.rb +57 -0
  17. data/lib/docyard/components/icon_processor.rb +51 -0
  18. data/lib/docyard/components/registry.rb +34 -0
  19. data/lib/docyard/components/table_wrapper_processor.rb +18 -0
  20. data/lib/docyard/components/tabs_parser.rb +60 -0
  21. data/lib/docyard/components/tabs_processor.rb +44 -0
  22. data/lib/docyard/config/validator.rb +171 -0
  23. data/lib/docyard/config.rb +135 -0
  24. data/lib/docyard/constants.rb +5 -0
  25. data/lib/docyard/icons/LICENSE.phosphor +21 -0
  26. data/lib/docyard/icons/file_types.rb +92 -0
  27. data/lib/docyard/icons/phosphor.rb +64 -0
  28. data/lib/docyard/icons.rb +40 -0
  29. data/lib/docyard/initializer.rb +93 -9
  30. data/lib/docyard/language_mapping.rb +52 -0
  31. data/lib/docyard/markdown.rb +27 -3
  32. data/lib/docyard/preview_server.rb +72 -0
  33. data/lib/docyard/rack_application.rb +77 -8
  34. data/lib/docyard/renderer.rb +56 -9
  35. data/lib/docyard/server.rb +5 -2
  36. data/lib/docyard/sidebar/config_parser.rb +180 -0
  37. data/lib/docyard/sidebar/item.rb +58 -0
  38. data/lib/docyard/sidebar/renderer.rb +33 -6
  39. data/lib/docyard/sidebar_builder.rb +54 -2
  40. data/lib/docyard/templates/assets/css/code.css +150 -2
  41. data/lib/docyard/templates/assets/css/components/callout.css +169 -0
  42. data/lib/docyard/templates/assets/css/components/code-block.css +196 -0
  43. data/lib/docyard/templates/assets/css/components/icon.css +16 -0
  44. data/lib/docyard/templates/assets/css/components/logo.css +44 -0
  45. data/lib/docyard/templates/assets/css/{components.css → components/navigation.css} +111 -53
  46. data/lib/docyard/templates/assets/css/components/tabs.css +299 -0
  47. data/lib/docyard/templates/assets/css/components/theme-toggle.css +69 -0
  48. data/lib/docyard/templates/assets/css/layout.css +14 -4
  49. data/lib/docyard/templates/assets/css/markdown.css +27 -17
  50. data/lib/docyard/templates/assets/css/reset.css +4 -0
  51. data/lib/docyard/templates/assets/css/variables.css +94 -3
  52. data/lib/docyard/templates/assets/favicon.svg +16 -0
  53. data/lib/docyard/templates/assets/js/components/code-block.js +162 -0
  54. data/lib/docyard/templates/assets/js/components/navigation.js +221 -0
  55. data/lib/docyard/templates/assets/js/components/tabs.js +338 -0
  56. data/lib/docyard/templates/assets/js/theme.js +12 -179
  57. data/lib/docyard/templates/assets/logo-dark.svg +4 -0
  58. data/lib/docyard/templates/assets/logo.svg +12 -0
  59. data/lib/docyard/templates/config/docyard.yml.erb +42 -0
  60. data/lib/docyard/templates/layouts/default.html.erb +32 -4
  61. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +46 -12
  62. data/lib/docyard/templates/markdown/guides/configuration.md.erb +202 -0
  63. data/lib/docyard/templates/markdown/guides/markdown-features.md.erb +247 -0
  64. data/lib/docyard/templates/markdown/index.md.erb +55 -59
  65. data/lib/docyard/templates/partials/_callout.html.erb +11 -0
  66. data/lib/docyard/templates/partials/_code_block.html.erb +6 -0
  67. data/lib/docyard/templates/partials/_icon.html.erb +1 -0
  68. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +1 -0
  69. data/lib/docyard/templates/partials/_nav_group.html.erb +10 -4
  70. data/lib/docyard/templates/partials/_nav_leaf.html.erb +9 -1
  71. data/lib/docyard/templates/partials/_tabs.html.erb +40 -0
  72. data/lib/docyard/templates/partials/_theme_toggle.html.erb +13 -0
  73. data/lib/docyard/version.rb +1 -1
  74. data/lib/docyard.rb +8 -0
  75. metadata +91 -7
  76. data/lib/docyard/templates/markdown/core-concepts/file-structure.md.erb +0 -61
  77. data/lib/docyard/templates/markdown/core-concepts/markdown.md.erb +0 -90
  78. data/lib/docyard/templates/markdown/getting-started/introduction.md.erb +0 -30
  79. data/lib/docyard/templates/markdown/getting-started/quick-start.md.erb +0 -56
  80. data/lib/docyard/templates/partials/_icons.html.erb +0 -11
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-progressbar"
4
+
5
+ module Docyard
6
+ module Build
7
+ class StaticGenerator
8
+ attr_reader :config, :verbose, :renderer
9
+
10
+ def initialize(config, verbose: false)
11
+ @config = config
12
+ @verbose = verbose
13
+ @renderer = Renderer.new(base_url: config.build.base_url)
14
+ end
15
+
16
+ def generate
17
+ markdown_files = collect_markdown_files
18
+ puts "\n[✓] Found #{markdown_files.size} markdown files"
19
+
20
+ progress = TTY::ProgressBar.new(
21
+ "Generating pages [:bar] :current/:total (:percent%)",
22
+ total: markdown_files.size,
23
+ width: 50
24
+ )
25
+
26
+ markdown_files.each do |file_path|
27
+ generate_page(file_path)
28
+ progress.advance
29
+ end
30
+
31
+ markdown_files.size
32
+ end
33
+
34
+ private
35
+
36
+ def collect_markdown_files
37
+ Dir.glob(File.join("docs", "**", "*.md"))
38
+ end
39
+
40
+ def generate_page(markdown_file_path)
41
+ output_path = determine_output_path(markdown_file_path)
42
+ current_path = determine_current_path(markdown_file_path)
43
+
44
+ sidebar_html = build_sidebar(current_path)
45
+ html_content = renderer.render_file(
46
+ markdown_file_path,
47
+ sidebar_html: sidebar_html,
48
+ branding: branding_options
49
+ )
50
+
51
+ FileUtils.mkdir_p(File.dirname(output_path))
52
+ File.write(output_path, html_content)
53
+
54
+ log "Generated: #{output_path}" if verbose
55
+ end
56
+
57
+ def determine_output_path(markdown_file_path)
58
+ relative_path = markdown_file_path.delete_prefix("docs/")
59
+ base_name = File.basename(relative_path, ".md")
60
+ dir_name = File.dirname(relative_path)
61
+
62
+ output_dir = config.build.output_dir
63
+
64
+ if base_name == "index"
65
+ File.join(output_dir, dir_name, "index.html")
66
+ else
67
+ File.join(output_dir, dir_name, base_name, "index.html")
68
+ end
69
+ end
70
+
71
+ def determine_current_path(markdown_file_path)
72
+ relative_path = markdown_file_path.delete_prefix("docs/")
73
+ base_name = File.basename(relative_path, ".md")
74
+ dir_name = File.dirname(relative_path)
75
+
76
+ if base_name == "index"
77
+ dir_name == "." ? "/" : "/#{dir_name}"
78
+ else
79
+ dir_name == "." ? "/#{base_name}" : "/#{dir_name}/#{base_name}"
80
+ end
81
+ end
82
+
83
+ def build_sidebar(current_path)
84
+ SidebarBuilder.new(
85
+ docs_path: "docs",
86
+ current_path: current_path,
87
+ config: config
88
+ ).to_html
89
+ end
90
+
91
+ def branding_options
92
+ default_branding.merge(config_branding_options)
93
+ end
94
+
95
+ def default_branding
96
+ {
97
+ site_title: Constants::DEFAULT_SITE_TITLE,
98
+ site_description: "",
99
+ logo: Constants::DEFAULT_LOGO_PATH,
100
+ logo_dark: Constants::DEFAULT_LOGO_DARK_PATH,
101
+ favicon: nil,
102
+ display_logo: true,
103
+ display_title: true
104
+ }
105
+ end
106
+
107
+ def config_branding_options
108
+ site = config.site
109
+ branding = config.branding
110
+
111
+ {
112
+ site_title: site.title || Constants::DEFAULT_SITE_TITLE,
113
+ site_description: site.description || "",
114
+ logo: resolve_logo(branding.logo, branding.logo_dark),
115
+ logo_dark: resolve_logo_dark(branding.logo, branding.logo_dark),
116
+ favicon: branding.favicon
117
+ }.merge(appearance_options(branding.appearance))
118
+ end
119
+
120
+ def appearance_options(appearance)
121
+ appearance ||= {}
122
+ {
123
+ display_logo: appearance["logo"] != false,
124
+ display_title: appearance["title"] != false
125
+ }
126
+ end
127
+
128
+ def resolve_logo(logo, logo_dark)
129
+ logo || logo_dark || Constants::DEFAULT_LOGO_PATH
130
+ end
131
+
132
+ def resolve_logo_dark(logo, logo_dark)
133
+ logo_dark || logo || Constants::DEFAULT_LOGO_DARK_PATH
134
+ end
135
+
136
+ def log(message)
137
+ puts message if verbose
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tty-progressbar"
5
+
6
+ module Docyard
7
+ class Builder
8
+ attr_reader :config, :clean, :verbose, :start_time
9
+
10
+ def initialize(clean: true, verbose: false)
11
+ @config = Config.new
12
+ @clean = clean
13
+ @verbose = verbose
14
+ @start_time = Time.now
15
+ end
16
+
17
+ def build
18
+ prepare_output_directory
19
+ log "Building static site..."
20
+
21
+ pages_built = generate_static_pages
22
+ bundles_created = bundle_assets
23
+ assets_copied = copy_static_files
24
+ generate_seo_files
25
+
26
+ display_summary(pages_built, bundles_created, assets_copied)
27
+ true
28
+ rescue StandardError => e
29
+ error "Build failed: #{e.message}"
30
+ error e.backtrace.first if verbose
31
+ false
32
+ end
33
+
34
+ private
35
+
36
+ def prepare_output_directory
37
+ output_dir = config.build.output_dir
38
+
39
+ if clean && Dir.exist?(output_dir)
40
+ log "[✓] Cleaning #{output_dir}/ directory"
41
+ FileUtils.rm_rf(output_dir)
42
+ end
43
+
44
+ FileUtils.mkdir_p(output_dir)
45
+ end
46
+
47
+ def generate_static_pages
48
+ require_relative "build/static_generator"
49
+ generator = Build::StaticGenerator.new(config, verbose: verbose)
50
+ generator.generate
51
+ end
52
+
53
+ def bundle_assets
54
+ require_relative "build/asset_bundler"
55
+ bundler = Build::AssetBundler.new(config, verbose: verbose)
56
+ bundler.bundle
57
+ end
58
+
59
+ def copy_static_files
60
+ require_relative "build/file_copier"
61
+ copier = Build::FileCopier.new(config, verbose: verbose)
62
+ copier.copy
63
+ end
64
+
65
+ def generate_seo_files
66
+ require_relative "build/sitemap_generator"
67
+ sitemap_gen = Build::SitemapGenerator.new(config)
68
+ sitemap_gen.generate
69
+
70
+ File.write(File.join(config.build.output_dir, "robots.txt"), robots_txt_content)
71
+ log "[✓] Generated robots.txt"
72
+ end
73
+
74
+ def robots_txt_content
75
+ base_url = config.build.base_url
76
+ base_url = "#{base_url}/" unless base_url.end_with?("/")
77
+
78
+ <<~ROBOTS
79
+ User-agent: *
80
+ Allow: /
81
+
82
+ Sitemap: #{base_url}sitemap.xml
83
+ ROBOTS
84
+ end
85
+
86
+ def display_summary(pages, bundles, assets)
87
+ elapsed = Time.now - start_time
88
+
89
+ puts "\n#{'=' * 50}"
90
+ puts "Build complete in #{format('%.2f', elapsed)}s"
91
+ puts "Output: #{config.build.output_dir}/"
92
+ puts "#{pages} pages, #{bundles} bundles, #{assets} static files"
93
+ puts "=" * 50
94
+ end
95
+
96
+ def log(message)
97
+ puts message
98
+ end
99
+
100
+ def error(message)
101
+ warn "[ERROR] #{message}"
102
+ end
103
+ end
104
+ end
data/lib/docyard/cli.rb CHANGED
@@ -19,6 +19,25 @@ module Docyard
19
19
  exit(1) unless initializer.run
20
20
  end
21
21
 
22
+ desc "build", "Build static site for production"
23
+ method_option :clean, type: :boolean, default: true, desc: "Clean output directory before building"
24
+ method_option :verbose, type: :boolean, default: false, aliases: "-v", desc: "Show verbose output"
25
+ def build
26
+ require_relative "builder"
27
+ builder = Docyard::Builder.new(
28
+ clean: options[:clean],
29
+ verbose: options[:verbose]
30
+ )
31
+ exit(1) unless builder.build
32
+ end
33
+
34
+ desc "preview", "Preview the built site locally"
35
+ method_option :port, type: :numeric, default: 4000, aliases: "-p", desc: "Port to run preview server on"
36
+ def preview
37
+ require_relative "preview_server"
38
+ Docyard::PreviewServer.new(port: options[:port]).start
39
+ end
40
+
22
41
  desc "serve", "Start the development server"
23
42
  method_option :port, type: :numeric, default: 4200, aliases: "-p", desc: "Port to run the server on"
24
43
  method_option :host, type: :string, default: "localhost", aliases: "-h", desc: "Host to bind the server to"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Components
5
+ class BaseProcessor
6
+ class << self
7
+ attr_accessor :priority
8
+
9
+ def inherited(subclass)
10
+ super
11
+ Registry.register(subclass)
12
+ end
13
+ end
14
+
15
+ def preprocess(content)
16
+ content
17
+ end
18
+
19
+ def postprocess(html)
20
+ html
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../icons"
4
+ require_relative "../renderer"
5
+ require_relative "base_processor"
6
+ require "kramdown"
7
+ require "kramdown-parser-gfm"
8
+
9
+ module Docyard
10
+ module Components
11
+ class CalloutProcessor < BaseProcessor
12
+ self.priority = 10
13
+
14
+ CALLOUT_TYPES = {
15
+ "note" => { title: "Note", icon: "info", color: "note" },
16
+ "tip" => { title: "Tip", icon: "lightbulb", color: "tip" },
17
+ "important" => { title: "Important", icon: "warning-circle", color: "important" },
18
+ "warning" => { title: "Warning", icon: "warning", color: "warning" },
19
+ "danger" => { title: "Danger", icon: "siren", color: "danger" }
20
+ }.freeze
21
+
22
+ GITHUB_ALERT_TYPES = {
23
+ "NOTE" => "note",
24
+ "TIP" => "tip",
25
+ "IMPORTANT" => "important",
26
+ "WARNING" => "warning",
27
+ "CAUTION" => "danger"
28
+ }.freeze
29
+
30
+ def preprocess(markdown)
31
+ process_container_syntax(markdown)
32
+ end
33
+
34
+ def postprocess(html)
35
+ process_github_alerts(html)
36
+ end
37
+
38
+ private
39
+
40
+ def process_container_syntax(markdown)
41
+ markdown.gsub(/^:::[ \t]*(\w+)(?:[ \t]+([^\n]+?))?[ \t]*\n(.*?)^:::[ \t]*$/m) do
42
+ process_callout_match(Regexp.last_match(0), Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3))
43
+ end
44
+ end
45
+
46
+ def process_callout_match(original_match, type_raw, custom_title, content_markdown)
47
+ type = type_raw.downcase
48
+ return original_match unless CALLOUT_TYPES.key?(type)
49
+
50
+ config = CALLOUT_TYPES[type]
51
+ title = determine_title(custom_title, config[:title])
52
+ content_html = render_markdown_content(content_markdown.strip)
53
+
54
+ wrap_in_nomarkdown(render_callout_html(type, title, content_html, config[:icon]))
55
+ end
56
+
57
+ def determine_title(custom_title, default_title)
58
+ title = custom_title&.strip
59
+ title.nil? || title.empty? ? default_title : title
60
+ end
61
+
62
+ def render_markdown_content(content_markdown)
63
+ return "" if content_markdown.empty?
64
+
65
+ Kramdown::Document.new(
66
+ content_markdown,
67
+ input: "GFM",
68
+ hard_wrap: false,
69
+ syntax_highlighter: "rouge"
70
+ ).to_html
71
+ end
72
+
73
+ def wrap_in_nomarkdown(html)
74
+ "{::nomarkdown}\n#{html}\n{:/nomarkdown}"
75
+ end
76
+
77
+ def process_github_alerts(html)
78
+ github_alert_regex = %r{
79
+ <blockquote>\s*
80
+ <p>\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*
81
+ (?:<br\s*/>)?\s*
82
+ (.*?)</p>
83
+ (.*?)
84
+ </blockquote>
85
+ }mx
86
+
87
+ html.gsub(github_alert_regex) do
88
+ process_github_alert_match(Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3))
89
+ end
90
+ end
91
+
92
+ def process_github_alert_match(alert_type, first_para, rest_content)
93
+ type = GITHUB_ALERT_TYPES[alert_type]
94
+ config = CALLOUT_TYPES[type]
95
+ content_html = combine_alert_content(first_para.strip, rest_content.strip)
96
+
97
+ render_callout_html(type, config[:title], content_html, config[:icon])
98
+ end
99
+
100
+ def combine_alert_content(first_para, rest_content)
101
+ return "<p>#{first_para}</p>" if rest_content.empty?
102
+
103
+ "<p>#{first_para}</p>#{rest_content}"
104
+ end
105
+
106
+ def render_callout_html(type, title, content_html, icon_name)
107
+ icon_svg = Icons.render(icon_name, "duotone") || ""
108
+ renderer = Renderer.new
109
+
110
+ renderer.render_partial(
111
+ "_callout", {
112
+ type: type,
113
+ title: title,
114
+ content_html: content_html,
115
+ icon_svg: icon_svg
116
+ }
117
+ )
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../icons"
4
+ require_relative "../renderer"
5
+ require_relative "base_processor"
6
+
7
+ module Docyard
8
+ module Components
9
+ class CodeBlockProcessor < BaseProcessor
10
+ self.priority = 20
11
+
12
+ def postprocess(html)
13
+ return html unless html.include?('<div class="highlight">')
14
+
15
+ html.gsub(%r{<div class="highlight">(.*?)</div>}m) do
16
+ process_code_block(Regexp.last_match(0), Regexp.last_match(1))
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def process_code_block(original_html, inner_html)
23
+ code_text = extract_code_text(inner_html)
24
+
25
+ render_code_block_with_copy(original_html, code_text)
26
+ end
27
+
28
+ def extract_code_text(html)
29
+ text = html.gsub(/<[^>]+>/, "")
30
+ text = CGI.unescapeHTML(text)
31
+ text.strip
32
+ end
33
+
34
+ def render_code_block_with_copy(code_block_html, code_text)
35
+ copy_icon = Icons.render("copy", "regular") || ""
36
+ renderer = Renderer.new
37
+
38
+ renderer.render_partial(
39
+ "_code_block", {
40
+ code_block_html: code_block_html,
41
+ code_text: escape_html_attribute(code_text),
42
+ copy_icon: copy_icon
43
+ }
44
+ )
45
+ end
46
+
47
+ def escape_html_attribute(text)
48
+ text.gsub('"', "&quot;")
49
+ .gsub("'", "&#39;")
50
+ .gsub("<", "&lt;")
51
+ .gsub(">", "&gt;")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../language_mapping"
4
+
5
+ module Docyard
6
+ module Components
7
+ class CodeDetector
8
+ def self.detect(content)
9
+ new(content).detect
10
+ end
11
+
12
+ def initialize(content)
13
+ @content = content
14
+ end
15
+
16
+ def detect
17
+ return nil unless code_only?
18
+
19
+ language = extract_language
20
+ return nil unless language
21
+
22
+ icon_for_language(language)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :content
28
+
29
+ def code_only?
30
+ stripped = content.strip
31
+ return false unless stripped.start_with?("```") && stripped.end_with?("```")
32
+
33
+ parts = stripped.split("```")
34
+ parts.length == 2 && parts[0].empty?
35
+ end
36
+
37
+ def extract_language
38
+ parts = content.strip.split("```")
39
+ return nil unless parts[1]
40
+
41
+ lines = parts[1].split("\n", 2)
42
+ lang_line = lines[0].strip
43
+ return nil if lang_line.empty? || lang_line.include?(" ")
44
+
45
+ lang_line.downcase
46
+ end
47
+
48
+ def icon_for_language(language)
49
+ if LanguageMapping.terminal_language?(language)
50
+ { icon: "terminal-window", source: "phosphor" }
51
+ elsif (extension = LanguageMapping.extension_for(language))
52
+ { icon: extension, source: "file-extension" }
53
+ else
54
+ { icon: "file", source: "phosphor" }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "code_detector"
4
+
5
+ module Docyard
6
+ module Components
7
+ class IconDetector
8
+ MANUAL_ICON_PATTERN = /^:([a-z0-9-]+):\s*(.+)$/i
9
+
10
+ def self.detect(tab_name, tab_content)
11
+ new(tab_name, tab_content).detect
12
+ end
13
+
14
+ def initialize(tab_name, tab_content)
15
+ @tab_name = tab_name
16
+ @tab_content = tab_content
17
+ end
18
+
19
+ def detect
20
+ manual_icon || auto_detected_icon || no_icon
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :tab_name, :tab_content
26
+
27
+ def manual_icon
28
+ return nil unless tab_name.match(MANUAL_ICON_PATTERN)
29
+
30
+ {
31
+ name: Regexp.last_match(2).strip,
32
+ icon: Regexp.last_match(1),
33
+ icon_source: "phosphor"
34
+ }
35
+ end
36
+
37
+ def auto_detected_icon
38
+ detected = CodeDetector.detect(tab_content)
39
+ return nil unless detected
40
+
41
+ {
42
+ name: tab_name,
43
+ icon: detected[:icon],
44
+ icon_source: detected[:source]
45
+ }
46
+ end
47
+
48
+ def no_icon
49
+ {
50
+ name: tab_name,
51
+ icon: nil,
52
+ icon_source: nil
53
+ }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../icons"
4
+ require_relative "base_processor"
5
+
6
+ module Docyard
7
+ module Components
8
+ class IconProcessor < BaseProcessor
9
+ self.priority = 20
10
+
11
+ ICON_PATTERN = /:([a-z][a-z0-9-]*):(?:([a-z]+):)?/i
12
+
13
+ def postprocess(html)
14
+ segments = split_preserving_code_blocks(html)
15
+
16
+ segments.map do |segment|
17
+ segment[:type] == :code ? segment[:content] : process_segment(segment[:content])
18
+ end.join
19
+ end
20
+
21
+ private
22
+
23
+ def split_preserving_code_blocks(html)
24
+ segments = []
25
+ current_pos = 0
26
+
27
+ html.scan(%r{<(code|pre)[^>]*>.*?</\1>}m) do
28
+ match_start = Regexp.last_match.begin(0)
29
+ match_end = Regexp.last_match.end(0)
30
+
31
+ segments << { type: :text, content: html[current_pos...match_start] } if match_start > current_pos
32
+ segments << { type: :code, content: html[match_start...match_end] }
33
+
34
+ current_pos = match_end
35
+ end
36
+
37
+ segments << { type: :text, content: html[current_pos..] } if current_pos < html.length
38
+
39
+ segments.empty? ? [{ type: :text, content: html }] : segments
40
+ end
41
+
42
+ def process_segment(content)
43
+ content.gsub(ICON_PATTERN) do
44
+ icon_name = Regexp.last_match(1)
45
+ weight = Regexp.last_match(2) || "regular"
46
+ Icons.render(icon_name, weight) || Regexp.last_match(0)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Components
5
+ class Registry
6
+ @processors = []
7
+
8
+ class << self
9
+ def register(processor_class)
10
+ @processors << processor_class
11
+ @processors.sort_by! { |p| p.priority || 100 }
12
+ end
13
+
14
+ def run_preprocessors(content)
15
+ @processors.reduce(content) do |processed_content, processor_class|
16
+ processor_class.new.preprocess(processed_content)
17
+ end
18
+ end
19
+
20
+ def run_postprocessors(html)
21
+ @processors.reduce(html) do |processed_html, processor_class|
22
+ processor_class.new.postprocess(processed_html)
23
+ end
24
+ end
25
+
26
+ def reset!
27
+ @processors = []
28
+ end
29
+
30
+ attr_reader :processors
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Components
5
+ class TableWrapperProcessor < BaseProcessor
6
+ self.priority = 100
7
+
8
+ def postprocess(html)
9
+ wrapped = html.gsub(/<table([^>]*)>/) do
10
+ attributes = Regexp.last_match(1)
11
+ "<div class=\"table-wrapper\"><table#{attributes}>"
12
+ end
13
+
14
+ wrapped.gsub("</table>", "</table></div>")
15
+ end
16
+ end
17
+ end
18
+ end