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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -1
- data/README.md +56 -11
- data/lib/docyard/build/asset_bundler.rb +17 -1
- data/lib/docyard/build/social_cards/card_renderer.rb +132 -0
- data/lib/docyard/build/social_cards/doc_card.rb +97 -0
- data/lib/docyard/build/social_cards/homepage_card.rb +98 -0
- data/lib/docyard/build/social_cards_generator.rb +188 -0
- data/lib/docyard/build/step_runner.rb +2 -0
- data/lib/docyard/builder.rb +10 -0
- data/lib/docyard/cli.rb +27 -0
- data/lib/docyard/config/branding_resolver.rb +1 -1
- data/lib/docyard/config/schema/definition.rb +2 -1
- data/lib/docyard/config/schema/sections.rb +0 -1
- data/lib/docyard/config/schema/simple_sections.rb +7 -0
- data/lib/docyard/config.rb +3 -1
- data/lib/docyard/customizer.rb +196 -0
- data/lib/docyard/rendering/markdown.rb +4 -0
- data/lib/docyard/rendering/og_helpers.rb +19 -1
- data/lib/docyard/rendering/renderer.rb +10 -1
- data/lib/docyard/search/build_indexer.rb +8 -3
- data/lib/docyard/search/dev_indexer.rb +3 -2
- data/lib/docyard/search/pagefind_binary.rb +185 -0
- data/lib/docyard/search/pagefind_support.rb +9 -7
- data/lib/docyard/server/asset_handler.rb +28 -2
- data/lib/docyard/server/dev_server.rb +1 -0
- data/lib/docyard/server/file_watcher.rb +10 -5
- data/lib/docyard/server/rack_application.rb +1 -1
- data/lib/docyard/templates/assets/css/variables.css +0 -6
- data/lib/docyard/templates/assets/js/components/abbreviation.js +20 -11
- data/lib/docyard/templates/assets/js/components/code-block.js +8 -3
- data/lib/docyard/templates/assets/js/components/code-group.js +20 -3
- data/lib/docyard/templates/assets/js/components/file-tree.js +9 -3
- data/lib/docyard/templates/assets/js/components/heading-anchor.js +71 -72
- data/lib/docyard/templates/assets/js/components/lightbox.js +10 -3
- data/lib/docyard/templates/assets/js/components/tabs.js +8 -3
- data/lib/docyard/templates/assets/js/components/tooltip.js +32 -23
- data/lib/docyard/templates/assets/js/hot-reload.js +42 -2
- data/lib/docyard/templates/partials/_head.html.erb +4 -0
- data/lib/docyard/templates/partials/_scripts.html.erb +3 -0
- data/lib/docyard/version.rb +1 -1
- data/lib/docyard.rb +2 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe9582d35d963462ff818674735f3b131607368f6adc6fdb70eac6152365efe8
|
|
4
|
+
data.tar.gz: a8e8b424702b4ca3ff2726a938ea0696f0b15bed3d82f449f803f64923bbbafc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
10
|
+
<p align="center">
|
|
11
|
+
Markdown to docs in seconds. No Node.js required.
|
|
12
|
+
</p>
|
|
5
13
|
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
cd my-docs
|
|
32
|
+
docyard init
|
|
14
33
|
docyard serve
|
|
15
34
|
```
|
|
16
35
|
|
|
17
|
-
Open
|
|
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
|
-
##
|
|
64
|
+
## Docs
|
|
20
65
|
|
|
21
|
-
|
|
66
|
+
[docyard.dev](https://docyard.dev) is built with Docyard.
|
|
22
67
|
|
|
23
68
|
## Contributing
|
|
24
69
|
|
|
25
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
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
|
-
|
|
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&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("&", "&")
|
|
111
|
+
.gsub("<", "<")
|
|
112
|
+
.gsub(">", ">")
|
|
113
|
+
.gsub('"', """)
|
|
114
|
+
.gsub("'", "'")
|
|
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
|