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