docyard 1.1.0 → 1.3.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -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 +44 -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/deploy/adapters/base.rb +56 -0
  19. data/lib/docyard/deploy/adapters/cloudflare.rb +38 -0
  20. data/lib/docyard/deploy/adapters/github_pages.rb +60 -0
  21. data/lib/docyard/deploy/adapters/netlify.rb +37 -0
  22. data/lib/docyard/deploy/adapters/vercel.rb +36 -0
  23. data/lib/docyard/deploy/deployer.rb +95 -0
  24. data/lib/docyard/deploy/platform_detector.rb +32 -0
  25. data/lib/docyard/errors.rb +2 -0
  26. data/lib/docyard/rendering/markdown.rb +4 -0
  27. data/lib/docyard/rendering/og_helpers.rb +19 -1
  28. data/lib/docyard/rendering/renderer.rb +10 -1
  29. data/lib/docyard/search/build_indexer.rb +8 -3
  30. data/lib/docyard/search/dev_indexer.rb +3 -2
  31. data/lib/docyard/search/pagefind_binary.rb +185 -0
  32. data/lib/docyard/search/pagefind_support.rb +9 -7
  33. data/lib/docyard/server/asset_handler.rb +28 -2
  34. data/lib/docyard/server/dev_server.rb +1 -0
  35. data/lib/docyard/server/file_watcher.rb +10 -5
  36. data/lib/docyard/server/rack_application.rb +1 -1
  37. data/lib/docyard/templates/assets/css/variables.css +0 -6
  38. data/lib/docyard/templates/assets/js/components/abbreviation.js +20 -11
  39. data/lib/docyard/templates/assets/js/components/code-block.js +8 -3
  40. data/lib/docyard/templates/assets/js/components/code-group.js +20 -3
  41. data/lib/docyard/templates/assets/js/components/file-tree.js +9 -3
  42. data/lib/docyard/templates/assets/js/components/heading-anchor.js +71 -72
  43. data/lib/docyard/templates/assets/js/components/lightbox.js +10 -3
  44. data/lib/docyard/templates/assets/js/components/tabs.js +8 -3
  45. data/lib/docyard/templates/assets/js/components/tooltip.js +32 -23
  46. data/lib/docyard/templates/assets/js/hot-reload.js +42 -2
  47. data/lib/docyard/templates/partials/_head.html.erb +4 -0
  48. data/lib/docyard/templates/partials/_scripts.html.erb +3 -0
  49. data/lib/docyard/version.rb +1 -1
  50. data/lib/docyard.rb +2 -0
  51. metadata +14 -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: 4892fa130361d02f347660d81932135f00c71d339c28b292e7bfecbe46ededa0
4
+ data.tar.gz: 4697d95492f675b935db0dad134c11eb0d57f7aca611dd25c3e4e22bded9f031
5
5
  SHA512:
6
- metadata.gz: 4c4e05cb41cf1259a74a73c24c45fa265da9fbaf6725905e9893f442e9e1a2dbf1979ba03c5aac030bafd9e2f574fa0fec50f1f11ee5fd583353e3fd7b8329ea
7
- data.tar.gz: f53424206fd8224b815232c4c2babca237852829f4f43f8fe537f63136c65e7b25ae75d6276a4b07416b4c24cc8274b822946c6f03a2e6aa2c6f394cc5334327
6
+ metadata.gz: 342e2e7f16c32da9da273b6325532ad8ee842d58304faf309a0e634be5f7c2d3a6a8c5eb0176b09e0b518a67150c25223b5ad3be9c67da90e974e4be9871e28e
7
+ data.tar.gz: 2f1b65cde20d044348908884fbfd0a185d47d6ef73d9fdd516e5f11792262700550bb70d6ec7523dc910dcaed864d2df51bf6f54480997092c63dfe926b6629d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-02-17
11
+
12
+ ### Added
13
+ - **Deploy Command** - One-command deployment with `docyard deploy` supporting Vercel, Netlify, Cloudflare Pages, and GitHub Pages (#153)
14
+ - **Platform Auto-Detection** - Automatically detects deployment platform from project config files (e.g. `vercel.json`, `netlify.toml`)
15
+
16
+ ### Documentation
17
+ - Added Deploy Command page with per-platform setup instructions
18
+ - Updated CLI reference with `docyard deploy` options
19
+ - Cross-linked existing GitHub Pages, Vercel, and Netlify docs to deploy command
20
+
21
+ ## [1.2.0] - 2026-02-03
22
+
23
+ ### Added
24
+ - **Social Cards** - Auto-generate Open Graph images for social sharing with `social_cards.enabled: true` config (#146)
25
+ - **Customize Command** - Generate theme customization files with `docyard customize` for custom CSS variables and scripts (#147)
26
+ - **Pagefind Standalone Binary** - Download and cache Pagefind binary in `~/.docyard/bin/` for faster, more reliable search indexing without npx (#148)
27
+
28
+ ### Fixed
29
+ - **Hot Reload JS Reinitialization** - Components now properly reinitialize when content changes during development (#149)
30
+
31
+ ### Documentation
32
+ - Added new pages: Abbreviations component, Docs for AI (llms.txt)
33
+ - Removed Troubleshooting page
34
+ - Removed unsupported bluesky platform (33 platforms now supported)
35
+
10
36
  ## [1.1.0] - 2026-01-30
11
37
 
12
38
  ### Added
@@ -249,7 +275,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
249
275
  - Initial gem structure
250
276
  - Project scaffolding
251
277
 
252
- [Unreleased]: https://github.com/sanifhimani/docyard/compare/v1.1.0...HEAD
278
+ [Unreleased]: https://github.com/sanifhimani/docyard/compare/v1.3.0...HEAD
279
+ [1.3.0]: https://github.com/sanifhimani/docyard/compare/v1.2.0...v1.3.0
280
+ [1.2.0]: https://github.com/sanifhimani/docyard/compare/v1.1.0...v1.2.0
253
281
  [1.1.0]: https://github.com/sanifhimani/docyard/compare/v1.0.2...v1.1.0
254
282
  [1.0.2]: https://github.com/sanifhimani/docyard/compare/v1.0.1...v1.0.2
255
283
  [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