docyard 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +23 -1
- data/README.md +16 -8
- data/lib/docyard/constants.rb +23 -0
- data/lib/docyard/errors.rb +54 -0
- data/lib/docyard/file_watcher.rb +2 -2
- data/lib/docyard/initializer.rb +9 -2
- data/lib/docyard/logging.rb +43 -0
- data/lib/docyard/rack_application.rb +29 -11
- data/lib/docyard/renderer.rb +5 -3
- data/lib/docyard/router.rb +13 -8
- data/lib/docyard/routing/resolution_result.rb +31 -0
- data/lib/docyard/sidebar/file_system_scanner.rb +77 -0
- data/lib/docyard/sidebar/renderer.rb +110 -0
- data/lib/docyard/sidebar/title_extractor.rb +25 -0
- data/lib/docyard/sidebar/tree_builder.rb +59 -0
- data/lib/docyard/sidebar_builder.rb +50 -0
- data/lib/docyard/templates/assets/css/code.css +214 -0
- data/lib/docyard/templates/assets/css/components.css +258 -0
- data/lib/docyard/templates/assets/css/layout.css +273 -0
- data/lib/docyard/templates/assets/css/main.css +10 -4
- data/lib/docyard/templates/assets/css/markdown.css +199 -0
- data/lib/docyard/templates/assets/css/reset.css +59 -0
- data/lib/docyard/templates/assets/css/typography.css +97 -0
- data/lib/docyard/templates/assets/css/variables.css +114 -0
- data/lib/docyard/templates/assets/js/theme.js +193 -1
- data/lib/docyard/templates/layouts/default.html.erb +41 -19
- data/lib/docyard/templates/markdown/core-concepts/file-structure.md.erb +61 -0
- data/lib/docyard/templates/markdown/core-concepts/markdown.md.erb +90 -0
- data/lib/docyard/templates/markdown/getting-started/installation.md.erb +43 -0
- data/lib/docyard/templates/markdown/getting-started/introduction.md.erb +30 -0
- data/lib/docyard/templates/markdown/getting-started/quick-start.md.erb +56 -0
- data/lib/docyard/templates/markdown/index.md.erb +78 -14
- data/lib/docyard/templates/partials/_icons.html.erb +11 -0
- data/lib/docyard/templates/partials/_nav_group.html.erb +7 -0
- data/lib/docyard/templates/partials/_nav_item.html.erb +3 -0
- data/lib/docyard/templates/partials/_nav_leaf.html.erb +1 -0
- data/lib/docyard/templates/partials/_nav_list.html.erb +3 -0
- data/lib/docyard/templates/partials/_nav_section.html.erb +6 -0
- data/lib/docyard/templates/partials/_sidebar.html.erb +6 -0
- data/lib/docyard/templates/partials/_sidebar_footer.html.erb +11 -0
- data/lib/docyard/utils/path_resolver.rb +30 -0
- data/lib/docyard/utils/text_formatter.rb +22 -0
- data/lib/docyard/version.rb +1 -1
- data/lib/docyard.rb +16 -4
- metadata +32 -3
- data/lib/docyard/templates/assets/css/syntax.css +0 -116
- data/lib/docyard/templates/markdown/getting-started.md.erb +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0614d285d89d4f529607c5e4bdb50c823cce769a5628b90100234c9513f98690
|
|
4
|
+
data.tar.gz: 2f7c134bca20f17766e54fb4bc3b02b602e735cf4d8c7e11f83f9158773bec22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1259470e5245ff3ffc8796144a1e48fae46d851b44ca2c6cbeda0e8f83c782ebb815cfdfe0bdb3fa36fc25744a050859df44e5451376c8dadb4601ad9c3e2e98
|
|
7
|
+
data.tar.gz: 90281b4af9dd20425d26d6ea14214de5a79237ddeec9038546b9142819e363051850c64942a1b56459e5e8e1c509d08a36b323c2e01e3bacd4353fc39755915a
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2025-11-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Automatic sidebar navigation with nested folders
|
|
14
|
+
- Collapsible sidebar sections with active page highlighting
|
|
15
|
+
- Sidebar scroll position persistence
|
|
16
|
+
- Modern responsive theme with mobile hamburger menu
|
|
17
|
+
- Structured logging system with configurable levels
|
|
18
|
+
- Custom error classes for better error handling
|
|
19
|
+
- Constants module for shared application values
|
|
20
|
+
- Utility modules for path resolution and text formatting
|
|
21
|
+
- Improved initial templates with nested docs structure
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Refactored sidebar rendering to use partial templates
|
|
25
|
+
- Modular CSS architecture (split into variables, reset, typography, components, layout)
|
|
26
|
+
- Enhanced router with routing resolution result pattern
|
|
27
|
+
|
|
28
|
+
### Removed
|
|
29
|
+
- Legacy syntax.css in favor of modular code.css
|
|
30
|
+
|
|
10
31
|
## [0.1.0] - 2025-11-04
|
|
11
32
|
|
|
12
33
|
### Added
|
|
@@ -25,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
25
46
|
- Initial gem structure
|
|
26
47
|
- Project scaffolding
|
|
27
48
|
|
|
28
|
-
[Unreleased]: https://github.com/sanifhimani/docyard/compare/v0.
|
|
49
|
+
[Unreleased]: https://github.com/sanifhimani/docyard/compare/v0.2.0...HEAD
|
|
50
|
+
[0.2.0]: https://github.com/sanifhimani/docyard/compare/v0.1.0...v0.2.0
|
|
29
51
|
[0.1.0]: https://github.com/sanifhimani/docyard/compare/v0.0.1...v0.1.0
|
|
30
52
|
[0.0.1]: https://github.com/sanifhimani/docyard/releases/tag/v0.0.1
|
data/README.md
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
> Documentation generator for Ruby
|
|
7
7
|
|
|
8
|
-
**Early development** - Core features work, but missing
|
|
8
|
+
**Early development** - Core features work, but missing search and build command. See [roadmap](#roadmap).
|
|
9
9
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
12
|
+
- **Sidebar navigation** - Automatic sidebar with nested folders and collapsible sections
|
|
12
13
|
- **Hot reload** - Changes appear instantly while you write
|
|
13
14
|
- **GitHub Flavored Markdown** - Tables, task lists, strikethrough
|
|
14
15
|
- **Syntax highlighting** - 100+ languages via Rouge
|
|
@@ -56,8 +57,14 @@ docyard init
|
|
|
56
57
|
This creates:
|
|
57
58
|
```
|
|
58
59
|
docs/
|
|
59
|
-
index.md
|
|
60
|
-
getting-started
|
|
60
|
+
index.md # Home page
|
|
61
|
+
getting-started/
|
|
62
|
+
introduction.md # Getting started guide
|
|
63
|
+
installation.md # Installation instructions
|
|
64
|
+
quick-start.md # Quick start guide
|
|
65
|
+
core-concepts/
|
|
66
|
+
file-structure.md # File structure guide
|
|
67
|
+
markdown.md # Markdown guide
|
|
61
68
|
```
|
|
62
69
|
|
|
63
70
|
### Start Development Server
|
|
@@ -163,11 +170,12 @@ bundle exec rubocop
|
|
|
163
170
|
|
|
164
171
|
## Roadmap
|
|
165
172
|
|
|
166
|
-
|
|
167
|
-
-
|
|
168
|
-
-
|
|
169
|
-
-
|
|
170
|
-
-
|
|
173
|
+
**Next up:**
|
|
174
|
+
- Dark mode with theme toggle
|
|
175
|
+
- Markdown components (callouts, code groups, collapsible sections)
|
|
176
|
+
- Icon system
|
|
177
|
+
- Client-side search
|
|
178
|
+
- Static site generation (`docyard build`)
|
|
171
179
|
|
|
172
180
|
## Contributing
|
|
173
181
|
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
end
|
|
23
|
+
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
|
data/lib/docyard/file_watcher.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Docyard
|
|
|
23
23
|
def stop
|
|
24
24
|
@listener&.stop
|
|
25
25
|
rescue StandardError => e
|
|
26
|
-
|
|
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
|
-
|
|
39
|
+
Docyard.logger.info "Files changed, triggering reload..."
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
end
|
data/lib/docyard/initializer.rb
CHANGED
|
@@ -9,7 +9,11 @@ module Docyard
|
|
|
9
9
|
|
|
10
10
|
TEMPLATES = {
|
|
11
11
|
"index.md" => "index.md.erb",
|
|
12
|
-
"getting-started.md" => "getting-started.md.erb"
|
|
12
|
+
"getting-started/introduction.md" => "getting-started/introduction.md.erb",
|
|
13
|
+
"getting-started/installation.md" => "getting-started/installation.md.erb",
|
|
14
|
+
"getting-started/quick-start.md" => "getting-started/quick-start.md.erb",
|
|
15
|
+
"core-concepts/file-structure.md" => "core-concepts/file-structure.md.erb",
|
|
16
|
+
"core-concepts/markdown.md" => "core-concepts/markdown.md.erb"
|
|
13
17
|
}.freeze
|
|
14
18
|
|
|
15
19
|
def initialize(path = ".")
|
|
@@ -46,6 +50,9 @@ module Docyard
|
|
|
46
50
|
template_path = File.join(TEMPLATE_DIR, template_name)
|
|
47
51
|
output_path = File.join(@docs_path, output_name)
|
|
48
52
|
|
|
53
|
+
output_dir = File.dirname(output_path)
|
|
54
|
+
FileUtils.mkdir_p(output_dir) unless File.directory?(output_dir)
|
|
55
|
+
|
|
49
56
|
content = File.read(template_path)
|
|
50
57
|
File.write(output_path, content)
|
|
51
58
|
end
|
|
@@ -63,7 +70,7 @@ module Docyard
|
|
|
63
70
|
puts ""
|
|
64
71
|
puts "Next steps:"
|
|
65
72
|
puts " 1. Edit your markdown files in #{DOCS_DIR}/"
|
|
66
|
-
puts " 2. Run 'docyard serve' to preview
|
|
73
|
+
puts " 2. Run 'docyard serve' to preview your documentation locally"
|
|
67
74
|
end
|
|
68
75
|
end
|
|
69
76
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Docyard
|
|
6
|
+
module Logging
|
|
7
|
+
class << self
|
|
8
|
+
attr_writer :logger
|
|
9
|
+
|
|
10
|
+
def logger
|
|
11
|
+
@logger ||= default_logger
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def level=(level)
|
|
15
|
+
logger.level = Logger.const_get(level.to_s.upcase)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def default_logger
|
|
21
|
+
logger = Logger.new($stdout)
|
|
22
|
+
logger.level = Logger::INFO
|
|
23
|
+
logger.formatter = log_formatter
|
|
24
|
+
logger
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def log_formatter
|
|
28
|
+
proc do |severity, datetime, _progname, msg|
|
|
29
|
+
timestamp = datetime.strftime("%Y-%m-%d %H:%M:%S")
|
|
30
|
+
"[#{timestamp}] [Docyard] [#{severity}] #{msg}\n"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.logger
|
|
37
|
+
Logging.logger
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.log_level=(level)
|
|
41
|
+
Logging.level = level
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "rack"
|
|
5
|
+
require_relative "sidebar_builder"
|
|
5
6
|
|
|
6
7
|
module Docyard
|
|
7
8
|
class RackApplication
|
|
@@ -24,8 +25,8 @@ module Docyard
|
|
|
24
25
|
def handle_request(env)
|
|
25
26
|
path = env["PATH_INFO"]
|
|
26
27
|
|
|
27
|
-
return handle_reload_check(env) if path ==
|
|
28
|
-
return asset_handler.serve(path) if path.start_with?(
|
|
28
|
+
return handle_reload_check(env) if path == Constants::RELOAD_ENDPOINT
|
|
29
|
+
return asset_handler.serve(path) if path.start_with?(Constants::ASSETS_PREFIX)
|
|
29
30
|
|
|
30
31
|
handle_documentation_request(path)
|
|
31
32
|
rescue StandardError => e
|
|
@@ -33,31 +34,48 @@ module Docyard
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def handle_documentation_request(path)
|
|
36
|
-
|
|
37
|
-
status = file_path ? 200 : 404
|
|
38
|
-
html = file_path ? renderer.render_file(file_path) : renderer.render_not_found
|
|
37
|
+
result = router.resolve(path)
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
if result.found?
|
|
40
|
+
sidebar = SidebarBuilder.new(docs_path: docs_path, current_path: path)
|
|
41
|
+
html = renderer.render_file(result.file_path, sidebar_html: sidebar.to_html)
|
|
42
|
+
[Constants::STATUS_OK, { "Content-Type" => Constants::CONTENT_TYPE_HTML }, [html]]
|
|
43
|
+
else
|
|
44
|
+
html = renderer.render_not_found
|
|
45
|
+
[Constants::STATUS_NOT_FOUND, { "Content-Type" => Constants::CONTENT_TYPE_HTML }, [html]]
|
|
46
|
+
end
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
def handle_reload_check(env)
|
|
44
|
-
|
|
45
|
-
since = query["since"] ? Time.at(query["since"].to_f) : Time.now
|
|
50
|
+
since = parse_since_timestamp(env)
|
|
46
51
|
reload_needed = file_watcher.changed_since?(since)
|
|
47
52
|
|
|
48
53
|
build_reload_response(reload_needed)
|
|
49
54
|
rescue StandardError => e
|
|
50
|
-
|
|
55
|
+
log_reload_error(e)
|
|
51
56
|
build_reload_response(false)
|
|
52
57
|
end
|
|
53
58
|
|
|
59
|
+
def parse_since_timestamp(env)
|
|
60
|
+
query = Rack::Utils.parse_query(env["QUERY_STRING"])
|
|
61
|
+
query["since"] ? Time.at(query["since"].to_f) : Time.now
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def log_reload_error(error)
|
|
65
|
+
Docyard.logger.error "Reload check error: #{error.message}"
|
|
66
|
+
Docyard.logger.debug error.backtrace.join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
54
69
|
def build_reload_response(reload_needed)
|
|
55
70
|
response_body = { reload: reload_needed, timestamp: Time.now.to_f }.to_json
|
|
56
|
-
[
|
|
71
|
+
[Constants::STATUS_OK, { "Content-Type" => Constants::CONTENT_TYPE_JSON }, [response_body]]
|
|
57
72
|
end
|
|
58
73
|
|
|
59
74
|
def handle_error(error)
|
|
60
|
-
|
|
75
|
+
Docyard.logger.error "Request error: #{error.message}"
|
|
76
|
+
Docyard.logger.debug error.backtrace.join("\n")
|
|
77
|
+
[Constants::STATUS_INTERNAL_ERROR, { "Content-Type" => Constants::CONTENT_TYPE_HTML },
|
|
78
|
+
[renderer.render_server_error(error)]]
|
|
61
79
|
end
|
|
62
80
|
end
|
|
63
81
|
end
|
data/lib/docyard/renderer.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Docyard
|
|
|
13
13
|
@layout_path = File.join(LAYOUTS_PATH, "#{layout}.html.erb")
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def render_file(file_path)
|
|
16
|
+
def render_file(file_path, sidebar_html: "")
|
|
17
17
|
markdown_content = File.read(file_path)
|
|
18
18
|
markdown = Markdown.new(markdown_content)
|
|
19
19
|
|
|
@@ -21,15 +21,17 @@ module Docyard
|
|
|
21
21
|
|
|
22
22
|
render(
|
|
23
23
|
content: html_content,
|
|
24
|
-
page_title: markdown.title || "Documentation"
|
|
24
|
+
page_title: markdown.title || "Documentation",
|
|
25
|
+
sidebar_html: sidebar_html
|
|
25
26
|
)
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
def render(content:, page_title: "Documentation")
|
|
29
|
+
def render(content:, page_title: "Documentation", sidebar_html: "")
|
|
29
30
|
template = File.read(layout_path)
|
|
30
31
|
|
|
31
32
|
@content = content
|
|
32
33
|
@page_title = page_title
|
|
34
|
+
@sidebar_html = sidebar_html
|
|
33
35
|
|
|
34
36
|
ERB.new(template).result(binding)
|
|
35
37
|
end
|
data/lib/docyard/router.rb
CHANGED
|
@@ -9,18 +9,23 @@ module Docyard
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def resolve(request_path)
|
|
12
|
-
clean_path = request_path
|
|
13
|
-
clean_path = "index" if clean_path.empty?
|
|
12
|
+
clean_path = sanitize_path(request_path)
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
file_path = File.join(docs_path, "#{clean_path}#{Constants::MARKDOWN_EXTENSION}")
|
|
15
|
+
return Routing::ResolutionResult.found(file_path) if File.file?(file_path)
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
return
|
|
17
|
+
index_path = File.join(docs_path, clean_path, "#{Constants::INDEX_FILE}#{Constants::MARKDOWN_EXTENSION}")
|
|
18
|
+
return Routing::ResolutionResult.found(index_path) if File.file?(index_path)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
Routing::ResolutionResult.not_found
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
def sanitize_path(request_path)
|
|
26
|
+
clean = request_path.delete_prefix("/")
|
|
27
|
+
clean = Constants::INDEX_FILE if clean.empty?
|
|
28
|
+
clean.delete_suffix(Constants::MARKDOWN_EXTENSION)
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
31
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
module Routing
|
|
5
|
+
class ResolutionResult
|
|
6
|
+
attr_reader :file_path, :status
|
|
7
|
+
|
|
8
|
+
def self.found(file_path)
|
|
9
|
+
new(file_path: file_path, status: :found)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.not_found
|
|
13
|
+
new(file_path: nil, status: :not_found)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(file_path:, status:)
|
|
17
|
+
@file_path = file_path
|
|
18
|
+
@status = status
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def found?
|
|
23
|
+
status == :found
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def not_found?
|
|
27
|
+
status == :not_found
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
module Sidebar
|
|
5
|
+
class FileSystemScanner
|
|
6
|
+
attr_reader :docs_path
|
|
7
|
+
|
|
8
|
+
def initialize(docs_path)
|
|
9
|
+
@docs_path = docs_path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def scan
|
|
13
|
+
return [] unless File.directory?(docs_path)
|
|
14
|
+
|
|
15
|
+
scan_directory(docs_path, "")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def scan_directory(base_path, relative_path)
|
|
21
|
+
full_path = File.join(base_path, relative_path)
|
|
22
|
+
return [] unless File.directory?(full_path)
|
|
23
|
+
|
|
24
|
+
entries = sorted_entries(full_path, relative_path)
|
|
25
|
+
entries.map { |entry| build_item(entry, base_path, relative_path, full_path) }.compact
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def sorted_entries(full_path, relative_path)
|
|
29
|
+
Dir.children(full_path)
|
|
30
|
+
.reject { |entry| hidden_or_ignored?(entry, relative_path) }
|
|
31
|
+
.sort_by { |entry| sort_key(entry) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_item(entry, base_path, relative_path, full_path)
|
|
35
|
+
entry_full_path = File.join(full_path, entry)
|
|
36
|
+
entry_relative_path = build_relative_path(relative_path, entry)
|
|
37
|
+
|
|
38
|
+
if File.directory?(entry_full_path)
|
|
39
|
+
build_directory_item(entry, entry_relative_path, base_path)
|
|
40
|
+
elsif entry.end_with?(".md")
|
|
41
|
+
build_file_item(entry, entry_relative_path)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_relative_path(relative_path, entry)
|
|
46
|
+
relative_path.empty? ? entry : File.join(relative_path, entry)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_directory_item(entry, entry_relative_path, base_path)
|
|
50
|
+
{
|
|
51
|
+
type: :directory,
|
|
52
|
+
name: entry,
|
|
53
|
+
path: entry_relative_path,
|
|
54
|
+
children: scan_directory(base_path, entry_relative_path)
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_file_item(entry, entry_relative_path)
|
|
59
|
+
{
|
|
60
|
+
type: :file,
|
|
61
|
+
name: entry.delete_suffix(".md"),
|
|
62
|
+
path: entry_relative_path
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def hidden_or_ignored?(entry, relative_path)
|
|
67
|
+
entry.start_with?(".") ||
|
|
68
|
+
entry.start_with?("_") ||
|
|
69
|
+
(entry == "index.md" && relative_path.empty?)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sort_key(entry)
|
|
73
|
+
entry.downcase
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Docyard
|
|
6
|
+
module Sidebar
|
|
7
|
+
class Renderer
|
|
8
|
+
PARTIALS_PATH = File.join(__dir__, "../templates/partials")
|
|
9
|
+
|
|
10
|
+
attr_reader :site_title
|
|
11
|
+
|
|
12
|
+
def initialize(site_title: "Documentation")
|
|
13
|
+
@site_title = site_title
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render(tree)
|
|
17
|
+
return "" if tree.empty?
|
|
18
|
+
|
|
19
|
+
nav_content = render_tree_with_sections(tree)
|
|
20
|
+
footer_html = render_partial(:sidebar_footer)
|
|
21
|
+
|
|
22
|
+
render_partial(:sidebar, nav_content: nav_content, footer_html: footer_html)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def render_partial(name, locals = {})
|
|
28
|
+
template_path = File.join(PARTIALS_PATH, "_#{name}.html.erb")
|
|
29
|
+
template = File.read(template_path)
|
|
30
|
+
|
|
31
|
+
locals.each { |key, value| instance_variable_set("@#{key}", value) }
|
|
32
|
+
|
|
33
|
+
erb_binding = binding
|
|
34
|
+
ERB.new(template).result(erb_binding)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def icon(name)
|
|
38
|
+
render_partial(:icons, icon_name: name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def render_tree_with_sections(items)
|
|
42
|
+
filtered_items = items.reject { |item| item[:title]&.downcase == site_title.downcase }
|
|
43
|
+
grouped_items = group_by_section(filtered_items)
|
|
44
|
+
|
|
45
|
+
grouped_items.map do |section_name, section_items|
|
|
46
|
+
render_section(section_name, section_items)
|
|
47
|
+
end.join
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_section(section_name, section_items)
|
|
51
|
+
section_content = render_tree(section_items)
|
|
52
|
+
render_partial(:nav_section, section_name: section_name, section_content: section_content)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def group_by_section(items)
|
|
56
|
+
sections = {}
|
|
57
|
+
root_items = []
|
|
58
|
+
|
|
59
|
+
items.each do |item|
|
|
60
|
+
process_section_item(item, sections, root_items)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
build_section_result(sections, root_items)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def process_section_item(item, sections, root_items)
|
|
67
|
+
return if item[:title]&.downcase == site_title.downcase
|
|
68
|
+
|
|
69
|
+
if item[:type] == :directory && !item[:children].empty?
|
|
70
|
+
section_name = item[:title].upcase
|
|
71
|
+
sections[section_name] = item[:children]
|
|
72
|
+
else
|
|
73
|
+
root_items << item
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_section_result(sections, root_items)
|
|
78
|
+
result = {}
|
|
79
|
+
result[nil] = root_items unless root_items.empty?
|
|
80
|
+
result.merge!(sections)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_tree(items)
|
|
84
|
+
return "" if items.empty?
|
|
85
|
+
|
|
86
|
+
list_items = items.map { |item| render_item(item) }.join
|
|
87
|
+
render_partial(:nav_list, list_items: list_items)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def render_item(item)
|
|
91
|
+
item_content = if item[:children].empty?
|
|
92
|
+
render_leaf_item(item)
|
|
93
|
+
else
|
|
94
|
+
render_group_item(item)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
render_partial(:nav_item, item_content: item_content)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def render_leaf_item(item)
|
|
101
|
+
render_partial(:nav_leaf, path: item[:path], title: item[:title], active: item[:active])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render_group_item(item)
|
|
105
|
+
children_html = render_tree(item[:children])
|
|
106
|
+
render_partial(:nav_group, title: item[:title], children_html: children_html)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
module Sidebar
|
|
5
|
+
class TitleExtractor
|
|
6
|
+
def extract(file_path)
|
|
7
|
+
return titleize_filename(file_path) unless File.file?(file_path)
|
|
8
|
+
|
|
9
|
+
content = File.read(file_path)
|
|
10
|
+
markdown = Markdown.new(content)
|
|
11
|
+
markdown.title || titleize_filename(file_path)
|
|
12
|
+
rescue StandardError => e
|
|
13
|
+
Docyard.logger.warn "Failed to extract title from #{file_path}: #{e.message}"
|
|
14
|
+
titleize_filename(file_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def titleize_filename(file_path)
|
|
20
|
+
filename = File.basename(file_path, Constants::MARKDOWN_EXTENSION)
|
|
21
|
+
Utils::TextFormatter.titleize(filename)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|