docyard 0.1.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/CHANGELOG.md +41 -1
  4. data/LICENSE.vscode-icons +42 -0
  5. data/README.md +57 -8
  6. data/lib/docyard/asset_handler.rb +33 -0
  7. data/lib/docyard/components/base_processor.rb +24 -0
  8. data/lib/docyard/components/callout_processor.rb +121 -0
  9. data/lib/docyard/components/code_block_processor.rb +55 -0
  10. data/lib/docyard/components/code_detector.rb +59 -0
  11. data/lib/docyard/components/icon_detector.rb +57 -0
  12. data/lib/docyard/components/icon_processor.rb +51 -0
  13. data/lib/docyard/components/registry.rb +34 -0
  14. data/lib/docyard/components/tabs_parser.rb +60 -0
  15. data/lib/docyard/components/tabs_processor.rb +44 -0
  16. data/lib/docyard/config/validator.rb +171 -0
  17. data/lib/docyard/config.rb +133 -0
  18. data/lib/docyard/constants.rb +28 -0
  19. data/lib/docyard/errors.rb +54 -0
  20. data/lib/docyard/file_watcher.rb +2 -2
  21. data/lib/docyard/icons/LICENSE.phosphor +21 -0
  22. data/lib/docyard/icons/file_types.rb +92 -0
  23. data/lib/docyard/icons/phosphor.rb +63 -0
  24. data/lib/docyard/icons.rb +40 -0
  25. data/lib/docyard/initializer.rb +27 -2
  26. data/lib/docyard/language_mapping.rb +52 -0
  27. data/lib/docyard/logging.rb +43 -0
  28. data/lib/docyard/markdown.rb +14 -3
  29. data/lib/docyard/rack_application.rb +100 -13
  30. data/lib/docyard/renderer.rb +40 -5
  31. data/lib/docyard/router.rb +13 -8
  32. data/lib/docyard/routing/resolution_result.rb +31 -0
  33. data/lib/docyard/server.rb +5 -2
  34. data/lib/docyard/sidebar/file_system_scanner.rb +77 -0
  35. data/lib/docyard/sidebar/renderer.rb +110 -0
  36. data/lib/docyard/sidebar/title_extractor.rb +25 -0
  37. data/lib/docyard/sidebar/tree_builder.rb +59 -0
  38. data/lib/docyard/sidebar_builder.rb +58 -0
  39. data/lib/docyard/templates/assets/css/code.css +362 -0
  40. data/lib/docyard/templates/assets/css/components/callout.css +169 -0
  41. data/lib/docyard/templates/assets/css/components/code-block.css +196 -0
  42. data/lib/docyard/templates/assets/css/components/icon.css +16 -0
  43. data/lib/docyard/templates/assets/css/components/logo.css +44 -0
  44. data/lib/docyard/templates/assets/css/components/navigation.css +258 -0
  45. data/lib/docyard/templates/assets/css/components/tabs.css +298 -0
  46. data/lib/docyard/templates/assets/css/components/theme-toggle.css +61 -0
  47. data/lib/docyard/templates/assets/css/layout.css +283 -0
  48. data/lib/docyard/templates/assets/css/main.css +10 -4
  49. data/lib/docyard/templates/assets/css/markdown.css +200 -0
  50. data/lib/docyard/templates/assets/css/reset.css +63 -0
  51. data/lib/docyard/templates/assets/css/typography.css +97 -0
  52. data/lib/docyard/templates/assets/css/variables.css +205 -0
  53. data/lib/docyard/templates/assets/favicon.svg +16 -0
  54. data/lib/docyard/templates/assets/js/components/code-block.js +162 -0
  55. data/lib/docyard/templates/assets/js/components/tabs.js +338 -0
  56. data/lib/docyard/templates/assets/js/theme.js +209 -1
  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 +20 -0
  60. data/lib/docyard/templates/layouts/default.html.erb +69 -19
  61. data/lib/docyard/templates/markdown/components/callouts.md.erb +204 -0
  62. data/lib/docyard/templates/markdown/components/icons.md.erb +125 -0
  63. data/lib/docyard/templates/markdown/components/tabs.md.erb +686 -0
  64. data/lib/docyard/templates/markdown/configuration.md.erb +202 -0
  65. data/lib/docyard/templates/markdown/core-concepts/file-structure.md.erb +61 -0
  66. data/lib/docyard/templates/markdown/core-concepts/markdown.md.erb +90 -0
  67. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +43 -0
  68. data/lib/docyard/templates/markdown/getting-started/introduction.md.erb +30 -0
  69. data/lib/docyard/templates/markdown/getting-started/quick-start.md.erb +56 -0
  70. data/lib/docyard/templates/markdown/index.md.erb +78 -14
  71. data/lib/docyard/templates/partials/_callout.html.erb +11 -0
  72. data/lib/docyard/templates/partials/_code_block.html.erb +6 -0
  73. data/lib/docyard/templates/partials/_icon.html.erb +1 -0
  74. data/lib/docyard/templates/partials/_icon_file_extension.html.erb +1 -0
  75. data/lib/docyard/templates/partials/_icons.html.erb +11 -0
  76. data/lib/docyard/templates/partials/_nav_group.html.erb +7 -0
  77. data/lib/docyard/templates/partials/_nav_item.html.erb +3 -0
  78. data/lib/docyard/templates/partials/_nav_leaf.html.erb +1 -0
  79. data/lib/docyard/templates/partials/_nav_list.html.erb +3 -0
  80. data/lib/docyard/templates/partials/_nav_section.html.erb +6 -0
  81. data/lib/docyard/templates/partials/_sidebar.html.erb +6 -0
  82. data/lib/docyard/templates/partials/_sidebar_footer.html.erb +11 -0
  83. data/lib/docyard/templates/partials/_tabs.html.erb +40 -0
  84. data/lib/docyard/templates/partials/_theme_toggle.html.erb +13 -0
  85. data/lib/docyard/utils/path_resolver.rb +30 -0
  86. data/lib/docyard/utils/text_formatter.rb +22 -0
  87. data/lib/docyard/version.rb +1 -1
  88. data/lib/docyard.rb +16 -4
  89. metadata +71 -3
  90. data/lib/docyard/templates/assets/css/syntax.css +0 -116
  91. data/lib/docyard/templates/markdown/getting-started.md.erb +0 -40
@@ -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
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../renderer"
4
+ require_relative "base_processor"
5
+ require_relative "tabs_parser"
6
+ require "securerandom"
7
+
8
+ module Docyard
9
+ module Components
10
+ class TabsProcessor < BaseProcessor
11
+ self.priority = 15
12
+
13
+ def preprocess(content)
14
+ return content unless content.include?(":::tabs")
15
+
16
+ content.gsub(/^:::[ \t]*tabs[ \t]*\n(.*?)^:::[ \t]*$/m) do
17
+ process_tabs_block(Regexp.last_match(1))
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def process_tabs_block(tabs_content)
24
+ tabs = TabsParser.parse(tabs_content)
25
+ return "" if tabs.empty?
26
+
27
+ wrap_in_nomarkdown(render_tabs(tabs))
28
+ end
29
+
30
+ def render_tabs(tabs)
31
+ Renderer.new.render_partial(
32
+ "_tabs", {
33
+ tabs: tabs,
34
+ group_id: SecureRandom.hex(4)
35
+ }
36
+ )
37
+ end
38
+
39
+ def wrap_in_nomarkdown(html)
40
+ "{::nomarkdown}\n#{html}\n{:/nomarkdown}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Config
5
+ class Validator
6
+ def initialize(config_data)
7
+ @config = config_data
8
+ @errors = []
9
+ end
10
+
11
+ def validate!
12
+ validate_site_section
13
+ validate_branding_section
14
+ validate_build_section
15
+
16
+ raise ConfigError, format_errors if @errors.any?
17
+ end
18
+
19
+ private
20
+
21
+ def validate_site_section
22
+ site = @config["site"]
23
+
24
+ validate_string(site["title"], "site.title")
25
+ validate_string(site["description"], "site.description")
26
+ end
27
+
28
+ def validate_branding_section
29
+ branding = @config["branding"]
30
+ return unless branding
31
+
32
+ validate_file_path_or_url(branding["logo"], "branding.logo")
33
+ validate_file_path_or_url(branding["logo_dark"], "branding.logo_dark")
34
+ validate_file_path_or_url(branding["favicon"], "branding.favicon")
35
+
36
+ appearance = branding["appearance"] || {}
37
+ validate_boolean(appearance["logo"], "branding.appearance.logo")
38
+ validate_boolean(appearance["title"], "branding.appearance.title")
39
+ end
40
+
41
+ def validate_build_section
42
+ build = @config["build"]
43
+
44
+ validate_string(build["output_dir"], "build.output_dir")
45
+ validate_no_slashes(build["output_dir"], "build.output_dir")
46
+ validate_string(build["base_url"], "build.base_url")
47
+ validate_starts_with_slash(build["base_url"], "build.base_url")
48
+ validate_boolean(build["clean"], "build.clean")
49
+ end
50
+
51
+ def validate_string(value, field_name)
52
+ return if value.nil?
53
+ return if value.is_a?(String)
54
+
55
+ add_error(
56
+ field: field_name,
57
+ error: "must be a string",
58
+ got: value.class.name,
59
+ fix: "Change to a string value"
60
+ )
61
+ end
62
+
63
+ def validate_boolean(value, field_name)
64
+ return if [true, false].include?(value)
65
+
66
+ add_error(
67
+ field: field_name,
68
+ error: "must be true or false",
69
+ got: value.inspect,
70
+ fix: "Change to true or false"
71
+ )
72
+ end
73
+
74
+ def validate_file_path(value, field_name)
75
+ return if value.nil?
76
+ return add_file_path_type_error(value, field_name) unless value.is_a?(String)
77
+
78
+ file_path = if File.absolute_path?(value)
79
+ value
80
+ else
81
+ File.join("docs", value)
82
+ end
83
+
84
+ return if File.exist?(file_path)
85
+
86
+ add_file_not_found_error(value, field_name)
87
+ end
88
+
89
+ def add_file_path_type_error(value, field_name)
90
+ add_error(
91
+ field: field_name,
92
+ error: "must be a file path (string)",
93
+ got: value.class.name,
94
+ fix: "Change to a string file path"
95
+ )
96
+ end
97
+
98
+ def add_file_not_found_error(value, field_name)
99
+ add_error(
100
+ field: field_name,
101
+ error: "file not found",
102
+ got: value,
103
+ fix: "Place the file in docs/ directory and use a relative path (e.g., 'assets/logo.svg')"
104
+ )
105
+ end
106
+
107
+ def validate_no_slashes(value, field_name)
108
+ return if value.nil?
109
+ return unless value.is_a?(String)
110
+ return unless value.include?("/") || value.include?("\\")
111
+
112
+ add_error(
113
+ field: field_name,
114
+ error: "cannot contain slashes",
115
+ got: value,
116
+ fix: "Use a simple directory name like 'dist' or '_site'"
117
+ )
118
+ end
119
+
120
+ def validate_starts_with_slash(value, field_name)
121
+ return if value.nil?
122
+ return if value.start_with?("/")
123
+
124
+ add_error(
125
+ field: field_name,
126
+ error: "must start with /",
127
+ got: value,
128
+ fix: "Change to '/#{value}'"
129
+ )
130
+ end
131
+
132
+ def validate_file_path_or_url(value, field_name)
133
+ return if value.nil?
134
+ return add_file_path_type_error(value, field_name) unless value.is_a?(String)
135
+
136
+ return if url?(value)
137
+
138
+ file_path = if File.absolute_path?(value)
139
+ value
140
+ else
141
+ File.join("docs", value)
142
+ end
143
+
144
+ return if File.exist?(file_path)
145
+
146
+ add_file_not_found_error(value, field_name)
147
+ end
148
+
149
+ def url?(value)
150
+ value.match?(%r{\Ahttps?://})
151
+ end
152
+
153
+ def add_error(error_data)
154
+ @errors << error_data
155
+ end
156
+
157
+ def format_errors
158
+ message = "Error in docyard.yml:\n\n"
159
+
160
+ @errors.each do |err|
161
+ message += " Field: #{err[:field]}\n"
162
+ message += " Error: #{err[:error]}\n"
163
+ message += " Got: #{err[:got]}\n"
164
+ message += " Fix: #{err[:fix]}\n\n"
165
+ end
166
+
167
+ message.chomp
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "config/validator"
5
+ require_relative "constants"
6
+
7
+ module Docyard
8
+ class Config
9
+ DEFAULT_CONFIG = {
10
+ "site" => {
11
+ "title" => Constants::DEFAULT_SITE_TITLE,
12
+ "description" => ""
13
+ },
14
+ "branding" => {
15
+ "logo" => nil,
16
+ "logo_dark" => nil,
17
+ "favicon" => nil,
18
+ "appearance" => {
19
+ "logo" => true,
20
+ "title" => true
21
+ }
22
+ },
23
+ "build" => {
24
+ "output_dir" => "dist",
25
+ "base_url" => "/",
26
+ "clean" => true
27
+ },
28
+ "sidebar" => nil
29
+ }.freeze
30
+
31
+ attr_reader :data, :file_path
32
+
33
+ def self.load(project_root = Dir.pwd)
34
+ new(project_root)
35
+ end
36
+
37
+ def initialize(project_root = Dir.pwd)
38
+ @project_root = project_root
39
+ @file_path = File.join(project_root, "docyard.yml")
40
+ @data = load_config_data
41
+ validate!
42
+ end
43
+
44
+ def file_exists?
45
+ File.exist?(file_path)
46
+ end
47
+
48
+ def site
49
+ @site ||= ConfigSection.new(data["site"])
50
+ end
51
+
52
+ def branding
53
+ @branding ||= ConfigSection.new(data["branding"])
54
+ end
55
+
56
+ def build
57
+ @build ||= ConfigSection.new(data["build"])
58
+ end
59
+
60
+ def sidebar
61
+ data["sidebar"]
62
+ end
63
+
64
+ private
65
+
66
+ def load_config_data
67
+ if file_exists?
68
+ load_and_merge_config
69
+ else
70
+ deep_dup(DEFAULT_CONFIG)
71
+ end
72
+ end
73
+
74
+ def load_and_merge_config
75
+ yaml_content = YAML.load_file(file_path)
76
+ deep_merge(deep_dup(DEFAULT_CONFIG), yaml_content || {})
77
+ rescue Psych::SyntaxError => e
78
+ raise ConfigError, build_yaml_error_message(e)
79
+ rescue StandardError => e
80
+ raise ConfigError, "Error loading docyard.yml: #{e.message}"
81
+ end
82
+
83
+ def deep_merge(hash1, hash2)
84
+ hash1.merge(hash2) do |_key, v1, v2|
85
+ if v2.nil?
86
+ v1
87
+ elsif v1.is_a?(Hash) && v2.is_a?(Hash)
88
+ deep_merge(v1, v2)
89
+ else
90
+ v2
91
+ end
92
+ end
93
+ end
94
+
95
+ def deep_dup(hash)
96
+ hash.transform_values { |value| value.is_a?(Hash) ? deep_dup(value) : value }
97
+ end
98
+
99
+ def build_yaml_error_message(error)
100
+ message = "Invalid YAML in docyard.yml:\n\n"
101
+ message += " #{error.message}\n\n"
102
+ message += "Fix: Check YAML syntax"
103
+ message += " at line #{error.line}" if error.respond_to?(:line)
104
+ message
105
+ end
106
+
107
+ def validate!
108
+ Validator.new(data).validate!
109
+ end
110
+ end
111
+
112
+ class ConfigSection
113
+ def initialize(data)
114
+ @data = data || {}
115
+ end
116
+
117
+ def appearance
118
+ @data["appearance"]
119
+ end
120
+
121
+ def method_missing(method, *args)
122
+ return @data[method.to_s] if args.empty?
123
+
124
+ super
125
+ end
126
+
127
+ def respond_to_missing?(method, include_private = false)
128
+ @data.key?(method.to_s) || super
129
+ end
130
+ end
131
+
132
+ class ConfigError < StandardError; end
133
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Constants
5
+ CONTENT_TYPE_HTML = "text/html; charset=utf-8"
6
+ CONTENT_TYPE_JSON = "application/json; charset=utf-8"
7
+ CONTENT_TYPE_CSS = "text/css; charset=utf-8"
8
+ CONTENT_TYPE_JS = "application/javascript; charset=utf-8"
9
+
10
+ RELOAD_ENDPOINT = "/_docyard/reload"
11
+ ASSETS_PREFIX = "/assets/"
12
+
13
+ INDEX_FILE = "index"
14
+ INDEX_TITLE = "Home"
15
+
16
+ MARKDOWN_EXTENSION = ".md"
17
+ HTML_EXTENSION = ".html"
18
+
19
+ STATUS_OK = 200
20
+ STATUS_NOT_FOUND = 404
21
+ STATUS_INTERNAL_ERROR = 500
22
+
23
+ DEFAULT_SITE_TITLE = "Documentation"
24
+ DEFAULT_LOGO_PATH = "assets/logo.svg"
25
+ DEFAULT_LOGO_DARK_PATH = "assets/logo-dark.svg"
26
+ DEFAULT_FAVICON_PATH = "assets/favicon.svg"
27
+ end
28
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ class Error < StandardError; end
5
+
6
+ class FileNotFoundError < Error
7
+ attr_reader :path
8
+
9
+ def initialize(path)
10
+ @path = path
11
+ super("File not found: #{path}")
12
+ end
13
+ end
14
+
15
+ class InvalidPathError < Error; end
16
+
17
+ class MarkdownParseError < Error
18
+ attr_reader :file_path, :original_error
19
+
20
+ def initialize(file_path, original_error)
21
+ @file_path = file_path
22
+ @original_error = original_error
23
+ super("Failed to parse markdown file #{file_path}: #{original_error.message}")
24
+ end
25
+ end
26
+
27
+ class TemplateRenderError < Error
28
+ attr_reader :template_path, :original_error
29
+
30
+ def initialize(template_path, original_error)
31
+ @template_path = template_path
32
+ @original_error = original_error
33
+ super("Failed to render template #{template_path}: #{original_error.message}")
34
+ end
35
+ end
36
+
37
+ class ReloadCheckError < Error
38
+ attr_reader :original_error
39
+
40
+ def initialize(original_error)
41
+ @original_error = original_error
42
+ super("Reload check failed: #{original_error.message}")
43
+ end
44
+ end
45
+
46
+ class AssetNotFoundError < Error
47
+ attr_reader :asset_path
48
+
49
+ def initialize(asset_path)
50
+ @asset_path = asset_path
51
+ super("Asset not found: #{asset_path}")
52
+ end
53
+ end
54
+ end
@@ -23,7 +23,7 @@ module Docyard
23
23
  def stop
24
24
  @listener&.stop
25
25
  rescue StandardError => e
26
- puts "[Docyard] Error stopping file watcher: #{e.class} - #{e.message}"
26
+ Docyard.logger.error "Error stopping file watcher: #{e.class} - #{e.message}"
27
27
  end
28
28
 
29
29
  def changed_since?(timestamp)
@@ -36,7 +36,7 @@ module Docyard
36
36
  return if modified.empty? && added.empty? && removed.empty?
37
37
 
38
38
  @last_modified_time = Time.now
39
- puts "[Docyard] Files changed, triggering reload..."
39
+ Docyard.logger.info "Files changed, triggering reload..."
40
40
  end
41
41
  end
42
42
  end
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020-2023 Phosphor Icons
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.