docyard 0.2.0 → 0.3.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -1
  3. data/LICENSE.vscode-icons +42 -0
  4. data/README.md +46 -5
  5. data/lib/docyard/asset_handler.rb +33 -0
  6. data/lib/docyard/components/base_processor.rb +24 -0
  7. data/lib/docyard/components/callout_processor.rb +121 -0
  8. data/lib/docyard/components/code_block_processor.rb +55 -0
  9. data/lib/docyard/components/code_detector.rb +59 -0
  10. data/lib/docyard/components/icon_detector.rb +57 -0
  11. data/lib/docyard/components/icon_processor.rb +51 -0
  12. data/lib/docyard/components/registry.rb +34 -0
  13. data/lib/docyard/components/tabs_parser.rb +60 -0
  14. data/lib/docyard/components/tabs_processor.rb +44 -0
  15. data/lib/docyard/config/validator.rb +171 -0
  16. data/lib/docyard/config.rb +133 -0
  17. data/lib/docyard/constants.rb +5 -0
  18. data/lib/docyard/icons/LICENSE.phosphor +21 -0
  19. data/lib/docyard/icons/file_types.rb +92 -0
  20. data/lib/docyard/icons/phosphor.rb +63 -0
  21. data/lib/docyard/icons.rb +40 -0
  22. data/lib/docyard/initializer.rb +20 -2
  23. data/lib/docyard/language_mapping.rb +52 -0
  24. data/lib/docyard/markdown.rb +14 -3
  25. data/lib/docyard/rack_application.rb +76 -7
  26. data/lib/docyard/renderer.rb +40 -7
  27. data/lib/docyard/server.rb +5 -2
  28. data/lib/docyard/sidebar_builder.rb +10 -2
  29. data/lib/docyard/templates/assets/css/code.css +150 -2
  30. data/lib/docyard/templates/assets/css/components/callout.css +169 -0
  31. data/lib/docyard/templates/assets/css/components/code-block.css +196 -0
  32. data/lib/docyard/templates/assets/css/components/icon.css +16 -0
  33. data/lib/docyard/templates/assets/css/components/logo.css +44 -0
  34. data/lib/docyard/templates/assets/css/{components.css → components/navigation.css} +47 -47
  35. data/lib/docyard/templates/assets/css/components/tabs.css +298 -0
  36. data/lib/docyard/templates/assets/css/components/theme-toggle.css +61 -0
  37. data/lib/docyard/templates/assets/css/layout.css +14 -4
  38. data/lib/docyard/templates/assets/css/markdown.css +9 -8
  39. data/lib/docyard/templates/assets/css/reset.css +4 -0
  40. data/lib/docyard/templates/assets/css/variables.css +94 -3
  41. data/lib/docyard/templates/assets/favicon.svg +16 -0
  42. data/lib/docyard/templates/assets/js/components/code-block.js +162 -0
  43. data/lib/docyard/templates/assets/js/components/tabs.js +338 -0
  44. data/lib/docyard/templates/assets/js/theme.js +16 -0
  45. data/lib/docyard/templates/assets/logo-dark.svg +4 -0
  46. data/lib/docyard/templates/assets/logo.svg +12 -0
  47. data/lib/docyard/templates/config/docyard.yml.erb +20 -0
  48. data/lib/docyard/templates/layouts/default.html.erb +31 -3
  49. data/lib/docyard/templates/markdown/components/callouts.md.erb +204 -0
  50. data/lib/docyard/templates/markdown/components/icons.md.erb +125 -0
  51. data/lib/docyard/templates/markdown/components/tabs.md.erb +686 -0
  52. data/lib/docyard/templates/markdown/configuration.md.erb +202 -0
  53. data/lib/docyard/templates/partials/_callout.html.erb +11 -0
  54. data/lib/docyard/templates/partials/_code_block.html.erb +6 -0
  55. data/lib/docyard/templates/partials/_icon.html.erb +1 -0
  56. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +1 -0
  57. data/lib/docyard/templates/partials/_tabs.html.erb +40 -0
  58. data/lib/docyard/templates/partials/_theme_toggle.html.erb +13 -0
  59. data/lib/docyard/version.rb +1 -1
  60. metadata +41 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0614d285d89d4f529607c5e4bdb50c823cce769a5628b90100234c9513f98690
4
- data.tar.gz: 2f7c134bca20f17766e54fb4bc3b02b602e735cf4d8c7e11f83f9158773bec22
3
+ metadata.gz: eab96fe7bde5b0471a5a60996f818f5909d159e34a96034506e5095730e64988
4
+ data.tar.gz: 319e4251a18b40c28960c2854079420feac035e472bf9f5f03845cccfc310879
5
5
  SHA512:
6
- metadata.gz: 1259470e5245ff3ffc8796144a1e48fae46d851b44ca2c6cbeda0e8f83c782ebb815cfdfe0bdb3fa36fc25744a050859df44e5451376c8dadb4601ad9c3e2e98
7
- data.tar.gz: 90281b4af9dd20425d26d6ea14214de5a79237ddeec9038546b9142819e363051850c64942a1b56459e5e8e1c509d08a36b323c2e01e3bacd4353fc39755915a
6
+ metadata.gz: 25c8f7d91d19d3905141894e7cf8f2968b7d7d20343f9fda48e5f105f952ba3f627227063a888dd70b7c17187950ac83e649196afbbde215e643e5f723157c1f
7
+ data.tar.gz: 338baa09ab053ffba20f745263f6bed124276ce707d8b2adc4d96ebf0a18b0b195f7097edfba8ffcde9265abe7cda3eaefd6710f01e3bf71b3836b87c21eb429
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2025-01-09
11
+
12
+ ### Added
13
+ - Configuration system with optional `docyard.yml` file (#20)
14
+ - Logo and favicon support with light/dark mode switching (#21)
15
+ - Dark mode with theme toggle and system preference detection (#14)
16
+ - Icon system with 24 Phosphor icons and `:icon:` syntax (#15)
17
+ - Callouts/Admonitions with 5 types (note, tip, important, warning, danger) (#16)
18
+ - Tabs component with keyboard navigation and icon auto-detection (#17, #18)
19
+ - Copy button for code blocks with visual feedback (#19)
20
+ - Component-based architecture with processors for extensibility
21
+ - Asset handler with dynamic concatenation of component files
22
+
23
+ ### Changed
24
+ - CSS architecture now uses CSS variables for comprehensive theming
25
+ - Markdown processing enhanced with preprocessor/postprocessor pattern
26
+
10
27
  ## [0.2.0] - 2025-11-08
11
28
 
12
29
  ### Added
@@ -46,7 +63,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
46
63
  - Initial gem structure
47
64
  - Project scaffolding
48
65
 
49
- [Unreleased]: https://github.com/sanifhimani/docyard/compare/v0.2.0...HEAD
66
+ [Unreleased]: https://github.com/sanifhimani/docyard/compare/v0.3.0...HEAD
67
+ [0.3.0]: https://github.com/sanifhimani/docyard/compare/v0.2.0...v0.3.0
50
68
  [0.2.0]: https://github.com/sanifhimani/docyard/compare/v0.1.0...v0.2.0
51
69
  [0.1.0]: https://github.com/sanifhimani/docyard/compare/v0.0.1...v0.1.0
52
70
  [0.0.1]: https://github.com/sanifhimani/docyard/releases/tag/v0.0.1
@@ -0,0 +1,42 @@
1
+ File Type Icons from VSCode Icons
2
+ =====================================
3
+
4
+ The file type icons embedded in lib/docyard/icons/file_types.rb are from the VSCode Icons project:
5
+ https://github.com/vscode-icons/vscode-icons
6
+
7
+ License: Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)
8
+ https://creativecommons.org/licenses/by-sa/4.0/
9
+
10
+ Copyright (c) 2016 Roberto Huertas
11
+
12
+ Icons included:
13
+ - JavaScript (.js)
14
+ - TypeScript (.ts)
15
+ - JSX (.jsx)
16
+ - TSX (.tsx)
17
+ - Python (.py)
18
+ - Ruby (.rb)
19
+ - HTML (.html)
20
+ - CSS (.css)
21
+ - JSON (.json)
22
+ - YAML (.yaml)
23
+ - TOML (.toml)
24
+ - Go (.go)
25
+ - Rust (.rs)
26
+ - PHP (.php)
27
+ - SQL (.sql)
28
+ - MySQL (.mysql)
29
+ - PostgreSQL (.pgsql)
30
+ - GraphQL (.graphql)
31
+ - Vue (.vue)
32
+ - Svelte (.svelte)
33
+ - Protobuf (.proto)
34
+
35
+ These icons are used under the terms of the CC BY-SA 4.0 license.
36
+ You are free to:
37
+ - Share — copy and redistribute the material in any medium or format
38
+ - Adapt — remix, transform, and build upon the material for any purpose, even commercially
39
+
40
+ Under the following terms:
41
+ - Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made
42
+ - ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license
data/README.md CHANGED
@@ -5,14 +5,22 @@
5
5
 
6
6
  > Documentation generator for Ruby
7
7
 
8
- **Early development** - Core features work, but missing search and build command. See [roadmap](#roadmap).
8
+ **Early development** - Core features and components work, but missing search and build command. See [roadmap](#roadmap).
9
9
 
10
10
  ## Features
11
11
 
12
+ - **Configuration system** - Optional `docyard.yml` for site metadata, branding, and build settings
13
+ - **Dark mode** - Beautiful light/dark theme with system preference detection
12
14
  - **Sidebar navigation** - Automatic sidebar with nested folders and collapsible sections
13
15
  - **Hot reload** - Changes appear instantly while you write
14
16
  - **GitHub Flavored Markdown** - Tables, task lists, strikethrough
15
17
  - **Syntax highlighting** - 100+ languages via Rouge
18
+ - **Markdown components**:
19
+ - **Callouts** - 5 types (note, tip, important, warning, danger) with GitHub alerts syntax
20
+ - **Tabs** - Code blocks, package managers, and custom tabs with keyboard navigation
21
+ - **Icons** - 24 Phosphor icons with `:icon:` syntax
22
+ - **Code block enhancements** - Copy button with visual feedback
23
+ - **Custom branding** - Logo and favicon with light/dark mode support
16
24
  - **YAML frontmatter** - Add metadata to your pages
17
25
  - **Customizable error pages** - Make 404/500 pages your own
18
26
 
@@ -119,6 +127,33 @@ Write links with `.md` extension, they'll be automatically cleaned:
119
127
  [Guide](./guide/index.md) → /guide
120
128
  ```
121
129
 
130
+ ### Using Icons
131
+
132
+ Docyard includes 24 essential Phosphor icons that work out of the box. Just type `:icon-name:` in your markdown:
133
+
134
+ ```markdown
135
+ :check: Zero configuration
136
+ :lightning: Hot reload
137
+ :rocket-launch: Fast and lightweight
138
+
139
+ Use different weights:
140
+ :heart: → regular weight (default)
141
+ :heart:bold: → bold weight
142
+ :heart:fill: → filled version
143
+ ```
144
+
145
+ Available icons: `heart`, `check`, `x`, `warning`, `info`, `question`, `arrow-right`, `arrow-left`, `arrow-up`, `arrow-down`, `code`, `terminal`, `package`, `rocket-launch`, `star`, `lightning`, `moon-stars`, `sun`, `link-external`, `copy`, `github`, `file`, `terminal-window`, `warning-circle`.
146
+
147
+ Weights: `regular` (default), `bold`, `fill`, `light`, `thin`, `duotone`
148
+
149
+ Icons automatically match your text size and color.
150
+
151
+ **Adding new icons:**
152
+
153
+ 1. Get the SVG path from [phosphoricons.com](https://phosphoricons.com)
154
+ 2. Add to `lib/docyard/icons/phosphor.rb` under the appropriate weight
155
+ 3. Format: `"icon-name" => '<path d="..."/>',`
156
+
122
157
  ### Directory Structure
123
158
 
124
159
  ```
@@ -170,11 +205,17 @@ bundle exec rubocop
170
205
 
171
206
  ## Roadmap
172
207
 
173
- **Next up:**
208
+ **v0.3.0 - Recently shipped:**
209
+ - Configuration system (docyard.yml)
210
+ - Logo and favicon support
174
211
  - Dark mode with theme toggle
175
- - Markdown components (callouts, code groups, collapsible sections)
176
- - Icon system
177
- - Client-side search
212
+ - Icon system (24 Phosphor icons)
213
+ - Callouts/Admonitions
214
+ - Tabs component
215
+ - Copy button for code blocks
216
+
217
+ **Next up (v0.4.0):**
218
+ - Sidebar customization
178
219
  - Static site generation (`docyard build`)
179
220
 
180
221
  ## Contributing
@@ -3,6 +3,7 @@
3
3
  module Docyard
4
4
  class AssetHandler
5
5
  ASSETS_PATH = File.join(__dir__, "templates", "assets")
6
+ USER_ASSETS_PATH = "docs/assets"
6
7
 
7
8
  CONTENT_TYPES = {
8
9
  ".css" => "text/css; charset=utf-8",
@@ -21,6 +22,9 @@ module Docyard
21
22
 
22
23
  return forbidden_response if directory_traversal?(asset_path)
23
24
 
25
+ return serve_components_css if asset_path == "css/components.css"
26
+ return serve_components_js if asset_path == "js/components.js"
27
+
24
28
  file_path = build_file_path(asset_path)
25
29
  return not_found_response unless File.file?(file_path)
26
30
 
@@ -38,6 +42,9 @@ module Docyard
38
42
  end
39
43
 
40
44
  def build_file_path(asset_path)
45
+ user_path = File.join(USER_ASSETS_PATH, asset_path)
46
+ return user_path if File.file?(user_path)
47
+
41
48
  File.join(ASSETS_PATH, asset_path)
42
49
  end
43
50
 
@@ -48,6 +55,32 @@ module Docyard
48
55
  [200, { "Content-Type" => content_type }, [content]]
49
56
  end
50
57
 
58
+ def serve_components_css
59
+ content = concatenate_component_css
60
+ [200, { "Content-Type" => "text/css; charset=utf-8" }, [content]]
61
+ end
62
+
63
+ def concatenate_component_css
64
+ components_dir = File.join(ASSETS_PATH, "css", "components")
65
+ return "" unless Dir.exist?(components_dir)
66
+
67
+ css_files = Dir.glob(File.join(components_dir, "*.css"))
68
+ css_files.map { |file| File.read(file) }.join("\n\n")
69
+ end
70
+
71
+ def serve_components_js
72
+ content = concatenate_component_js
73
+ [200, { "Content-Type" => "application/javascript; charset=utf-8" }, [content]]
74
+ end
75
+
76
+ def concatenate_component_js
77
+ components_dir = File.join(ASSETS_PATH, "js", "components")
78
+ return "" unless Dir.exist?(components_dir)
79
+
80
+ js_files = Dir.glob(File.join(components_dir, "*.js"))
81
+ js_files.map { |file| File.read(file) }.join("\n\n")
82
+ end
83
+
51
84
  def detect_content_type(file_path)
52
85
  extension = File.extname(file_path)
53
86
  CONTENT_TYPES.fetch(extension, "application/octet-stream")
@@ -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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "icon_detector"
4
+ require "kramdown"
5
+ require "kramdown-parser-gfm"
6
+
7
+ module Docyard
8
+ module Components
9
+ class TabsParser
10
+ def self.parse(content)
11
+ new(content).parse
12
+ end
13
+
14
+ def initialize(content)
15
+ @content = content
16
+ end
17
+
18
+ def parse
19
+ sections.filter_map { |section| parse_section(section) }
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :content
25
+
26
+ def sections
27
+ content.split(/^==[ \t]+/)
28
+ end
29
+
30
+ def parse_section(section)
31
+ return nil if section.strip.empty?
32
+
33
+ parts = section.split("\n", 2)
34
+ tab_name = parts[0]&.strip
35
+ return nil if tab_name.nil? || tab_name.empty?
36
+
37
+ tab_content = parts[1]&.strip || ""
38
+ icon_data = IconDetector.detect(tab_name, tab_content)
39
+
40
+ {
41
+ name: icon_data[:name],
42
+ content: render_markdown(tab_content),
43
+ icon: icon_data[:icon],
44
+ icon_source: icon_data[:icon_source]
45
+ }
46
+ end
47
+
48
+ def render_markdown(markdown_content)
49
+ return "" if markdown_content.empty?
50
+
51
+ Kramdown::Document.new(
52
+ markdown_content,
53
+ input: "GFM",
54
+ hard_wrap: false,
55
+ syntax_highlighter: "rouge"
56
+ ).to_html
57
+ end
58
+ end
59
+ end
60
+ end