docyard 0.3.0 → 0.5.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -2
  3. data/README.md +80 -33
  4. data/lib/docyard/build/asset_bundler.rb +139 -0
  5. data/lib/docyard/build/file_copier.rb +105 -0
  6. data/lib/docyard/build/sitemap_generator.rb +57 -0
  7. data/lib/docyard/build/static_generator.rb +141 -0
  8. data/lib/docyard/builder.rb +104 -0
  9. data/lib/docyard/cli.rb +19 -0
  10. data/lib/docyard/components/heading_anchor_processor.rb +34 -0
  11. data/lib/docyard/components/table_of_contents_processor.rb +64 -0
  12. data/lib/docyard/components/table_wrapper_processor.rb +18 -0
  13. data/lib/docyard/config.rb +15 -2
  14. data/lib/docyard/icons/phosphor.rb +3 -1
  15. data/lib/docyard/initializer.rb +80 -14
  16. data/lib/docyard/markdown.rb +19 -0
  17. data/lib/docyard/prev_next_builder.rb +159 -0
  18. data/lib/docyard/preview_server.rb +72 -0
  19. data/lib/docyard/rack_application.rb +25 -3
  20. data/lib/docyard/renderer.rb +33 -8
  21. data/lib/docyard/sidebar/config_parser.rb +180 -0
  22. data/lib/docyard/sidebar/item.rb +58 -0
  23. data/lib/docyard/sidebar/renderer.rb +33 -6
  24. data/lib/docyard/sidebar_builder.rb +45 -1
  25. data/lib/docyard/templates/assets/css/components/callout.css +1 -1
  26. data/lib/docyard/templates/assets/css/components/code-block.css +2 -2
  27. data/lib/docyard/templates/assets/css/components/heading-anchor.css +77 -0
  28. data/lib/docyard/templates/assets/css/components/navigation.css +65 -7
  29. data/lib/docyard/templates/assets/css/components/prev-next.css +114 -0
  30. data/lib/docyard/templates/assets/css/components/table-of-contents.css +269 -0
  31. data/lib/docyard/templates/assets/css/components/tabs.css +3 -2
  32. data/lib/docyard/templates/assets/css/components/theme-toggle.css +8 -0
  33. data/lib/docyard/templates/assets/css/layout.css +58 -1
  34. data/lib/docyard/templates/assets/css/markdown.css +20 -11
  35. data/lib/docyard/templates/assets/css/variables.css +1 -0
  36. data/lib/docyard/templates/assets/js/components/heading-anchor.js +90 -0
  37. data/lib/docyard/templates/assets/js/components/navigation.js +225 -0
  38. data/lib/docyard/templates/assets/js/components/table-of-contents.js +301 -0
  39. data/lib/docyard/templates/assets/js/theme.js +2 -185
  40. data/lib/docyard/templates/config/docyard.yml.erb +32 -10
  41. data/lib/docyard/templates/layouts/default.html.erb +10 -2
  42. data/lib/docyard/templates/markdown/getting-started/installation.md.erb +46 -12
  43. data/lib/docyard/templates/markdown/guides/configuration.md.erb +202 -0
  44. data/lib/docyard/templates/markdown/guides/markdown-features.md.erb +247 -0
  45. data/lib/docyard/templates/markdown/index.md.erb +55 -59
  46. data/lib/docyard/templates/partials/_heading_anchor.html.erb +1 -0
  47. data/lib/docyard/templates/partials/_nav_group.html.erb +10 -4
  48. data/lib/docyard/templates/partials/_nav_leaf.html.erb +9 -1
  49. data/lib/docyard/templates/partials/_prev_next.html.erb +23 -0
  50. data/lib/docyard/templates/partials/_table_of_contents.html.erb +45 -0
  51. data/lib/docyard/templates/partials/_table_of_contents_toggle.html.erb +8 -0
  52. data/lib/docyard/version.rb +1 -1
  53. data/lib/docyard.rb +8 -0
  54. metadata +67 -10
  55. data/lib/docyard/templates/markdown/components/callouts.md.erb +0 -204
  56. data/lib/docyard/templates/markdown/components/icons.md.erb +0 -125
  57. data/lib/docyard/templates/markdown/components/tabs.md.erb +0 -686
  58. data/lib/docyard/templates/markdown/configuration.md.erb +0 -202
  59. data/lib/docyard/templates/markdown/core-concepts/file-structure.md.erb +0 -61
  60. data/lib/docyard/templates/markdown/core-concepts/markdown.md.erb +0 -90
  61. data/lib/docyard/templates/markdown/getting-started/introduction.md.erb +0 -30
  62. data/lib/docyard/templates/markdown/getting-started/quick-start.md.erb +0 -56
  63. data/lib/docyard/templates/partials/_icons.html.erb +0 -11
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "rack"
5
5
  require_relative "sidebar_builder"
6
+ require_relative "prev_next_builder"
6
7
  require_relative "constants"
7
8
 
8
9
  module Docyard
@@ -12,7 +13,7 @@ module Docyard
12
13
  @file_watcher = file_watcher
13
14
  @config = config
14
15
  @router = Router.new(docs_path: docs_path)
15
- @renderer = Renderer.new
16
+ @renderer = Renderer.new(base_url: config&.build&.base_url || "/")
16
17
  @asset_handler = AssetHandler.new
17
18
  end
18
19
 
@@ -46,9 +47,12 @@ module Docyard
46
47
  end
47
48
 
48
49
  def render_documentation_page(file_path, current_path)
50
+ sidebar_builder = build_sidebar_instance(current_path)
51
+
49
52
  html = renderer.render_file(
50
53
  file_path,
51
- sidebar_html: build_sidebar(current_path),
54
+ sidebar_html: sidebar_builder.to_html,
55
+ prev_next_html: build_prev_next(sidebar_builder, current_path, file_path),
52
56
  branding: branding_options
53
57
  )
54
58
 
@@ -60,14 +64,32 @@ module Docyard
60
64
  [Constants::STATUS_NOT_FOUND, { "Content-Type" => Constants::CONTENT_TYPE_HTML }, [html]]
61
65
  end
62
66
 
63
- def build_sidebar(current_path)
67
+ def build_sidebar_instance(current_path)
64
68
  SidebarBuilder.new(
65
69
  docs_path: docs_path,
66
70
  current_path: current_path,
67
71
  config: config
72
+ )
73
+ end
74
+
75
+ def build_prev_next(sidebar_builder, current_path, file_path)
76
+ markdown_content = File.read(file_path)
77
+ markdown = Markdown.new(markdown_content)
78
+
79
+ PrevNextBuilder.new(
80
+ sidebar_tree: sidebar_builder.tree,
81
+ current_path: current_path,
82
+ frontmatter: markdown.frontmatter,
83
+ config: navigation_config
68
84
  ).to_html
69
85
  end
70
86
 
87
+ def navigation_config
88
+ return {} unless config
89
+
90
+ config.navigation&.footer || {}
91
+ end
92
+
71
93
  def branding_options
72
94
  return default_branding unless config
73
95
 
@@ -9,30 +9,40 @@ module Docyard
9
9
  ERRORS_PATH = File.join(__dir__, "templates", "errors")
10
10
  PARTIALS_PATH = File.join(__dir__, "templates", "partials")
11
11
 
12
- attr_reader :layout_path
12
+ attr_reader :layout_path, :base_url
13
13
 
14
- def initialize(layout: "default")
14
+ def initialize(layout: "default", base_url: "/")
15
15
  @layout_path = File.join(LAYOUTS_PATH, "#{layout}.html.erb")
16
+ @base_url = normalize_base_url(base_url)
16
17
  end
17
18
 
18
- def render_file(file_path, sidebar_html: "", branding: {})
19
+ def render_file(file_path, sidebar_html: "", prev_next_html: "", branding: {})
19
20
  markdown_content = File.read(file_path)
20
21
  markdown = Markdown.new(markdown_content)
21
22
 
22
23
  html_content = strip_md_from_links(markdown.html)
24
+ toc = markdown.toc
23
25
 
24
26
  render(
25
27
  content: html_content,
26
28
  page_title: markdown.title || Constants::DEFAULT_SITE_TITLE,
27
- sidebar_html: sidebar_html,
29
+ navigation: {
30
+ sidebar_html: sidebar_html,
31
+ prev_next_html: prev_next_html,
32
+ toc: toc
33
+ },
28
34
  branding: branding
29
35
  )
30
36
  end
31
37
 
32
- def render(content:, page_title: Constants::DEFAULT_SITE_TITLE, sidebar_html: "", branding: {})
38
+ def render(content:, page_title: Constants::DEFAULT_SITE_TITLE, navigation: {}, branding: {})
33
39
  template = File.read(layout_path)
34
40
 
35
- assign_content_variables(content, page_title, sidebar_html)
41
+ sidebar_html = navigation[:sidebar_html] || ""
42
+ prev_next_html = navigation[:prev_next_html] || ""
43
+ toc = navigation[:toc] || []
44
+
45
+ assign_content_variables(content, page_title, sidebar_html, prev_next_html, toc)
36
46
  assign_branding_variables(branding)
37
47
 
38
48
  ERB.new(template).result(binding)
@@ -66,15 +76,30 @@ module Docyard
66
76
  def asset_path(path)
67
77
  return path if path.nil? || path.start_with?("http://", "https://")
68
78
 
69
- "/#{path}"
79
+ "#{base_url}#{path}"
80
+ end
81
+
82
+ def link_path(path)
83
+ return path if path.nil? || path.start_with?("http://", "https://")
84
+
85
+ "#{base_url.chomp('/')}#{path}"
70
86
  end
71
87
 
72
88
  private
73
89
 
74
- def assign_content_variables(content, page_title, sidebar_html)
90
+ def normalize_base_url(url)
91
+ return "/" if url.nil? || url.empty?
92
+
93
+ url = "/#{url}" unless url.start_with?("/")
94
+ url.end_with?("/") ? url : "#{url}/"
95
+ end
96
+
97
+ def assign_content_variables(content, page_title, sidebar_html, prev_next_html, toc)
75
98
  @content = content
76
99
  @page_title = page_title
77
100
  @sidebar_html = sidebar_html
101
+ @prev_next_html = prev_next_html
102
+ @toc = toc
78
103
  end
79
104
 
80
105
  def assign_branding_variables(branding)
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "item"
4
+ require_relative "title_extractor"
5
+
6
+ module Docyard
7
+ module Sidebar
8
+ class ConfigParser
9
+ attr_reader :config_items, :docs_path, :current_path, :title_extractor
10
+
11
+ def initialize(config_items, docs_path:, current_path: "/", title_extractor: TitleExtractor.new)
12
+ @config_items = config_items || []
13
+ @docs_path = docs_path
14
+ @current_path = Utils::PathResolver.normalize(current_path)
15
+ @title_extractor = title_extractor
16
+ end
17
+
18
+ def parse
19
+ parse_items(config_items)
20
+ end
21
+
22
+ private
23
+
24
+ def parse_items(items, base_path = "")
25
+ items.map do |item_config|
26
+ parse_item(item_config, base_path)
27
+ end.compact
28
+ end
29
+
30
+ def parse_item(item_config, base_path)
31
+ case item_config
32
+ when String
33
+ resolve_file_item(item_config, base_path)
34
+ when Hash
35
+ parse_hash_item(item_config, base_path)
36
+ end
37
+ end
38
+
39
+ def parse_hash_item(item_config, base_path)
40
+ return parse_link_item(item_config) if link_item?(item_config)
41
+ return parse_nested_item(item_config, base_path) if nested_item?(item_config)
42
+ return resolve_file_item(item_config.keys.first, base_path, {}) if nil_value_item?(item_config)
43
+
44
+ slug = item_config.keys.first
45
+ options = item_config.values.first || {}
46
+ resolve_file_item(slug, base_path, options)
47
+ end
48
+
49
+ def link_item?(config)
50
+ config.key?("link") || config.key?(:link)
51
+ end
52
+
53
+ def nested_item?(config)
54
+ config.size == 1 && config.values.first.is_a?(Hash)
55
+ end
56
+
57
+ def nil_value_item?(config)
58
+ config.size == 1 && config.values.first.nil?
59
+ end
60
+
61
+ def parse_link_item(config)
62
+ link = config["link"] || config[:link]
63
+ text = config["text"] || config[:text]
64
+ icon = config["icon"] || config[:icon]
65
+ target = config["target"] || config[:target] || "_blank"
66
+
67
+ Item.new(
68
+ text: text,
69
+ link: link,
70
+ path: link,
71
+ icon: icon,
72
+ target: target,
73
+ type: :external
74
+ )
75
+ end
76
+
77
+ def parse_nested_item(item_config, base_path)
78
+ slug = item_config.keys.first.to_s
79
+ options = item_config.values.first || {}
80
+ nested_items = extract_nested_items(options)
81
+
82
+ dir_path = File.join(docs_path, base_path, slug)
83
+
84
+ if File.directory?(dir_path)
85
+ build_directory_item(slug, options, nested_items, base_path)
86
+ elsif nested_items.any?
87
+ build_file_with_children_item(slug, options, nested_items, base_path)
88
+ else
89
+ resolve_file_item(slug, base_path, options)
90
+ end
91
+ end
92
+
93
+ def extract_nested_items(options)
94
+ options["items"] || options[:items] || []
95
+ end
96
+
97
+ def extract_common_options(options)
98
+ {
99
+ text: options["text"] || options[:text],
100
+ icon: options["icon"] || options[:icon],
101
+ collapsed: options["collapsed"] || options[:collapsed] || false
102
+ }
103
+ end
104
+
105
+ def build_directory_item(slug, options, nested_items, base_path)
106
+ common_opts = extract_common_options(options)
107
+ new_base_path = File.join(base_path, slug)
108
+ parsed_items = parse_items(nested_items, new_base_path)
109
+
110
+ Item.new(
111
+ slug: slug,
112
+ text: common_opts[:text] || Utils::TextFormatter.titleize(slug),
113
+ icon: common_opts[:icon],
114
+ collapsed: common_opts[:collapsed],
115
+ items: parsed_items,
116
+ type: :directory
117
+ )
118
+ end
119
+
120
+ def build_file_with_children_item(slug, options, nested_items, base_path)
121
+ common_opts = extract_common_options(options)
122
+ file_path = File.join(docs_path, base_path, "#{slug}.md")
123
+ url_path = Utils::PathResolver.to_url(File.join(base_path, slug))
124
+ resolved_text = common_opts[:text] || extract_file_title(file_path, slug)
125
+
126
+ Item.new(
127
+ slug: slug,
128
+ text: resolved_text,
129
+ path: url_path,
130
+ icon: common_opts[:icon],
131
+ collapsed: common_opts[:collapsed],
132
+ items: parse_items(nested_items, base_path),
133
+ active: current_path == url_path,
134
+ type: :file
135
+ )
136
+ end
137
+
138
+ def extract_file_title(file_path, slug)
139
+ File.exist?(file_path) ? title_extractor.extract(file_path) : Utils::TextFormatter.titleize(slug)
140
+ end
141
+
142
+ def resolve_file_item(slug, base_path, options = {})
143
+ slug_str = slug.to_s
144
+ options ||= {}
145
+
146
+ file_path = File.join(docs_path, base_path, "#{slug_str}.md")
147
+ url_path = Utils::PathResolver.to_url(File.join(base_path, slug_str))
148
+
149
+ frontmatter = extract_frontmatter_metadata(file_path)
150
+ text = resolve_item_text(slug_str, file_path, options, frontmatter[:text])
151
+ icon = resolve_item_icon(options, frontmatter[:icon])
152
+ final_path = options["link"] || options[:link] || url_path
153
+
154
+ Item.new(
155
+ slug: slug_str, text: text, path: final_path, icon: icon,
156
+ active: current_path == final_path, type: :file
157
+ )
158
+ end
159
+
160
+ def extract_frontmatter_metadata(file_path)
161
+ return { text: nil, icon: nil } unless File.exist?(file_path)
162
+
163
+ markdown = Markdown.new(File.read(file_path))
164
+ {
165
+ text: markdown.sidebar_text || markdown.title,
166
+ icon: markdown.sidebar_icon
167
+ }
168
+ end
169
+
170
+ def resolve_item_text(slug, file_path, options, frontmatter_text)
171
+ text = options["text"] || options[:text] || frontmatter_text
172
+ text || extract_file_title(file_path, slug)
173
+ end
174
+
175
+ def resolve_item_icon(options, frontmatter_icon)
176
+ options["icon"] || options[:icon] || frontmatter_icon
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Sidebar
5
+ class Item
6
+ attr_reader :slug, :text, :icon, :link, :target, :collapsed, :items, :path, :active, :type
7
+
8
+ def initialize(**options)
9
+ @slug = options[:slug]
10
+ @text = options[:text]
11
+ @icon = options[:icon]
12
+ @link = options[:link]
13
+ @target = options[:target] || "_self"
14
+ @collapsed = options[:collapsed] || false
15
+ @items = options[:items] || []
16
+ @path = options[:path] || options[:link]
17
+ @active = options[:active] || false
18
+ @type = options[:type] || :file
19
+ end
20
+
21
+ def external?
22
+ return false if path.nil?
23
+
24
+ path.start_with?("http://", "https://")
25
+ end
26
+
27
+ def children?
28
+ items.any?
29
+ end
30
+
31
+ def title
32
+ text
33
+ end
34
+
35
+ def children
36
+ items
37
+ end
38
+
39
+ def collapsible?
40
+ children?
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ title: title,
46
+ path: path,
47
+ icon: icon,
48
+ active: active,
49
+ type: type,
50
+ collapsed: collapsed,
51
+ collapsible: collapsible?,
52
+ target: target,
53
+ children: children.map(&:to_h)
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -7,10 +7,11 @@ module Docyard
7
7
  class Renderer
8
8
  PARTIALS_PATH = File.join(__dir__, "../templates/partials")
9
9
 
10
- attr_reader :site_title
10
+ attr_reader :site_title, :base_url
11
11
 
12
- def initialize(site_title: "Documentation")
12
+ def initialize(site_title: "Documentation", base_url: "/")
13
13
  @site_title = site_title
14
+ @base_url = normalize_base_url(base_url)
14
15
  end
15
16
 
16
17
  def render(tree)
@@ -34,8 +35,21 @@ module Docyard
34
35
  ERB.new(template).result(erb_binding)
35
36
  end
36
37
 
37
- def icon(name)
38
- render_partial(:icons, icon_name: name)
38
+ def icon(name, weight = "regular")
39
+ Icons.render(name.to_s.tr("_", "-"), weight) || ""
40
+ end
41
+
42
+ def link_path(path)
43
+ return path if path.nil? || path.start_with?("http://", "https://")
44
+
45
+ "#{base_url.chomp('/')}#{path}"
46
+ end
47
+
48
+ def normalize_base_url(url)
49
+ return "/" if url.nil? || url.empty?
50
+
51
+ url = "/#{url}" unless url.start_with?("/")
52
+ url.end_with?("/") ? url : "#{url}/"
39
53
  end
40
54
 
41
55
  def render_tree_with_sections(items)
@@ -98,12 +112,25 @@ module Docyard
98
112
  end
99
113
 
100
114
  def render_leaf_item(item)
101
- render_partial(:nav_leaf, path: item[:path], title: item[:title], active: item[:active])
115
+ render_partial(
116
+ :nav_leaf,
117
+ path: item[:path],
118
+ title: item[:title],
119
+ active: item[:active],
120
+ icon: item[:icon],
121
+ target: item[:target]
122
+ )
102
123
  end
103
124
 
104
125
  def render_group_item(item)
105
126
  children_html = render_tree(item[:children])
106
- render_partial(:nav_group, title: item[:title], children_html: children_html)
127
+ render_partial(
128
+ :nav_group,
129
+ title: item[:title],
130
+ children_html: children_html,
131
+ icon: item[:icon],
132
+ collapsed: item[:collapsed]
133
+ )
107
134
  end
108
135
  end
109
136
  end
@@ -4,6 +4,7 @@ require_relative "sidebar/file_system_scanner"
4
4
  require_relative "sidebar/title_extractor"
5
5
  require_relative "sidebar/tree_builder"
6
6
  require_relative "sidebar/renderer"
7
+ require_relative "sidebar/config_parser"
7
8
 
8
9
  module Docyard
9
10
  class SidebarBuilder
@@ -26,10 +27,44 @@ module Docyard
26
27
  private
27
28
 
28
29
  def build_tree
30
+ if config_sidebar_items?
31
+ build_tree_from_config
32
+ else
33
+ build_tree_from_filesystem
34
+ end
35
+ end
36
+
37
+ def build_tree_from_config
38
+ config_parser.parse.map(&:to_h)
39
+ end
40
+
41
+ def build_tree_from_filesystem
29
42
  file_items = scanner.scan
30
43
  tree_builder.build(file_items)
31
44
  end
32
45
 
46
+ def config_sidebar_items?
47
+ config_sidebar_items&.any?
48
+ end
49
+
50
+ def config_sidebar_items
51
+ return [] unless config
52
+
53
+ if config.is_a?(Hash)
54
+ config.dig("sidebar", "items") || config.dig(:sidebar, :items) || []
55
+ else
56
+ config.sidebar&.items || []
57
+ end
58
+ end
59
+
60
+ def config_parser
61
+ @config_parser ||= Sidebar::ConfigParser.new(
62
+ config_sidebar_items,
63
+ docs_path: docs_path,
64
+ current_path: current_path
65
+ )
66
+ end
67
+
33
68
  def scanner
34
69
  @scanner ||= Sidebar::FileSystemScanner.new(docs_path)
35
70
  end
@@ -43,10 +78,19 @@ module Docyard
43
78
 
44
79
  def renderer
45
80
  @renderer ||= Sidebar::Renderer.new(
46
- site_title: extract_site_title
81
+ site_title: extract_site_title,
82
+ base_url: extract_base_url
47
83
  )
48
84
  end
49
85
 
86
+ def extract_base_url
87
+ if config.is_a?(Hash)
88
+ config.dig(:build, :base_url) || "/"
89
+ else
90
+ config&.build&.base_url || "/"
91
+ end
92
+ end
93
+
50
94
  def extract_site_title
51
95
  if config.is_a?(Hash)
52
96
  config[:site_title] || "Documentation"
@@ -6,7 +6,7 @@
6
6
  margin: var(--space-6) 0;
7
7
  border: 1px solid;
8
8
  border-radius: var(--radius-lg);
9
- transition: all var(--transition-base);
9
+ transition: border-color var(--transition-base), background-color var(--transition-base);
10
10
  }
11
11
 
12
12
  .docyard-callout__icon {
@@ -47,7 +47,7 @@
47
47
  background-color: var(--color-bg);
48
48
  color: var(--color-text-secondary);
49
49
  cursor: pointer;
50
- transition: all var(--transition-base);
50
+ transition: background-color var(--transition-base), border-color var(--transition-base), color var(--transition-base), opacity var(--transition-base), visibility var(--transition-base);
51
51
  opacity: 0;
52
52
  visibility: hidden;
53
53
  }
@@ -78,7 +78,7 @@
78
78
  .docyard-code-block__copy svg {
79
79
  width: 16px;
80
80
  height: 16px;
81
- transition: all 0.2s ease;
81
+ transition: transform 0.2s ease, opacity 0.2s ease;
82
82
  }
83
83
 
84
84
  /* Success state */
@@ -0,0 +1,77 @@
1
+ .content h2[id],
2
+ .content h3[id],
3
+ .content h4[id],
4
+ .content h5[id],
5
+ .content h6[id] {
6
+ position: relative;
7
+ scroll-margin-top: 100px;
8
+ }
9
+
10
+ /* Tablet: Account for both primary + secondary header */
11
+ @media (max-width: 1280px) and (min-width: 1025px) {
12
+ .content h2[id],
13
+ .content h3[id],
14
+ .content h4[id],
15
+ .content h5[id],
16
+ .content h6[id] {
17
+ scroll-margin-top: calc(var(--header-height) + 3rem + var(--space-4));
18
+ }
19
+ }
20
+
21
+ .heading-anchor {
22
+ float: left;
23
+ margin-left: -0.75em;
24
+ padding-right: 0.25em;
25
+ font-weight: var(--font-weight-normal);
26
+ color: var(--color-primary);
27
+ text-decoration: none !important;
28
+ opacity: 0;
29
+ transition: opacity var(--transition-fast);
30
+ cursor: pointer;
31
+ user-select: none;
32
+ }
33
+
34
+ .heading-anchor:hover,
35
+ .heading-anchor:focus {
36
+ opacity: 1;
37
+ text-decoration: none !important;
38
+ }
39
+
40
+ .heading-anchor:active,
41
+ .heading-anchor:visited {
42
+ text-decoration: none !important;
43
+ }
44
+
45
+ .heading-anchor:focus {
46
+ outline: 2px solid var(--color-primary);
47
+ outline-offset: 2px;
48
+ border-radius: 4px;
49
+ }
50
+
51
+ .content h2[id]:hover .heading-anchor,
52
+ .content h3[id]:hover .heading-anchor,
53
+ .content h4[id]:hover .heading-anchor,
54
+ .content h5[id]:hover .heading-anchor,
55
+ .content h6[id]:hover .heading-anchor {
56
+ opacity: 1;
57
+ }
58
+
59
+ @media (max-width: 1024px) {
60
+ .heading-anchor {
61
+ position: static;
62
+ margin-left: var(--space-2);
63
+ float: none;
64
+ opacity: 0.6;
65
+ }
66
+
67
+ .heading-anchor:hover,
68
+ .heading-anchor:focus {
69
+ opacity: 1;
70
+ }
71
+ }
72
+
73
+ @media (prefers-reduced-motion: reduce) {
74
+ .heading-anchor {
75
+ transition: none;
76
+ }
77
+ }