docyard 1.1.0 → 1.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/README.md +56 -11
  4. data/lib/docyard/build/asset_bundler.rb +17 -1
  5. data/lib/docyard/build/social_cards/card_renderer.rb +132 -0
  6. data/lib/docyard/build/social_cards/doc_card.rb +97 -0
  7. data/lib/docyard/build/social_cards/homepage_card.rb +98 -0
  8. data/lib/docyard/build/social_cards_generator.rb +188 -0
  9. data/lib/docyard/build/step_runner.rb +2 -0
  10. data/lib/docyard/builder.rb +10 -0
  11. data/lib/docyard/cli.rb +27 -0
  12. data/lib/docyard/config/branding_resolver.rb +1 -1
  13. data/lib/docyard/config/schema/definition.rb +2 -1
  14. data/lib/docyard/config/schema/sections.rb +0 -1
  15. data/lib/docyard/config/schema/simple_sections.rb +7 -0
  16. data/lib/docyard/config.rb +3 -1
  17. data/lib/docyard/customizer.rb +196 -0
  18. data/lib/docyard/rendering/markdown.rb +4 -0
  19. data/lib/docyard/rendering/og_helpers.rb +19 -1
  20. data/lib/docyard/rendering/renderer.rb +10 -1
  21. data/lib/docyard/search/build_indexer.rb +8 -3
  22. data/lib/docyard/search/dev_indexer.rb +3 -2
  23. data/lib/docyard/search/pagefind_binary.rb +185 -0
  24. data/lib/docyard/search/pagefind_support.rb +9 -7
  25. data/lib/docyard/server/asset_handler.rb +28 -2
  26. data/lib/docyard/server/dev_server.rb +1 -0
  27. data/lib/docyard/server/file_watcher.rb +10 -5
  28. data/lib/docyard/server/rack_application.rb +1 -1
  29. data/lib/docyard/templates/assets/css/variables.css +0 -6
  30. data/lib/docyard/templates/assets/js/components/abbreviation.js +20 -11
  31. data/lib/docyard/templates/assets/js/components/code-block.js +8 -3
  32. data/lib/docyard/templates/assets/js/components/code-group.js +20 -3
  33. data/lib/docyard/templates/assets/js/components/file-tree.js +9 -3
  34. data/lib/docyard/templates/assets/js/components/heading-anchor.js +71 -72
  35. data/lib/docyard/templates/assets/js/components/lightbox.js +10 -3
  36. data/lib/docyard/templates/assets/js/components/tabs.js +8 -3
  37. data/lib/docyard/templates/assets/js/components/tooltip.js +32 -23
  38. data/lib/docyard/templates/assets/js/hot-reload.js +42 -2
  39. data/lib/docyard/templates/partials/_head.html.erb +4 -0
  40. data/lib/docyard/templates/partials/_scripts.html.erb +3 -0
  41. data/lib/docyard/version.rb +1 -1
  42. data/lib/docyard.rb +2 -0
  43. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6ab36d72a5ae740f0ba203c29ab88b2698f0010bbc980ac0ef3a8b7d5f9f579
4
- data.tar.gz: f4df0ae89a078729eb319733ac7dab36943fa9e228daaba9fb75e6323a2f6fab
3
+ metadata.gz: fe9582d35d963462ff818674735f3b131607368f6adc6fdb70eac6152365efe8
4
+ data.tar.gz: a8e8b424702b4ca3ff2726a938ea0696f0b15bed3d82f449f803f64923bbbafc
5
5
  SHA512:
6
- metadata.gz: 4c4e05cb41cf1259a74a73c24c45fa265da9fbaf6725905e9893f442e9e1a2dbf1979ba03c5aac030bafd9e2f574fa0fec50f1f11ee5fd583353e3fd7b8329ea
7
- data.tar.gz: f53424206fd8224b815232c4c2babca237852829f4f43f8fe537f63136c65e7b25ae75d6276a4b07416b4c24cc8274b822946c6f03a2e6aa2c6f394cc5334327
6
+ metadata.gz: 4c6ae9f9e88f186d43aa13ef62c2c96d3c4ed65e0320558413df73915bd33478b673dae25dc4f3e3fdf6b0fe627ca595a8fc53cded6186ed3afb03ae08ef7f5d
7
+ data.tar.gz: 17ad431713b55f78b87a67b5de17db1e58c949895a08ff9d31b6f658aa277a8df8cab84f3d3ee178f9c70a9eb30557089fc0b732e2793b6c9b3c8054bf513b1c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0] - 2026-02-03
11
+
12
+ ### Added
13
+ - **Social Cards** - Auto-generate Open Graph images for social sharing with `social_cards.enabled: true` config (#146)
14
+ - **Customize Command** - Generate theme customization files with `docyard customize` for custom CSS variables and scripts (#147)
15
+ - **Pagefind Standalone Binary** - Download and cache Pagefind binary in `~/.docyard/bin/` for faster, more reliable search indexing without npx (#148)
16
+
17
+ ### Fixed
18
+ - **Hot Reload JS Reinitialization** - Components now properly reinitialize when content changes during development (#149)
19
+
20
+ ### Documentation
21
+ - Added new pages: Abbreviations component, Docs for AI (llms.txt)
22
+ - Removed Troubleshooting page
23
+ - Removed unsupported bluesky platform (33 platforms now supported)
24
+
10
25
  ## [1.1.0] - 2026-01-30
11
26
 
12
27
  ### Added
@@ -249,7 +264,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
249
264
  - Initial gem structure
250
265
  - Project scaffolding
251
266
 
252
- [Unreleased]: https://github.com/sanifhimani/docyard/compare/v1.1.0...HEAD
267
+ [Unreleased]: https://github.com/sanifhimani/docyard/compare/v1.2.0...HEAD
268
+ [1.2.0]: https://github.com/sanifhimani/docyard/compare/v1.1.0...v1.2.0
253
269
  [1.1.0]: https://github.com/sanifhimani/docyard/compare/v1.0.2...v1.1.0
254
270
  [1.0.2]: https://github.com/sanifhimani/docyard/compare/v1.0.1...v1.0.2
255
271
  [1.0.1]: https://github.com/sanifhimani/docyard/compare/v1.0.0...v1.0.1
data/README.md CHANGED
@@ -1,28 +1,73 @@
1
- # Docyard
1
+ <p align="center">
2
+ <a href="https://docyard.dev">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="docs/public/logo-dark.svg">
5
+ <img src="docs/public/logo.svg" height="60" alt="Docyard">
6
+ </picture>
7
+ </a>
8
+ </p>
2
9
 
3
- [![CI](https://github.com/sanifhimani/docyard/actions/workflows/ci.yml/badge.svg)](https://github.com/sanifhimani/docyard/actions/workflows/ci.yml)
4
- [![Gem Version](https://badge.fury.io/rb/docyard.svg)](https://badge.fury.io/rb/docyard)
10
+ <p align="center">
11
+ Markdown to docs in seconds. No Node.js required.
12
+ </p>
5
13
 
6
- Beautiful documentation sites from Markdown. Fast, simple, no configuration required.
14
+ <p align="center">
15
+ <a href="https://github.com/sanifhimani/docyard/actions/workflows/ci.yml"><img src="https://github.com/sanifhimani/docyard/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
16
+ <a href="https://badge.fury.io/rb/docyard"><img src="https://badge.fury.io/rb/docyard.svg" alt="Gem Version"></a>
17
+ <a href="https://github.com/sanifhimani/docyard/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
18
+ </p>
7
19
 
8
- ## Quick Start
20
+ <p align="center">
21
+ <a href="https://docyard.dev">Docs</a> ·
22
+ <a href="https://docyard.dev/getting-started/quickstart">Quickstart</a> ·
23
+ <a href="https://github.com/sanifhimani/docyard/blob/main/CHANGELOG.md">Changelog</a>
24
+ </p>
25
+
26
+ ---
27
+
28
+ ## Install
9
29
 
10
30
  ```bash
11
31
  gem install docyard
12
- docyard init my-docs
13
- cd my-docs
32
+ docyard init
14
33
  docyard serve
15
34
  ```
16
35
 
17
- Open http://localhost:4200 and start writing.
36
+ Open `localhost:4200`. Edits reload instantly.
37
+
38
+ ## Example
39
+
40
+ ```markdown
41
+ :::note
42
+ Requires Ruby 3.2 or higher.
43
+ :::
44
+
45
+ :::tabs
46
+ == macOS
47
+ brew install ruby
48
+
49
+ == Linux
50
+ sudo apt install ruby-full
51
+ :::
52
+ ```
53
+
54
+ Callouts, tabs, steps, cards, code groups, accordions, and more. [See all components](https://docyard.dev/write-content/components)
55
+
56
+ ## Build
57
+
58
+ ```bash
59
+ docyard build
60
+ ```
61
+
62
+ Static HTML, search index, sitemap, and social cards in `dist/`. Deploy anywhere.
18
63
 
19
- ## Documentation
64
+ ## Docs
20
65
 
21
- Visit [docyard.dev](https://docyard.dev) for the full documentation.
66
+ [docyard.dev](https://docyard.dev) is built with Docyard.
22
67
 
23
68
  ## Contributing
24
69
 
25
- See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
70
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
26
71
 
27
72
  ## License
28
73
 
@@ -32,6 +32,7 @@ module Docyard
32
32
  def bundle_css
33
33
  main_css = File.read(File.join(ASSETS_PATH, "css", "main.css"))
34
34
  css_content = resolve_css_imports(main_css)
35
+ css_content = append_custom_css(css_content)
35
36
  minified = CSSminify.compress(css_content)
36
37
  minified = fix_calc_whitespace(minified)
37
38
  minified = fix_css_math_functions(minified)
@@ -85,7 +86,8 @@ module Docyard
85
86
  def bundle_js
86
87
  theme_js = File.read(File.join(ASSETS_PATH, "js", "theme.js"))
87
88
  components_js = concatenate_component_js
88
- js_content = [theme_js, components_js].join("\n")
89
+ custom_js = load_custom_js
90
+ js_content = [theme_js, components_js, custom_js].compact.join("\n")
89
91
  minified = Terser.compile(js_content)
90
92
  minified = replace_js_asset_urls(minified)
91
93
  hash = generate_hash(minified)
@@ -146,6 +148,20 @@ module Docyard
146
148
  FileUtils.mkdir_p(File.dirname(output_path))
147
149
  File.write(output_path, content)
148
150
  end
151
+
152
+ def append_custom_css(css_content)
153
+ custom_path = File.join(config.source, "_custom", "styles.css")
154
+ return css_content unless File.exist?(custom_path)
155
+
156
+ "#{css_content}\n#{File.read(custom_path)}"
157
+ end
158
+
159
+ def load_custom_js
160
+ custom_path = File.join(config.source, "_custom", "scripts.js")
161
+ return nil unless File.exist?(custom_path)
162
+
163
+ File.read(custom_path)
164
+ end
149
165
  end
150
166
  end
151
167
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docyard
4
+ module Build
5
+ module SocialCards
6
+ class CardRenderer
7
+ VIPS_INSTALL_INSTRUCTIONS = <<~MSG.strip
8
+ Social cards require libvips to be installed.
9
+
10
+ Install libvips:
11
+ macOS: brew install vips
12
+ Ubuntu: sudo apt install libvips-dev
13
+ Fedora: sudo dnf install vips-devel
14
+
15
+ Then run: bundle install
16
+
17
+ Or disable social cards in docyard.yml:
18
+ social_cards:
19
+ enabled: false
20
+ MSG
21
+ WIDTH = 1200
22
+ HEIGHT = 630
23
+ BACKGROUND_COLOR = "#121212"
24
+ DEFAULT_BRAND_COLOR = "#22D3EE"
25
+ WHITE = "#FFFFFF"
26
+ GRAY = "#71717A"
27
+
28
+ PADDING = 88
29
+ LOGO_BOTTOM_OFFSET = 88
30
+
31
+ LOGO_ICON_WIDTH = 40
32
+ LOGO_ICON_HEIGHT = 58
33
+ LOGO_TEXT_SIZE = 28
34
+ LOGO_GAP = 16
35
+
36
+ attr_reader :config
37
+
38
+ def initialize(config)
39
+ @config = config
40
+ end
41
+
42
+ def render(output_path)
43
+ svg_content = build_svg
44
+ save_as_png(svg_content, output_path)
45
+ end
46
+
47
+ protected
48
+
49
+ def build_svg
50
+ <<~SVG
51
+ <svg xmlns="http://www.w3.org/2000/svg" width="#{WIDTH}" height="#{HEIGHT}" viewBox="0 0 #{WIDTH} #{HEIGHT}">
52
+ <defs>
53
+ <style>
54
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&amp;display=swap');
55
+ .inter { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
56
+ </style>
57
+ </defs>
58
+ <rect width="#{WIDTH}" height="#{HEIGHT}" fill="#{BACKGROUND_COLOR}"/>
59
+ #{content_svg}
60
+ #{logo_svg}
61
+ </svg>
62
+ SVG
63
+ end
64
+
65
+ def content_svg
66
+ raise NotImplementedError, "Subclasses must implement content_svg"
67
+ end
68
+
69
+ def logo_svg
70
+ site_title = escape_xml(config.title || "Docyard")
71
+ logo_y = HEIGHT - LOGO_BOTTOM_OFFSET
72
+ logo_x, text_anchor = logo_position_and_anchor
73
+
74
+ <<~SVG
75
+ <g transform="translate(#{logo_x}, #{logo_y})">
76
+ #{logo_icon_svg(0, -LOGO_ICON_HEIGHT)}
77
+ <text x="#{LOGO_ICON_WIDTH + LOGO_GAP}" y="-#{(LOGO_ICON_HEIGHT / 2) - 10}" class="inter" font-size="#{LOGO_TEXT_SIZE}" font-weight="700" fill="#{WHITE}" text-anchor="#{text_anchor}">#{site_title}</text>
78
+ </g>
79
+ SVG
80
+ end
81
+
82
+ def logo_position_and_anchor
83
+ [PADDING, "start"]
84
+ end
85
+
86
+ def logo_icon_svg(x_offset, y_offset)
87
+ color = brand_color
88
+ scale = LOGO_ICON_WIDTH.to_f / 531
89
+ <<~SVG
90
+ <g transform="translate(#{x_offset}, #{y_offset}) scale(#{scale})">
91
+ <path fill="#{color}" d="M359.643 59.1798C402.213 89.4398 449.713 123.6 502.063 160.99C510.793 167.23 515.873 170.31 519.293 178.05C523.253 187.02 521.733 198.11 515.883 205.77C513.77 208.536 510.93 211.2 507.363 213.76C379.643 305.353 309.413 355.73 296.673 364.89C287.987 371.136 282.07 374.8 278.923 375.88C269.703 379.026 260.263 378.636 250.603 374.71C248.243 373.75 244.497 371.416 239.363 367.71C199.963 339.29 177.32 322.99 171.433 318.81C128.863 288.54 81.3733 254.39 29.0233 216.99C20.2833 210.75 15.2033 207.67 11.7833 199.93C7.82332 190.96 9.34332 179.87 15.1933 172.21C17.3067 169.443 20.1467 166.78 23.7133 164.22C151.433 72.6264 221.663 22.2498 234.403 13.0898C243.09 6.84309 249.007 3.17976 252.153 2.09976C261.373 -1.04691 270.813 -0.656912 280.473 3.26976C282.833 4.22976 286.58 6.56309 291.713 10.2698C331.113 38.6898 353.757 54.9931 359.643 59.1798Z"/>
92
+ <path fill="#{WHITE}" d="M467.383 298.01C483.943 286.23 505.033 289.93 519.063 303.51C524.457 308.723 528.033 314.713 529.793 321.48C530.433 323.92 530.733 330.946 530.693 342.56C530.647 356.206 530.657 427.233 530.723 555.64C530.723 566.633 530.513 573 530.093 574.74C527.033 587.29 518.333 592.61 506.693 601.06C504.313 602.786 430.877 656.346 286.383 761.74C275.623 769.59 261.793 770.79 250.113 764.36C249.18 763.846 245.86 761.513 240.153 757.36C150.56 692.066 74.8667 637.046 13.0733 592.3C6.70001 587.68 2.65667 581.73 0.943337 574.45C0.316671 571.783 0.00333476 564.803 0.00333476 553.51C-0.00333191 421.323 -4.06895e-06 348.98 0.0133293 336.48C0.0133293 332.84 -0.0766665 327.18 0.783334 323.18C4.59333 305.51 20.1033 293.29 37.4533 291.15C42.9467 290.476 48.8667 291.276 55.2133 293.55C58.28 294.643 63.3533 297.8 70.4333 303.02C75.98 307.113 82.4433 311.78 89.8233 317.02C128.563 344.526 178.703 380.303 240.243 424.35C242.73 426.13 245.853 428.246 249.613 430.7C257.443 435.8 268.453 436.24 277.213 433.14C279.8 432.22 284.54 429.283 291.433 424.33C394.46 350.276 453.11 308.17 467.383 298.01Z"/>
93
+ </g>
94
+ SVG
95
+ end
96
+
97
+ def brand_color
98
+ color = config.branding.color
99
+ if color.is_a?(Hash)
100
+ color["dark"] || color["light"] || DEFAULT_BRAND_COLOR
101
+ elsif color.is_a?(String) && !color.strip.empty?
102
+ color.strip
103
+ else
104
+ DEFAULT_BRAND_COLOR
105
+ end
106
+ end
107
+
108
+ def escape_xml(text)
109
+ text.to_s
110
+ .gsub("&", "&amp;")
111
+ .gsub("<", "&lt;")
112
+ .gsub(">", "&gt;")
113
+ .gsub('"', "&quot;")
114
+ .gsub("'", "&apos;")
115
+ end
116
+
117
+ def save_as_png(svg_content, output_path)
118
+ require_vips!
119
+ FileUtils.mkdir_p(File.dirname(output_path))
120
+ image = Vips::Image.svgload_buffer(svg_content, dpi: 96)
121
+ image.write_to_file(output_path, compression: 9, palette: true)
122
+ end
123
+
124
+ def require_vips!
125
+ require "vips"
126
+ rescue LoadError
127
+ raise Docyard::Error, VIPS_INSTALL_INSTRUCTIONS
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "card_renderer"
4
+
5
+ module Docyard
6
+ module Build
7
+ module SocialCards
8
+ class DocCard < CardRenderer
9
+ SECTION_LABEL_SIZE = 26
10
+ TITLE_SIZE = 92
11
+ DESCRIPTION_SIZE = 30
12
+
13
+ SECTION_TO_TITLE_GAP = 16
14
+ TITLE_TO_DESC_GAP = 24
15
+
16
+ LOGO_AREA_HEIGHT = 160
17
+
18
+ TITLE_MAX_CHARS = 22
19
+ DESCRIPTION_MAX_CHARS = 70
20
+
21
+ def initialize(config, title:, section: nil, description: nil)
22
+ super(config)
23
+ @title = truncate_text(title, TITLE_MAX_CHARS)
24
+ @section = section
25
+ @description = truncate_text(description, DESCRIPTION_MAX_CHARS)
26
+ end
27
+
28
+ protected
29
+
30
+ def content_svg
31
+ start_y = calculate_start_y
32
+ elements = []
33
+ y_pos = start_y
34
+
35
+ if @section && !@section.empty?
36
+ elements << section_svg(y_pos)
37
+ y_pos += SECTION_TO_TITLE_GAP + TITLE_SIZE
38
+ else
39
+ y_pos += TITLE_SIZE
40
+ end
41
+
42
+ elements << title_svg(y_pos)
43
+
44
+ if @description && !@description.empty?
45
+ y_pos += TITLE_TO_DESC_GAP + DESCRIPTION_SIZE
46
+ elements << description_svg(y_pos)
47
+ end
48
+
49
+ elements.join("\n")
50
+ end
51
+
52
+ private
53
+
54
+ def calculate_start_y
55
+ total_height = calculate_content_height
56
+ available_height = HEIGHT - LOGO_AREA_HEIGHT
57
+ (available_height - total_height) / 2
58
+ end
59
+
60
+ def calculate_content_height
61
+ height = TITLE_SIZE
62
+ height += SECTION_LABEL_SIZE + SECTION_TO_TITLE_GAP if @section && !@section.empty?
63
+ height += TITLE_TO_DESC_GAP + DESCRIPTION_SIZE if @description && !@description.empty?
64
+ height
65
+ end
66
+
67
+ def section_svg(y_pos)
68
+ section_text = escape_xml(@section.upcase)
69
+ <<~SVG
70
+ <text x="#{PADDING}" y="#{y_pos}" class="inter" font-size="#{SECTION_LABEL_SIZE}" font-weight="600" fill="#{brand_color}" letter-spacing="0.5">#{section_text}</text>
71
+ SVG
72
+ end
73
+
74
+ def title_svg(y_pos)
75
+ title_text = escape_xml(@title)
76
+ <<~SVG
77
+ <text x="#{PADDING}" y="#{y_pos}" class="inter" font-size="#{TITLE_SIZE}" font-weight="800" fill="#{WHITE}" letter-spacing="-0.02em">#{title_text}</text>
78
+ SVG
79
+ end
80
+
81
+ def description_svg(y_pos)
82
+ desc_text = escape_xml(@description)
83
+ <<~SVG
84
+ <text x="#{PADDING}" y="#{y_pos}" class="inter" font-size="#{DESCRIPTION_SIZE}" font-weight="400" fill="#{GRAY}">#{desc_text}</text>
85
+ SVG
86
+ end
87
+
88
+ def truncate_text(text, max_chars)
89
+ return nil if text.nil?
90
+ return text if text.length <= max_chars
91
+
92
+ "#{text[0, max_chars - 3].strip}..."
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "card_renderer"
4
+
5
+ module Docyard
6
+ module Build
7
+ module SocialCards
8
+ class HomepageCard < CardRenderer
9
+ TITLE_SIZE = 84
10
+ TITLE_MAX_WIDTH = 1024
11
+
12
+ def initialize(config, title:)
13
+ super(config)
14
+ @title = title
15
+ end
16
+
17
+ protected
18
+
19
+ def content_svg
20
+ title_text = escape_xml(@title)
21
+ logo_area = LOGO_ICON_HEIGHT + LOGO_BOTTOM_OFFSET + 40
22
+ title_y = (HEIGHT - logo_area) / 2
23
+
24
+ <<~SVG
25
+ #{background_curves}
26
+ <text x="#{WIDTH / 2}" y="#{title_y}" class="inter" font-size="#{TITLE_SIZE}" font-weight="700" fill="#{brand_color}" text-anchor="middle" dominant-baseline="middle">
27
+ #{wrap_text(title_text, TITLE_SIZE, TITLE_MAX_WIDTH)}
28
+ </text>
29
+ SVG
30
+ end
31
+
32
+ def background_curves
33
+ <<~SVG
34
+ <g fill="none" stroke="#1f1f1f" stroke-width="4">
35
+ <!-- Top left flowing down -->
36
+ <ellipse cx="-200" cy="-100" rx="500" ry="400"/>
37
+ <ellipse cx="-300" cy="200" rx="600" ry="500"/>
38
+ <!-- Top right diagonal -->
39
+ <ellipse cx="1400" cy="-200" rx="550" ry="450"/>
40
+ <ellipse cx="1100" cy="-300" rx="700" ry="600"/>
41
+ <!-- Bottom crossing -->
42
+ <ellipse cx="200" cy="800" rx="500" ry="400"/>
43
+ <ellipse cx="1000" cy="900" rx="600" ry="450"/>
44
+ <!-- Mid crossing -->
45
+ <ellipse cx="1500" cy="400" rx="450" ry="550"/>
46
+ </g>
47
+ SVG
48
+ end
49
+
50
+ def logo_position_and_anchor
51
+ [
52
+ (WIDTH / 2) - ((LOGO_ICON_WIDTH + LOGO_GAP + estimate_text_width(config.title || "Docyard",
53
+ LOGO_TEXT_SIZE)) / 2), "start"
54
+ ]
55
+ end
56
+
57
+ private
58
+
59
+ def wrap_text(text, font_size, max_width)
60
+ lines = split_into_lines(text, max_width, font_size)
61
+ lines_to_tspans(lines, font_size)
62
+ end
63
+
64
+ def split_into_lines(text, max_width, font_size)
65
+ chars_per_line = (max_width / (font_size * 0.5)).to_i
66
+ words = text.split
67
+ lines = []
68
+ current_line = []
69
+
70
+ words.each { |word| current_line, lines = process_word(word, current_line, lines, chars_per_line) }
71
+ lines << current_line.join(" ") if current_line.any?
72
+ lines
73
+ end
74
+
75
+ def process_word(word, current_line, lines, chars_per_line)
76
+ test_line = (current_line + [word]).join(" ")
77
+ if test_line.length > chars_per_line && current_line.any?
78
+ lines << current_line.join(" ")
79
+ [[word], lines]
80
+ else
81
+ [current_line + [word], lines]
82
+ end
83
+ end
84
+
85
+ def lines_to_tspans(lines, font_size)
86
+ lines.map.with_index do |line, i|
87
+ dy = i.zero? ? 0 : font_size * 1.15
88
+ %(<tspan x="#{WIDTH / 2}" dy="#{dy}">#{line}</tspan>)
89
+ end.join
90
+ end
91
+
92
+ def estimate_text_width(text, font_size)
93
+ text.length * font_size * 0.55
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+ require "parallel"
6
+ require_relative "social_cards/card_renderer"
7
+ require_relative "social_cards/homepage_card"
8
+ require_relative "social_cards/doc_card"
9
+
10
+ module Docyard
11
+ module Build
12
+ class SocialCardsGenerator
13
+ OUTPUT_DIR = "_docyard/og"
14
+ FRONTMATTER_REGEX = /\A---\s*\n(.*?\n)---\s*\n/m
15
+ HOMEPAGE_PATHS = ["/", "/index"].freeze
16
+ PARALLEL_THRESHOLD = 5
17
+
18
+ attr_reader :config, :verbose
19
+
20
+ def initialize(config, verbose: false)
21
+ @config = config
22
+ @verbose = verbose
23
+ @generated_cards = []
24
+ @failed_cards = []
25
+ end
26
+
27
+ def generate
28
+ ensure_vips_available!
29
+ pages = collect_pages
30
+ generate_cards_for(pages)
31
+ [successful_count, build_verbose_details]
32
+ end
33
+
34
+ def successful_count
35
+ @generated_cards.compact.size
36
+ end
37
+
38
+ def card_path_for(url_path)
39
+ File.join("/", OUTPUT_DIR, url_to_filename(url_path))
40
+ end
41
+
42
+ private
43
+
44
+ def generate_cards_for(pages)
45
+ if pages.size >= PARALLEL_THRESHOLD
46
+ results = Parallel.map(pages, in_threads: Parallel.processor_count) do |page|
47
+ generate_card(page)
48
+ end
49
+ @generated_cards = results.compact
50
+ else
51
+ pages.each { |page| @generated_cards << generate_card(page) }
52
+ end
53
+ end
54
+
55
+ def collect_pages
56
+ pages = []
57
+
58
+ Dir.glob(File.join(docs_path, "**", "*.md")).each do |file_path|
59
+ frontmatter = extract_frontmatter(file_path)
60
+ relative_path = file_path.delete_prefix("#{docs_path}/")
61
+ url_path = markdown_to_url(relative_path)
62
+
63
+ pages << build_page_data(file_path, url_path, frontmatter)
64
+ end
65
+
66
+ pages
67
+ end
68
+
69
+ def build_page_data(file_path, url_path, frontmatter)
70
+ social_cards_config = frontmatter["social_cards"] || {}
71
+ section = derive_section(url_path)
72
+
73
+ {
74
+ file_path: file_path,
75
+ url_path: url_path,
76
+ title: resolve_title(social_cards_config, frontmatter, url_path),
77
+ description: social_cards_config["description"] || frontmatter["description"],
78
+ section: section,
79
+ is_homepage: homepage?(url_path)
80
+ }
81
+ end
82
+
83
+ def resolve_title(social_cards_config, frontmatter, url_path)
84
+ title = social_cards_config["title"]
85
+ return title if title && !title.strip.empty?
86
+
87
+ title = frontmatter["title"]
88
+ return title if title && !title.strip.empty?
89
+
90
+ derive_title_from_path(url_path)
91
+ end
92
+
93
+ def ensure_vips_available!
94
+ require "vips"
95
+ rescue LoadError
96
+ raise Docyard::Error, SocialCards::CardRenderer::VIPS_INSTALL_INSTRUCTIONS
97
+ end
98
+
99
+ def generate_card(page)
100
+ output_path = File.join(config.build.output, OUTPUT_DIR, url_to_filename(page[:url_path]))
101
+ card = build_card_for(page)
102
+ card.render(output_path)
103
+ output_path
104
+ rescue StandardError => e
105
+ log_card_error(page, e.message)
106
+ nil
107
+ end
108
+
109
+ def log_card_error(page, message)
110
+ @failed_cards << page[:url_path]
111
+ Docyard.logger.warn("Social card generation failed for #{page[:url_path]}: #{message}")
112
+ end
113
+
114
+ def build_card_for(page)
115
+ if page[:is_homepage]
116
+ SocialCards::HomepageCard.new(config, title: page[:title])
117
+ else
118
+ SocialCards::DocCard.new(
119
+ config,
120
+ title: page[:title],
121
+ section: page[:section],
122
+ description: page[:description]
123
+ )
124
+ end
125
+ end
126
+
127
+ def extract_frontmatter(file_path)
128
+ content = File.read(file_path)
129
+ match = content.match(FRONTMATTER_REGEX)
130
+ return {} unless match
131
+
132
+ YAML.safe_load(match[1]) || {}
133
+ rescue Psych::SyntaxError
134
+ {}
135
+ end
136
+
137
+ def markdown_to_url(relative_path)
138
+ path = relative_path
139
+ .delete_suffix(".md")
140
+ .delete_suffix("/index")
141
+
142
+ path = "index" if path == "index" || path.empty?
143
+ "/#{path}"
144
+ end
145
+
146
+ def url_to_filename(url_path)
147
+ path = url_path.delete_prefix("/")
148
+ path = "index" if path.empty?
149
+ "#{path}.png"
150
+ end
151
+
152
+ def homepage?(url_path)
153
+ HOMEPAGE_PATHS.include?(url_path)
154
+ end
155
+
156
+ def derive_section(url_path)
157
+ parts = url_path.delete_prefix("/").split("/")
158
+ return nil if parts.size <= 1
159
+
160
+ parts.first.tr("-", " ").split.map(&:capitalize).join(" ")
161
+ end
162
+
163
+ def derive_title_from_path(url_path)
164
+ filename = File.basename(url_path)
165
+ filename = "Home" if filename == "index" || filename.empty?
166
+ filename.tr("-", " ").split.map(&:capitalize).join(" ")
167
+ end
168
+
169
+ def docs_path
170
+ config.source
171
+ end
172
+
173
+ def build_verbose_details
174
+ return nil unless verbose
175
+
176
+ details = @generated_cards.compact.map do |path|
177
+ path.delete_prefix("#{config.build.output}/")
178
+ end
179
+
180
+ @failed_cards.each do |url_path|
181
+ details << "FAILED: #{url_path}"
182
+ end
183
+
184
+ details
185
+ end
186
+ end
187
+ end
188
+ end