docs-kit 1.0.0 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee1f1a74269d5f9c8595eee3fe8c2275f9082a3f83f642671cb2227b6113fbf5
4
- data.tar.gz: b1f465550e55b8ebc2159635706530391aa881f6cd6413fb4e0d71c79f810055
3
+ metadata.gz: df9e88e060d79197aaf8f9247000b31f5da3568bbf7099f18f59bc216b11e877
4
+ data.tar.gz: 1fde31a5d995087e06b62f2b58983e73acfc12309d6958db6e913f538894edbf
5
5
  SHA512:
6
- metadata.gz: 4f2a568efb0a6a0766fb72065a0721976ff84968d49cbada10597700f53adb6db3b31ca73d17f022212d4ce7da474d499c7d14f54e2e284122cc2b1acebd5a69
7
- data.tar.gz: 04a0e1a8fcaedcb330d7a35aa9a1fe7e799412795bd6062524d86834591d677fa5947fcf56c232f220b905fb0b4a5d2d6dc21de09d12615dafa0a5f0a2e9d5b1
6
+ metadata.gz: 8c8de40c603fa96e5fbee8aa377816894566c3b4f40c632e51d1a1055b875fe48b3349ef9cc833f0bada6b85d3504cfdc433ef27c9cdb9fff16ce30e81f0aa16
7
+ data.tar.gz: 7230b76a2e9d68d98061c92b2002ad59a2ca9a4609df75d8de2c68f4d911c78938188e1b87d1ced4c3ad7ff52ea4e9e6105fe8b0d2f43c27da826b77297f82fb
data/CHANGELOG.md CHANGED
@@ -2,8 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Fixed
6
+
7
+ - **SEO `og:image` 404.** The og:image tag pointed at the raw config path
8
+ (`https://site/og/og.png`), which isn't a served URL — Propshaft serves the
9
+ digested asset under `/assets`. A relative `og_image` is now resolved through
10
+ the site's asset pipeline (`image_url`) to the digested `/assets/og/og-<digest>.png`
11
+ URL, and the image is treated as **site content**: the gem ships none,
12
+ `c.seo.og_image` defaults to **nil** (unset → no og:image tag, never a 404), and
13
+ the `docs_kit:og` task writes into the *site's* `app/assets/images/`. Added a
14
+ booted-app integration test in the dogfood site
15
+ (`docs/test/integration/seo_meta_tags_test.rb`) that asserts og:image resolves
16
+ to a `/assets` URL that actually returns 200 — the coverage isolated component
17
+ specs can't provide.
18
+
5
19
  ### Added
6
20
 
21
+ - **SEO + social sharing.** Every page now emits a complete SEO `<head>` —
22
+ meta description, Open Graph, Twitter Card, canonical, favicon, robots, and
23
+ theme-color — via the new `DocsUI::MetaTags` component, driven entirely by a
24
+ new `c.seo` config block (`DocsKit::SeoConfig`). Pages carry an authorable
25
+ `description "..."` (falling back to the page's `#lead`). The social-share image
26
+ is site content (not shipped by the gem): the `docs_kit:og` rake task screenshots
27
+ a site's OWN landing page into its `app/assets/images/og/` (host-side headless
28
+ browser; never a gem runtime dependency), and `c.seo.og_image` points at it.
29
+ Backwards-compatible: a site that sets no `c.seo` renders a valid minimal card
30
+ and its `<head>` is a strict superset of before. The install generator documents
31
+ `c.seo` and installs the task; `docs-kit new` reminds the owner to run it. (#48)
7
32
  - Release tooling matching the sibling gems (daisyui/phlex-reactive/pgbus): a
8
33
  `rake release[X.Y.Z]` task (version bump → lockfile update → build-verify →
9
34
  commit → push → GitHub Release; `pre`/`force` supported, `main`-only, clean-tree
data/README.md CHANGED
@@ -169,6 +169,57 @@ Any other `icon:` value is treated as a **lucide** name and rendered through
169
169
  sets nothing has an unchanged topbar. The marks use `fill: currentColor`, so they
170
170
  recolor with the active daisyUI theme like the rest of the chrome.
171
171
 
172
+ ### SEO & social sharing
173
+
174
+ Every page ships a complete SEO `<head>` — a meta description, **Open Graph**,
175
+ **Twitter Card**, canonical, favicon, and theme-color — so a link shared to
176
+ Slack/Discord/X/LinkedIn renders a rich card instead of a bare URL. It's all
177
+ config-driven (`DocsUI::MetaTags` reads `DocsKit.configuration`); a site that sets
178
+ nothing still gets a valid minimal card, so this is fully backwards-compatible.
179
+
180
+ ```ruby
181
+ # config/initializers/docs_kit.rb
182
+ c.seo.description = "What these docs cover, in one sentence."
183
+ c.seo.og_image = "og/og.png" # a path in YOUR app/assets/images/
184
+ c.seo.twitter_site = "@your_handle"
185
+ c.seo.site_url = "https://docs.example.com" # your canonical base URL
186
+ # c.seo.robots = "noindex, nofollow" # keep a staging/private site out of search
187
+ # c.seo.theme_color = "#0f172a" # tints mobile browser chrome
188
+ ```
189
+
190
+ **Per-page descriptions.** A `DocsUI::Page` sets its own with `description "..."`;
191
+ when it doesn't, the description is derived from the page's `#lead`, so existing
192
+ pages get a sensible per-page description for free:
193
+
194
+ ```ruby
195
+ class Views::Docs::Pages::Installation < DocsUI::Page
196
+ title "Installation"
197
+ description "Add the gem and render your first component." # optional
198
+ def lead = "..." # used as the description when none is set
199
+ end
200
+ ```
201
+
202
+ **The social-share image is your content, not the gem's.** docs-kit ships no OG
203
+ image — your landing page isn't docs-kit's to render. Until you set `c.seo.og_image`,
204
+ **no `og:image` tag is emitted** (a valid card, never a broken-image 404). Generate
205
+ one from your **own** landing page — it screenshots `/` into
206
+ `app/assets/images/og/{og,twitter,square}.png` — then point `og_image` at it:
207
+
208
+ ```bash
209
+ bin/rails docs_kit:og # needs a headless browser: shot-scraper or chromium/chrome
210
+ ```
211
+
212
+ `og_image` is a **logical asset path in your pipeline** (`"og/og.png"`), resolved
213
+ through `image_url` to the digested `/assets/og/og-<digest>.png` URL Propshaft
214
+ serves — never the raw path, which 404s. So the image must be **precompiled**
215
+ (your Dockerfile's `assets:precompile` handles this at deploy); a configured
216
+ `og_image` that isn't in the pipeline fails loudly at deploy, not silently in
217
+ production. An absolute URL (`"https://cdn.example.com/card.png"`) is used verbatim.
218
+
219
+ `docs_kit:og` is a documented, manual routine (never run at deploy time), so a
220
+ machine without a browser is never blocked. Set `DOCS_KIT_OG_URL` to shoot a
221
+ deployed URL instead of booting locally, or `DOCS_KIT_SHOT` to force a browser CLI.
222
+
172
223
  ### Custom nav (advanced)
173
224
 
174
225
  Sites that interleave several registries under a heading, or need custom
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # The SEO / social-share <head> tags: description, Open Graph, Twitter Card,
5
+ # canonical, favicon, robots, theme-color. Rendered by DocsUI::Shell inside
6
+ # <head>, driven entirely by DocsKit.configuration (+ .seo) and the per-page
7
+ # title/description — so every docs site is share-ready with zero markup, and a
8
+ # site tunes it through config alone (no Shell subclass).
9
+ #
10
+ # render DocsUI::MetaTags.new(title: "Installation", description: "…")
11
+ #
12
+ # All free text (title, description, brand) is emitted as normal Phlex
13
+ # attribute values, so Phlex escapes it — config free text is never trusted
14
+ # markup. The og:image is SITE content (nil by default → no og:image tag): a
15
+ # relative og_image is resolved through the SITE'S asset pipeline (image_url) to
16
+ # the DIGESTED /assets URL Propshaft serves — NOT the raw config path, which
17
+ # 404s; an absolute URL passes through. canonical/og:url come from
18
+ # config.seo.site_url, else the request URL; both are omitted off a request
19
+ # (guarded like Shell#csp_nonce, so an isolated render never raises).
20
+ class MetaTags < Phlex::HTML
21
+ include Phlex::Rails::Helpers::Request
22
+ include Phlex::Rails::Helpers::ImageURL
23
+
24
+ # title: the page title (nil on a page that sets none, e.g. the home
25
+ # page) — combined with config.title_suffix for og:title.
26
+ # description: the page's resolved description (DocsUI::Page passes its own
27
+ # description or #lead); nil falls back to config.seo.description.
28
+ def initialize(title: nil, description: nil)
29
+ @title = title
30
+ @description = description
31
+ end
32
+
33
+ def view_template
34
+ description_meta
35
+ open_graph
36
+ twitter_card
37
+ canonical_link
38
+ favicon_link
39
+ robots_meta
40
+ theme_color_meta
41
+ end
42
+
43
+ private
44
+
45
+ def config = DocsKit.configuration
46
+ def seo = config.seo
47
+
48
+ # The page description, falling back to the site-wide default. nil → the
49
+ # description tags (meta description, og:description) are omitted.
50
+ def description = @description || seo.description
51
+
52
+ # The full title used for og:title, identical to what Shell puts in <title>:
53
+ # "Page · Suffix", or just the suffix on a title-less page (the home page).
54
+ def full_title
55
+ [@title, config.title_suffix].compact.join(" · ")
56
+ end
57
+
58
+ def description_meta
59
+ return unless description
60
+
61
+ meta(name: "description", content: description)
62
+ end
63
+
64
+ # The always-present minimal OG block (title/type/site_name) plus the opt-in
65
+ # description/image/url. A site that configures nothing still gets a valid
66
+ # card — never a broken empty tag.
67
+ def open_graph
68
+ meta(property: "og:title", content: full_title)
69
+ meta(property: "og:type", content: seo.og_type)
70
+ meta(property: "og:site_name", content: config.brand)
71
+ open_graph_optional
72
+ end
73
+
74
+ # The OG tags emitted only when their value resolves — kept separate so the
75
+ # always-present block above stays a flat, obvious minimum.
76
+ def open_graph_optional
77
+ meta(property: "og:locale", content: seo.locale) if seo.locale
78
+ meta(property: "og:description", content: description) if description
79
+ meta(property: "og:image", content: og_image_url) if og_image_url
80
+ meta(property: "og:url", content: canonical_url) if canonical_url
81
+ end
82
+
83
+ def twitter_card
84
+ meta(name: "twitter:card", content: seo.twitter_card)
85
+ meta(name: "twitter:site", content: seo.twitter_site) if seo.twitter_site
86
+ meta(name: "twitter:creator", content: seo.twitter_creator) if seo.twitter_creator
87
+ meta(name: "twitter:image", content: og_image_url) if og_image_url
88
+ end
89
+
90
+ def canonical_link
91
+ return unless canonical_url
92
+
93
+ link(rel: "canonical", href: canonical_url)
94
+ end
95
+
96
+ def favicon_link
97
+ return unless seo.favicon
98
+
99
+ link(rel: "icon", href: seo.favicon)
100
+ end
101
+
102
+ def robots_meta
103
+ return unless seo.robots
104
+
105
+ meta(name: "robots", content: seo.robots)
106
+ end
107
+
108
+ def theme_color_meta
109
+ return unless seo.theme_color
110
+
111
+ meta(name: "theme-color", content: seo.theme_color)
112
+ end
113
+
114
+ # The og:image as an absolute URL a crawler can fetch, or nil to emit NO
115
+ # og:image (a valid card without an image — never a 404). nil when og_image is
116
+ # unset (the default). An already-absolute og_image passes through. A relative
117
+ # path is a logical asset in the SITE'S pipeline, resolved through image_url to
118
+ # the DIGESTED, host-qualified /assets URL Propshaft actually serves
119
+ # (https://host/assets/og/og-<digest>.png) — never the raw config path, which
120
+ # 404s. Off a request (an isolated render / static build) there is no asset
121
+ # pipeline to resolve a relative path, so we emit nothing rather than a
122
+ # guessed-and-wrong URL; a real app always renders with a view context.
123
+ def og_image_url
124
+ image = seo.og_image
125
+ return if image.to_s.empty?
126
+ return image if absolute?(image)
127
+
128
+ resolve_asset(image)
129
+ end
130
+
131
+ # Resolve a logical asset path to its served (digested, host-qualified) URL via
132
+ # the Rails asset helper. nil when there's no view context (image_url delegates
133
+ # to view_context, which raises without one — the same seam Shell#csp_nonce
134
+ # guards), so an isolated render emits no og:image rather than raising.
135
+ #
136
+ # A configured-but-unresolvable og_image (asset missing / not precompiled)
137
+ # raises the pipeline's MissingAssetError — intended, NOT rescued: a broken
138
+ # og_image is a real misconfiguration that must surface at deploy time
139
+ # (assets:precompile runs before the app serves), not ship silently-broken
140
+ # social cards. A site with no card image leaves og_image nil (see #og_image_url).
141
+ def resolve_asset(path)
142
+ return unless view_context
143
+
144
+ image_url(path)
145
+ end
146
+
147
+ # The canonical/og:url for this page. From config.seo.site_url when set (its
148
+ # path is honored verbatim so a site can point canonical at a specific URL),
149
+ # else the request's original URL. nil when neither is available (an isolated
150
+ # render), so canonical/og:url are simply omitted.
151
+ def canonical_url
152
+ return seo.site_url if seo.site_url
153
+ return unless request?
154
+
155
+ request.original_url
156
+ end
157
+
158
+ def absolute?(url) = url.to_s.match?(%r{\Ahttps?://}i)
159
+
160
+ # True only when there's a live Rails view context AND a request on it — the
161
+ # phlex-rails #request helper delegates to view_context, which raises without
162
+ # one. Mirrors Shell#csp_nonce's guard so an isolated Phlex render (the specs,
163
+ # a request-less static build) degrades cleanly instead of raising.
164
+ def request? = !view_context.nil? && !request.nil?
165
+ end
166
+ end
@@ -36,6 +36,15 @@ module DocsUI
36
36
  @eyebrow
37
37
  end
38
38
 
39
+ # The per-page SEO/social description → DocsUI::MetaTags (via Shell). Set it
40
+ # for a hand-tuned description; when unset, Page derives one from #lead, so
41
+ # an existing page gets a sensible description for free. nil (no
42
+ # description, no lead) falls back to config.seo.description in MetaTags.
43
+ def description(value = nil)
44
+ @description = value if value
45
+ @description
46
+ end
47
+
39
48
  # The "On this page" auto-TOC placement for this page. Defaults to the
40
49
  # configured DocsKit.configuration.on_page_default; set false to opt out,
41
50
  # or :panel/:toggle/:sidebar to override per page.
@@ -46,7 +55,11 @@ module DocsUI
46
55
  end
47
56
 
48
57
  def view_template
49
- render DocsUI::Shell.new(title: self.class.title, on_page: self.class.on_page) do
58
+ render DocsUI::Shell.new(
59
+ title: self.class.title,
60
+ description: self.class.description || lead,
61
+ on_page: self.class.on_page
62
+ ) do
50
63
  # data-md-skip drops this nav from the Markdown export — it's chrome, not
51
64
  # page content (DocsKit::MarkdownExport strips [data-md-skip]). The
52
65
  # "Markdown" action sits opposite "← Home"; it's chrome too, so it lives
@@ -23,11 +23,16 @@ module DocsUI
23
23
 
24
24
  DRAWER_ID = "site-drawer"
25
25
 
26
- # on_page: the auto-TOC placement for this page (:panel/:toggle/:sidebar or
27
- # false). Threaded to the sidebar's docs-nav controller; :panel/:toggle also
28
- # render an "On this page" slot inside the content column.
29
- def initialize(title: nil, on_page: false)
26
+ # title: the page title <title> and og:title.
27
+ # description: the page description → the SEO/social meta tags (DocsUI::Page
28
+ # passes its own description or #lead); nil falls back to
29
+ # config.seo.description in DocsUI::MetaTags.
30
+ # on_page: the auto-TOC placement for this page (:panel/:toggle/:sidebar or
31
+ # false). Threaded to the sidebar's docs-nav controller;
32
+ # :panel/:toggle also render an "On this page" slot in the content.
33
+ def initialize(title: nil, description: nil, on_page: false)
30
34
  @title = title
35
+ @description = description
31
36
  @on_page = DocsKit.configuration.normalize_on_page(on_page)
32
37
  end
33
38
 
@@ -72,6 +77,11 @@ module DocsUI
72
77
  title { [@title, config.title_suffix].compact.join(" · ") }
73
78
  meta(charset: "utf-8")
74
79
  meta(name: "viewport", content: "width=device-width,initial-scale=1")
80
+ # SEO / social-share tags (description, Open Graph, Twitter Card,
81
+ # canonical, favicon, robots, theme-color) from config.seo + this page's
82
+ # title/description. A site that sets no c.seo still gets a valid minimal
83
+ # OG block, so the head is a strict superset of the pre-SEO markup.
84
+ render DocsUI::MetaTags.new(title: @title, description: @description)
75
85
  csrf_meta_tags
76
86
  csp_meta_tag
77
87
  # Turbo morphs page-level navigations so a re-render preserves scroll and
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "seo_config"
4
+
3
5
  module DocsKit
4
6
  # Per-site configuration for the shared docs chrome. Everything that differs
5
7
  # between two otherwise-identical docs sites lives here, so the Phlex shell
@@ -270,6 +272,15 @@ module DocsKit
270
272
  Array(@topbar_links).map { |link| DocsKit::TopbarLink.from(link) }
271
273
  end
272
274
 
275
+ # The SEO / social-share knobs (DocsKit::SeoConfig), read by DocsUI::MetaTags.
276
+ # Lazily built and memoized so a `c.seo.description = ...` block mutates the
277
+ # one instance the Shell later reads. A site that never touches it gets the
278
+ # backwards-safe defaults (see SeoConfig), so the head is a strict superset of
279
+ # the pre-SEO markup.
280
+ def seo
281
+ @seo ||= DocsKit::SeoConfig.new
282
+ end
283
+
273
284
  # The loaded DocsKit::OpenApi::Document for #openapi. Memoized; when #openapi
274
285
  # is a file path, the memo is invalidated on an mtime change so editing the
275
286
  # spec in development is picked up without a server restart. Raises a
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module DocsKit
6
+ # Screenshots a URL into the site's OG/Twitter images. Loaded ONLY by the
7
+ # host-installed `docs_kit:og` rake task (via an explicit require) — never at
8
+ # gem runtime — so the headless-browser tooling it drives is never a docs-kit
9
+ # dependency. zeitwerk ignores this file (see lib/docs_kit.rb) for the same
10
+ # reason: it must not be eager-loaded into a host that never runs the task.
11
+ #
12
+ # Usage (from the rake task):
13
+ # DocsKit::OgGenerator.new(url:, out_dir:, sizes:, shooter:).call
14
+ #
15
+ # When #url is nil the generator boots the Rails app on an ephemeral port and
16
+ # shoots "/"; when set it shoots that URL directly (e.g. a deployed site). The
17
+ # shooter is a headless-browser CLI resolved at runtime: an explicit override,
18
+ # else the first of shot-scraper / chromium / chrome found on PATH.
19
+ class OgGenerator
20
+ # Raised when no supported headless-browser CLI is available. The message
21
+ # names each supported tool + how to install one, so the task fails with a
22
+ # fix, not a stack trace.
23
+ class NoShooterError < StandardError; end
24
+
25
+ # The shooters we know how to drive, in preference order. shot-scraper first
26
+ # (purpose-built, handles sizing cleanly); then a raw chromium/chrome.
27
+ SHOOTERS = %w[shot-scraper chromium chromium-browser google-chrome chrome].freeze
28
+
29
+ attr_reader :url, :out_dir, :sizes
30
+
31
+ def initialize(url:, out_dir:, sizes:, shooter: nil)
32
+ @url = url
33
+ @out_dir = out_dir.to_s
34
+ @sizes = sizes
35
+ @shooter = shooter
36
+ end
37
+
38
+ # The chosen shooter name (explicit override or nil until #resolve_shooter!).
39
+ def shooter_name = @shooter
40
+
41
+ # The full run: resolve a shooter, ensure the output dir, boot a local server
42
+ # if needed, and shoot every size. Returns the list of written paths.
43
+ def call
44
+ tool = resolve_shooter!
45
+ require "fileutils"
46
+ FileUtils.mkdir_p(out_dir)
47
+
48
+ with_target_url do |target|
49
+ sizes.map do |filename, dimensions|
50
+ path = File.join(out_dir, filename)
51
+ run(command_for(tool, target, dimensions, path))
52
+ path
53
+ end
54
+ end
55
+ end
56
+
57
+ # Resolve the shooter CLI: an explicit override (returned as-is so a user can
58
+ # force a specific tool), else the first supported binary on PATH. Raises
59
+ # NoShooterError naming the options when none is found.
60
+ def resolve_shooter!
61
+ return @shooter if @shooter && !@shooter.to_s.empty?
62
+
63
+ found = SHOOTERS.find { |cmd| which(cmd) }
64
+ return found if found
65
+
66
+ raise NoShooterError,
67
+ "No headless-browser CLI found. Install one of: shot-scraper " \
68
+ "(`pipx install shot-scraper && shot-scraper install`), chromium, or " \
69
+ "chrome — or set DOCS_KIT_SHOT to the command to use."
70
+ end
71
+
72
+ # Build the argv for `tool` to screenshot `target` at `[w, h]` into `path`.
73
+ # Kept pure (no side effects) so it's unit-testable without a browser.
74
+ def command_for(tool, target, dimensions, path)
75
+ width, height = dimensions
76
+ case tool
77
+ when "shot-scraper"
78
+ ["shot-scraper", target, "--width", width.to_s, "--height", height.to_s, "-o", path]
79
+ else # a chromium/chrome family binary
80
+ [
81
+ tool, "--headless=new", "--disable-gpu", "--hide-scrollbars",
82
+ "--force-device-scale-factor=1",
83
+ "--window-size=#{width},#{height}",
84
+ "--screenshot=#{path}", target
85
+ ]
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ # Yield the URL to shoot. If #url was given, shoot it directly. Otherwise boot
92
+ # the host Rails app on an ephemeral port, yield its local URL, and tear it
93
+ # down after. Rack/rackup are the host app's own deps (it's a Rails app), not
94
+ # docs-kit's — required lazily here so the gem never loads them.
95
+ def with_target_url(&)
96
+ return yield(url) if url && !url.to_s.empty?
97
+
98
+ boot_local_server(&)
99
+ end
100
+
101
+ # Boot the Rails app with Rack::Handler on an ephemeral port in a background
102
+ # thread, wait for it to answer, yield the URL, then shut down.
103
+ def boot_local_server
104
+ require "rack"
105
+ require "socket"
106
+
107
+ port = free_port
108
+ app = Rails.application
109
+ server_thread = Thread.new do
110
+ Rackup::Handler.get("webrick").run(app, Port: port, Host: "127.0.0.1", Logger: null_logger, AccessLog: [])
111
+ rescue LoadError
112
+ Rack::Handler.get("webrick").run(app, Port: port, Host: "127.0.0.1", Logger: null_logger, AccessLog: [])
113
+ end
114
+ wait_for_port(port)
115
+ yield "http://127.0.0.1:#{port}/"
116
+ ensure
117
+ server_thread&.kill
118
+ end
119
+
120
+ # An open ephemeral port (bind to 0, read it back, close). A tiny race window
121
+ # remains but is acceptable for a one-shot local screenshot server.
122
+ def free_port
123
+ server = TCPServer.new("127.0.0.1", 0)
124
+ port = server.addr[1]
125
+ server.close
126
+ port
127
+ end
128
+
129
+ # Poll until the local server accepts a connection (or give up after ~15s so a
130
+ # boot failure surfaces instead of hanging).
131
+ def wait_for_port(port, timeout: 15)
132
+ deadline = monotonic_now + timeout
133
+ loop do
134
+ TCPSocket.new("127.0.0.1", port).close
135
+ return
136
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
137
+ raise "docs_kit:og: local server did not start within #{timeout}s" if monotonic_now > deadline
138
+
139
+ sleep 0.2
140
+ end
141
+ end
142
+
143
+ def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
144
+
145
+ def null_logger
146
+ require "logger"
147
+ Logger.new(File::NULL)
148
+ end
149
+
150
+ # Run a command, raising with the joined argv on a non-zero exit so a shooter
151
+ # failure is loud (not a silently-missing image).
152
+ def run(argv)
153
+ ok = system(*argv)
154
+ raise "docs_kit:og: screenshot command failed: #{argv.shelljoin}" unless ok
155
+ end
156
+
157
+ # Locate an executable on PATH (a dependency-free `which`), returning its full
158
+ # path or nil. Overridable in specs so shooter resolution is testable.
159
+ def which(cmd)
160
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
161
+ candidate = File.join(dir, cmd)
162
+ return candidate if File.executable?(candidate) && !File.directory?(candidate)
163
+ end
164
+ nil
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # The per-site SEO / social-share knobs, read by DocsUI::MetaTags to emit the
5
+ # <head> meta/link tags (description, Open Graph, Twitter Card, canonical,
6
+ # favicon, theme-color). Nested under DocsKit::Configuration#seo so a site
7
+ # configures it as a small block:
8
+ #
9
+ # DocsKit.configure do |c|
10
+ # c.seo.description = "What these docs cover, in one sentence."
11
+ # c.seo.og_image = "og/og.png" # generated by `bin/rails docs_kit:og`
12
+ # c.seo.twitter_site = "@my_handle"
13
+ # end
14
+ #
15
+ # Every field defaults to a backwards-safe value: a site that sets none still
16
+ # renders a valid minimal Open Graph block and no broken empty tags. Plain
17
+ # accessors (not Data.define) because each field is individually assignable in
18
+ # the `c.seo.x = ...` block, mirroring DocsKit::Configuration's own style.
19
+ class SeoConfig
20
+ # A one-line page/site description → <meta name="description"> and
21
+ # og:description / twitter:description. nil omits the description tags
22
+ # (DocsUI::Page derives a per-page value from #lead when a page sets none).
23
+ attr_accessor :description
24
+
25
+ # The social-share image → og:image / twitter:image. Either a logical asset
26
+ # path in the SITE'S OWN pipeline (e.g. "og/og.png", resolved through
27
+ # image_url to the digested /assets URL Propshaft serves) or an absolute URL.
28
+ # The image is SITE content, not shipped by the gem — nil by default, so a
29
+ # site with no card image emits NO og:image (a valid card, never a 404).
30
+ # Generate one into app/assets/images/ with `bin/rails docs_kit:og`, then set
31
+ # this to its path.
32
+ attr_accessor :og_image
33
+
34
+ # The og:type. "website" for a docs site; a page could override to "article".
35
+ attr_accessor :og_type
36
+
37
+ # The twitter:card style. "summary_large_image" renders a full-width banner
38
+ # (size your og:image 1200×630 for it); "summary" is the small square card.
39
+ attr_accessor :twitter_card
40
+
41
+ # The site's @handle → twitter:site, and the content author's → twitter:creator.
42
+ # Both nil by default (a site with no X presence emits neither tag).
43
+ attr_accessor :twitter_site, :twitter_creator
44
+
45
+ # The og:locale (e.g. "en_US", "sv_SE").
46
+ attr_accessor :locale
47
+
48
+ # The site's canonical base URL (e.g. "https://docs.example.com"). When set,
49
+ # og:image/og:url/canonical are absolutized against it even off a request
50
+ # (a static build, an isolated render). nil → DocsUI::MetaTags falls back to
51
+ # the request base URL, and omits canonical/absolute og:url when there's no
52
+ # request either.
53
+ attr_accessor :site_url
54
+
55
+ # A robots directive (e.g. "noindex, nofollow") → <meta name="robots">. nil
56
+ # omits the tag, so the page is indexable by default.
57
+ attr_accessor :favicon
58
+
59
+ # A favicon asset path/URL → <link rel="icon">. nil omits the link (the host
60
+ # may serve /favicon.ico itself).
61
+ attr_accessor :robots
62
+
63
+ # A theme-color (e.g. "#0f172a") → <meta name="theme-color">, tinting mobile
64
+ # browser chrome. nil omits the tag.
65
+ attr_accessor :theme_color
66
+
67
+ def initialize
68
+ @description = nil
69
+ @og_image = nil
70
+ @og_type = "website"
71
+ @twitter_card = "summary_large_image"
72
+ @twitter_site = nil
73
+ @twitter_creator = nil
74
+ @locale = "en_US"
75
+ @site_url = nil
76
+ @robots = nil
77
+ @favicon = nil
78
+ @theme_color = nil
79
+ end
80
+ end
81
+ end
@@ -169,6 +169,9 @@ after_bundle do
169
169
  Next:
170
170
  cd #{app_name}
171
171
  bin/dev # or bin/rails server
172
+ bin/rails docs_kit:og # generate your social-share image from the landing page
173
+ # (needs a headless browser: shot-scraper or chromium)
174
+ SEO: set c.seo.* in config/initializers/docs_kit.rb (description, og_image, twitter_site).
172
175
  Deploy: push, create a GitHub Release (or run the Deploy docs workflow).
173
176
  Set repo secrets: SSH_PRIVATE_KEY, DEPLOY_HOST, DEPLOY_DOMAIN (env: docs).
174
177
  MSG
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DocsKit
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.2"
5
5
  end
data/lib/docs_kit.rb CHANGED
@@ -54,6 +54,13 @@ loader.push_dir(File.expand_path("docs_kit", __dir__), namespace: DocsKit)
54
54
  loader.push_dir(File.expand_path("../app/components/docs_ui", __dir__), namespace: DocsUI)
55
55
  loader.ignore(File.expand_path("docs_kit/version.rb", __dir__))
56
56
  loader.ignore(File.expand_path("docs_kit/configuration.rb", __dir__))
57
+ # Required eagerly by configuration.rb (a plain-Ruby value object, no Rails), so
58
+ # ignore it here too or zeitwerk double-manages the constant.
59
+ loader.ignore(File.expand_path("docs_kit/seo_config.rb", __dir__))
60
+ # Loaded ONLY by the host's docs_kit:og rake task (an explicit require), never at
61
+ # gem runtime — so its Rack/browser tooling is never pulled into a host that
62
+ # doesn't run the task. Ignore it so eager_load! doesn't require it.
63
+ loader.ignore(File.expand_path("docs_kit/og_generator.rb", __dir__))
57
64
  # docs_kit/rubocop.rb is the RuboCop-cop entry point: it defines cops under
58
65
  # RuboCop::Cop::DocsKit::*, not a DocsKit::Rubocop constant, so zeitwerk must not
59
66
  # manage it. It (and the cops under lib/rubocop/, which are outside the loader's
@@ -168,6 +168,16 @@ module DocsKit
168
168
  create_file "app/assets/builds/.keep", ""
169
169
  end
170
170
 
171
+ # Install the `docs_kit:og` rake task — gem-owned wiring, refreshed on every
172
+ # run so a site picks up task fixes. It does NOT ship an OG image: the
173
+ # social-share image is SITE content, generated into the site's OWN
174
+ # app/assets/images/ by `bin/rails docs_kit:og`. Until a site runs it (and
175
+ # sets c.seo.og_image), no og:image tag is emitted — a valid card, never a
176
+ # 404 for an image the gem can't provide.
177
+ def create_og_task
178
+ template "docs_kit_og.rake", "lib/tasks/docs_kit_og.rake"
179
+ end
180
+
171
181
  def wire_assets_and_package_json
172
182
  # Serve the bun-built CSS from app/assets/builds.
173
183
  inject_into_file "config/initializers/assets.rb",
@@ -30,6 +30,28 @@ Rails.application.config.to_prepare do
30
30
  # # { href: "https://discord.gg/INVITE", label: "Discord", icon: :discord },
31
31
  # ]
32
32
 
33
+ # SEO + social sharing. docs-kit emits a full <head> (description, Open Graph,
34
+ # Twitter Card, canonical, favicon, theme-color) from these knobs — a shared
35
+ # link renders a rich card with zero markup. Every field is optional; a site
36
+ # that sets none still gets a valid minimal card. Per-page descriptions come
37
+ # from each page's `description "..."` (falling back to its lead paragraph).
38
+ # c.seo.description = "What these docs cover, in one sentence."
39
+ # c.seo.twitter_card = "summary_large_image" # default; "summary" = small card
40
+ # c.seo.twitter_site = "@your_handle"
41
+ # c.seo.twitter_creator = "@author_handle"
42
+ # c.seo.site_url = "https://docs.example.com" # your canonical base URL
43
+ # c.seo.locale = "en_US"
44
+ # c.seo.robots = "noindex, nofollow" # keep a staging/private site out of search
45
+ # c.seo.favicon = "/favicon.ico"
46
+ # c.seo.theme_color = "#0f172a" # tints mobile browser chrome
47
+ #
48
+ # The social-share image is YOUR content, not shipped by the gem. Generate one
49
+ # from your landing page (screenshots "/" into app/assets/images/og/) and point
50
+ # og_image at it — it's resolved through YOUR asset pipeline to the digested
51
+ # /assets URL. Until you set it, no og:image is emitted (a valid card, no 404).
52
+ # bin/rails docs_kit:og
53
+ # c.seo.og_image = "og/og.png" # a path in app/assets/images/
54
+
33
55
  # Code blocks use one Rouge theme by default. To keep them readable in BOTH
34
56
  # light and dark daisyUI themes, set a light base + a dark override — the dark
35
57
  # theme's CSS is scoped under [data-theme=X] for each shipped dark theme, so
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generate this site's social-share (Open Graph / Twitter) images by
4
+ # screenshotting its OWN landing page, so a shared link renders a real card of
5
+ # your docs — not the neutral placeholder docs-kit ships. Run it whenever the
6
+ # landing page changes materially:
7
+ #
8
+ # bin/rails docs_kit:og
9
+ #
10
+ # It boots the app, serves it locally, screenshots "/" at the standard sizes, and
11
+ # writes them into app/assets/images/og/. Point c.seo.og_image at the result
12
+ # (the default "og/og.png" already matches). This is a documented, manual routine
13
+ # (like phlex-reactive's vendored-client re-sync) — never run automatically, so a
14
+ # machine without a headless browser is never blocked at deploy time.
15
+ #
16
+ # It shells out to a headless-browser CLI you already have; it is NOT a docs-kit
17
+ # runtime dependency. Supported (auto-detected, first one found wins), override
18
+ # with DOCS_KIT_SHOT:
19
+ # * shot-scraper (https://shot-scraper.datasette.io) — `pipx install shot-scraper`
20
+ # * chromium/chrome headless --screenshot
21
+ # Set DOCS_KIT_OG_URL to shoot a deployed URL instead of booting locally.
22
+ namespace :docs_kit do
23
+ desc "Screenshot the landing page into app/assets/images/og/{og,twitter,square}.png"
24
+ task og: :environment do
25
+ require "docs_kit/og_generator"
26
+
27
+ sizes = {
28
+ "og.png" => [1200, 630], # Open Graph / twitter summary_large_image
29
+ "twitter.png" => [1024, 512], # Twitter summary card
30
+ "square.png" => [600, 600] # square fallback (some chat clients)
31
+ }
32
+ out_dir = Rails.root.join("app/assets/images/og")
33
+
34
+ DocsKit::OgGenerator.new(
35
+ url: ENV.fetch("DOCS_KIT_OG_URL", nil),
36
+ out_dir: out_dir,
37
+ sizes: sizes,
38
+ shooter: ENV.fetch("DOCS_KIT_SHOT", nil)
39
+ ).call
40
+
41
+ puts "✅ Wrote #{sizes.keys.join(', ')} to #{out_dir.to_s.sub("#{Dir.pwd}/", '')}"
42
+ puts " Point c.seo.og_image at one of them (default \"og/og.png\" already does)."
43
+ end
44
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docs-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -176,6 +176,7 @@ files:
176
176
  - app/components/docs_ui/json_response.rb
177
177
  - app/components/docs_ui/markdown.rb
178
178
  - app/components/docs_ui/markdown_action.rb
179
+ - app/components/docs_ui/meta_tags.rb
179
180
  - app/components/docs_ui/on_this_page.rb
180
181
  - app/components/docs_ui/open_api_operation.rb
181
182
  - app/components/docs_ui/page.rb
@@ -214,6 +215,7 @@ files:
214
215
  - lib/docs_kit/mcp_server.rb
215
216
  - lib/docs_kit/mcp_tools.rb
216
217
  - lib/docs_kit/nav_item.rb
218
+ - lib/docs_kit/og_generator.rb
217
219
  - lib/docs_kit/open_api.rb
218
220
  - lib/docs_kit/open_api/document.rb
219
221
  - lib/docs_kit/open_api/operation.rb
@@ -223,6 +225,7 @@ files:
223
225
  - lib/docs_kit/search_hit.rb
224
226
  - lib/docs_kit/search_index.rb
225
227
  - lib/docs_kit/search_index/snippet.rb
228
+ - lib/docs_kit/seo_config.rb
226
229
  - lib/docs_kit/shortcut.rb
227
230
  - lib/docs_kit/templates/new_site.rb
228
231
  - lib/docs_kit/topbar_link.rb
@@ -237,6 +240,7 @@ files:
237
240
  - lib/generators/docs_kit/install/templates/doc.rb.erb
238
241
  - lib/generators/docs_kit/install/templates/docs_controller.rb.erb
239
242
  - lib/generators/docs_kit/install/templates/docs_kit.rb.erb
243
+ - lib/generators/docs_kit/install/templates/docs_kit_og.rake
240
244
  - lib/generators/docs_kit/install/templates/installation_page.rb.erb
241
245
  - lib/generators/docs_kit/install/templates/landing.rb.erb
242
246
  - lib/generators/docs_kit/install/templates/landings_controller.rb.erb