docyard 0.0.1 → 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/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
- data/.github/pull_request_template.md +14 -0
- data/.github/workflows/ci.yml +49 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +52 -0
- data/CONTRIBUTING.md +55 -0
- data/README.md +174 -3
- data/lib/docyard/asset_handler.rb +64 -0
- data/lib/docyard/cli.rb +34 -0
- data/lib/docyard/constants.rb +23 -0
- data/lib/docyard/errors.rb +54 -0
- data/lib/docyard/file_watcher.rb +42 -0
- data/lib/docyard/initializer.rb +76 -0
- data/lib/docyard/logging.rb +43 -0
- data/lib/docyard/markdown.rb +61 -0
- data/lib/docyard/rack_application.rb +81 -0
- data/lib/docyard/renderer.rb +61 -0
- data/lib/docyard/router.rb +31 -0
- data/lib/docyard/routing/resolution_result.rb +31 -0
- data/lib/docyard/server.rb +91 -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 -0
- 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/reload.js +98 -0
- data/lib/docyard/templates/assets/js/theme.js +193 -0
- data/lib/docyard/templates/errors/404.html.erb +16 -0
- data/lib/docyard/templates/errors/500.html.erb +25 -0
- data/lib/docyard/templates/layouts/default.html.erb +51 -0
- 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 +86 -0
- 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 +20 -2
- metadata +169 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Docyard
|
|
6
|
+
class Initializer
|
|
7
|
+
DOCS_DIR = "docs"
|
|
8
|
+
TEMPLATE_DIR = File.join(__dir__, "templates", "markdown")
|
|
9
|
+
|
|
10
|
+
TEMPLATES = {
|
|
11
|
+
"index.md" => "index.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"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(path = ".")
|
|
20
|
+
@path = path
|
|
21
|
+
@docs_path = File.join(@path, DOCS_DIR)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
if already_initialized?
|
|
26
|
+
print_already_exists_error
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
create_structure
|
|
31
|
+
print_success
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def already_initialized?
|
|
38
|
+
File.exist?(@docs_path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_structure
|
|
42
|
+
FileUtils.mkdir_p(@docs_path)
|
|
43
|
+
|
|
44
|
+
TEMPLATES.each do |output_name, template_name|
|
|
45
|
+
copy_template(template_name, output_name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def copy_template(template_name, output_name)
|
|
50
|
+
template_path = File.join(TEMPLATE_DIR, template_name)
|
|
51
|
+
output_path = File.join(@docs_path, output_name)
|
|
52
|
+
|
|
53
|
+
output_dir = File.dirname(output_path)
|
|
54
|
+
FileUtils.mkdir_p(output_dir) unless File.directory?(output_dir)
|
|
55
|
+
|
|
56
|
+
content = File.read(template_path)
|
|
57
|
+
File.write(output_path, content)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def print_already_exists_error
|
|
61
|
+
puts "Error: #{DOCS_DIR}/ folder already exists"
|
|
62
|
+
puts " Remove it first or run docyard in a different directory"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def print_success
|
|
66
|
+
puts "Docyard initialized successfully!"
|
|
67
|
+
puts ""
|
|
68
|
+
puts "Created:"
|
|
69
|
+
TEMPLATES.each_key { |file| puts " #{DOCS_DIR}/#{file}" }
|
|
70
|
+
puts ""
|
|
71
|
+
puts "Next steps:"
|
|
72
|
+
puts " 1. Edit your markdown files in #{DOCS_DIR}/"
|
|
73
|
+
puts " 2. Run 'docyard serve' to preview your documentation locally"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
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
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kramdown"
|
|
4
|
+
require "kramdown-parser-gfm"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module Docyard
|
|
8
|
+
class Markdown
|
|
9
|
+
FRONTMATTER_REGEX = /\A---\s*\n(.*?\n)---\s*\n/m
|
|
10
|
+
|
|
11
|
+
attr_reader :raw
|
|
12
|
+
|
|
13
|
+
def initialize(raw)
|
|
14
|
+
@raw = raw.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def frontmatter
|
|
18
|
+
@frontmatter ||= parse_frontmatter
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def content
|
|
22
|
+
@content ||= extract_content
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def html
|
|
26
|
+
@html ||= render_html
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def title
|
|
30
|
+
frontmatter["title"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def description
|
|
34
|
+
frontmatter["description"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse_frontmatter
|
|
40
|
+
match = raw.match(FRONTMATTER_REGEX)
|
|
41
|
+
return {} unless match
|
|
42
|
+
|
|
43
|
+
YAML.safe_load(match[1])
|
|
44
|
+
rescue Psych::SyntaxError
|
|
45
|
+
{}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_content
|
|
49
|
+
raw.sub(FRONTMATTER_REGEX, "").strip
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_html
|
|
53
|
+
Kramdown::Document.new(
|
|
54
|
+
content,
|
|
55
|
+
input: "GFM",
|
|
56
|
+
hard_wrap: false,
|
|
57
|
+
syntax_highlighter: "rouge"
|
|
58
|
+
).to_html
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rack"
|
|
5
|
+
require_relative "sidebar_builder"
|
|
6
|
+
|
|
7
|
+
module Docyard
|
|
8
|
+
class RackApplication
|
|
9
|
+
def initialize(docs_path:, file_watcher:)
|
|
10
|
+
@docs_path = docs_path
|
|
11
|
+
@file_watcher = file_watcher
|
|
12
|
+
@router = Router.new(docs_path: docs_path)
|
|
13
|
+
@renderer = Renderer.new
|
|
14
|
+
@asset_handler = AssetHandler.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
handle_request(env)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :docs_path, :file_watcher, :router, :renderer, :asset_handler
|
|
24
|
+
|
|
25
|
+
def handle_request(env)
|
|
26
|
+
path = env["PATH_INFO"]
|
|
27
|
+
|
|
28
|
+
return handle_reload_check(env) if path == Constants::RELOAD_ENDPOINT
|
|
29
|
+
return asset_handler.serve(path) if path.start_with?(Constants::ASSETS_PREFIX)
|
|
30
|
+
|
|
31
|
+
handle_documentation_request(path)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
handle_error(e)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def handle_documentation_request(path)
|
|
37
|
+
result = router.resolve(path)
|
|
38
|
+
|
|
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
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def handle_reload_check(env)
|
|
50
|
+
since = parse_since_timestamp(env)
|
|
51
|
+
reload_needed = file_watcher.changed_since?(since)
|
|
52
|
+
|
|
53
|
+
build_reload_response(reload_needed)
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
log_reload_error(e)
|
|
56
|
+
build_reload_response(false)
|
|
57
|
+
end
|
|
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
|
+
|
|
69
|
+
def build_reload_response(reload_needed)
|
|
70
|
+
response_body = { reload: reload_needed, timestamp: Time.now.to_f }.to_json
|
|
71
|
+
[Constants::STATUS_OK, { "Content-Type" => Constants::CONTENT_TYPE_JSON }, [response_body]]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def handle_error(error)
|
|
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)]]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Docyard
|
|
6
|
+
class Renderer
|
|
7
|
+
LAYOUTS_PATH = File.join(__dir__, "templates", "layouts")
|
|
8
|
+
ERRORS_PATH = File.join(__dir__, "templates", "errors")
|
|
9
|
+
|
|
10
|
+
attr_reader :layout_path
|
|
11
|
+
|
|
12
|
+
def initialize(layout: "default")
|
|
13
|
+
@layout_path = File.join(LAYOUTS_PATH, "#{layout}.html.erb")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render_file(file_path, sidebar_html: "")
|
|
17
|
+
markdown_content = File.read(file_path)
|
|
18
|
+
markdown = Markdown.new(markdown_content)
|
|
19
|
+
|
|
20
|
+
html_content = strip_md_from_links(markdown.html)
|
|
21
|
+
|
|
22
|
+
render(
|
|
23
|
+
content: html_content,
|
|
24
|
+
page_title: markdown.title || "Documentation",
|
|
25
|
+
sidebar_html: sidebar_html
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render(content:, page_title: "Documentation", sidebar_html: "")
|
|
30
|
+
template = File.read(layout_path)
|
|
31
|
+
|
|
32
|
+
@content = content
|
|
33
|
+
@page_title = page_title
|
|
34
|
+
@sidebar_html = sidebar_html
|
|
35
|
+
|
|
36
|
+
ERB.new(template).result(binding)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def render_not_found
|
|
40
|
+
render_error_template(404)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render_server_error(error)
|
|
44
|
+
@error_message = error.message
|
|
45
|
+
@backtrace = error.backtrace.join("\n")
|
|
46
|
+
render_error_template(500)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_error_template(status)
|
|
50
|
+
error_template_path = File.join(ERRORS_PATH, "#{status}.html.erb")
|
|
51
|
+
template = File.read(error_template_path)
|
|
52
|
+
ERB.new(template).result(binding)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def strip_md_from_links(html)
|
|
58
|
+
html.gsub(/href="([^"]+)\.md"/, 'href="\1"')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Docyard
|
|
4
|
+
class Router
|
|
5
|
+
attr_reader :docs_path
|
|
6
|
+
|
|
7
|
+
def initialize(docs_path:)
|
|
8
|
+
@docs_path = docs_path
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def resolve(request_path)
|
|
12
|
+
clean_path = sanitize_path(request_path)
|
|
13
|
+
|
|
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
|
+
|
|
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
|
+
|
|
20
|
+
Routing::ResolutionResult.not_found
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
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)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webrick"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require_relative "file_watcher"
|
|
6
|
+
require_relative "rack_application"
|
|
7
|
+
|
|
8
|
+
module Docyard
|
|
9
|
+
class Server
|
|
10
|
+
DEFAULT_PORT = 4200
|
|
11
|
+
DEFAULT_HOST = "localhost"
|
|
12
|
+
|
|
13
|
+
attr_reader :port, :host, :docs_path
|
|
14
|
+
|
|
15
|
+
def initialize(port: DEFAULT_PORT, host: DEFAULT_HOST, docs_path: "docs")
|
|
16
|
+
@port = port
|
|
17
|
+
@host = host
|
|
18
|
+
@docs_path = docs_path
|
|
19
|
+
@file_watcher = FileWatcher.new(File.expand_path(docs_path))
|
|
20
|
+
@app = RackApplication.new(
|
|
21
|
+
docs_path: File.expand_path(docs_path),
|
|
22
|
+
file_watcher: @file_watcher
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start
|
|
27
|
+
validate_docs_directory!
|
|
28
|
+
print_server_info
|
|
29
|
+
@file_watcher.start
|
|
30
|
+
|
|
31
|
+
http_server.mount_proc("/") { |req, res| handle_request(req, res) }
|
|
32
|
+
trap("INT") { shutdown_server }
|
|
33
|
+
|
|
34
|
+
http_server.start
|
|
35
|
+
@file_watcher.stop
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_docs_directory!
|
|
41
|
+
return if File.directory?(docs_path)
|
|
42
|
+
|
|
43
|
+
abort "Error: #{docs_path}/ directory not found.\n" \
|
|
44
|
+
"Run `docyard init` first to create the docs structure."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def print_server_info
|
|
48
|
+
puts "Starting Docyard server..."
|
|
49
|
+
puts "=> Serving docs from: #{docs_path}/"
|
|
50
|
+
puts "=> Running at: http://#{host}:#{port}"
|
|
51
|
+
puts "=> Press Ctrl+C to stop\n"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def shutdown_server
|
|
55
|
+
puts "\nShutting down server..."
|
|
56
|
+
http_server.shutdown
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def http_server
|
|
60
|
+
@http_server ||= WEBrick::HTTPServer.new(
|
|
61
|
+
Port: port,
|
|
62
|
+
BindAddress: host,
|
|
63
|
+
AccessLog: [],
|
|
64
|
+
Logger: WEBrick::Log.new(File::NULL)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def handle_request(req, res)
|
|
69
|
+
env = build_rack_env(req)
|
|
70
|
+
status, headers, body = @app.call(env)
|
|
71
|
+
|
|
72
|
+
res.status = status
|
|
73
|
+
headers.each { |key, value| res[key] = value }
|
|
74
|
+
body.each { |chunk| res.body << chunk }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_rack_env(req)
|
|
78
|
+
{
|
|
79
|
+
"REQUEST_METHOD" => req.request_method,
|
|
80
|
+
"PATH_INFO" => req.path,
|
|
81
|
+
"QUERY_STRING" => req.query_string || "",
|
|
82
|
+
"SERVER_NAME" => req.host,
|
|
83
|
+
"SERVER_PORT" => req.port.to_s,
|
|
84
|
+
"rack.version" => Rack::VERSION,
|
|
85
|
+
"rack.url_scheme" => "http",
|
|
86
|
+
"rack.input" => StringIO.new,
|
|
87
|
+
"rack.errors" => $stderr
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
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
|