docs-kit 0.1.0 → 1.0.1

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: aa6ab8345eda1cc169927e190d267236133574627f0075f528bb417d842b2868
4
- data.tar.gz: f620e9eb3e6bdca64e1d8365ad5cb2647bcc38d8576af8f2f7e2a1b397f8584c
3
+ metadata.gz: 72b8c93e9a6c2896bb31bd9ba8aaaf8cf5692424ab84d92ca8e5817d1b85291c
4
+ data.tar.gz: bfc8a48ab90cbb0d62083f864fd8f188a645070b86bf4af1ff63c9a302160e99
5
5
  SHA512:
6
- metadata.gz: b813b8eef499c093632c7b06974acdf3b5bd7033235020b815c19933ffb8ba82d08f7640791c5cb3685334ca67fa7e93012a83b214308339495ddabdbd4edaa8
7
- data.tar.gz: ad7d05a4a84edc6cf1a4348c3e04da3f01a7402be4a0d55d68cdbad1978b5735eec30846edf8994b6b6747dac452a03ca4f5c7eec583f359935a847f136c5cfe
6
+ metadata.gz: 6d6846fbbe1169e4513a2061b298a15ae65281da4eacaf3486d34392ceb52ecfcff7542df8eb28c2f8d5ff2c3b443c0e75a8371019b524293a51e76fbd964e80
7
+ data.tar.gz: ee32039817d7e32af7b6a97e2abb2bed4ff5142974fe8e2c43f40949755343e8d6c96781d82603b61fa4794f92f3c24ef78832d33be0288c32c39308dfea1a64
data/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@
4
4
 
5
5
  ### Added
6
6
 
7
+ - **SEO + social sharing.** Every page now emits a complete SEO `<head>` —
8
+ meta description, Open Graph, Twitter Card, canonical, favicon, robots, and
9
+ theme-color — via the new `DocsUI::MetaTags` component, driven entirely by a
10
+ new `c.seo` config block (`DocsKit::SeoConfig`). Pages carry an authorable
11
+ `description "..."` (falling back to the page's `#lead`). docs-kit ships a
12
+ neutral 1200×630 default OG image, and installs a `docs_kit:og` rake task that
13
+ screenshots a site's OWN landing page into `app/assets/images/og/` (host-side
14
+ headless browser; never a gem runtime dependency). A guard spec keeps the
15
+ shipped default present. Backwards-compatible: a site that sets no `c.seo`
16
+ renders a valid minimal card and its `<head>` is a strict superset of before.
17
+ The install generator documents `c.seo` and installs the task + default image;
18
+ `docs-kit new` reminds the owner to run it. (#48)
7
19
  - Release tooling matching the sibling gems (daisyui/phlex-reactive/pgbus): a
8
20
  `rake release[X.Y.Z]` task (version bump → lockfile update → build-verify →
9
21
  commit → push → GitHub Release; `pre`/`force` supported, `main`-only, clean-tree
data/README.md CHANGED
@@ -169,6 +169,50 @@ 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" # generated by `bin/rails docs_kit:og`
184
+ c.seo.twitter_site = "@your_handle"
185
+ c.seo.site_url = "https://docs.example.com" # absolutizes og:image + canonical
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.** docs-kit ships a neutral 1200×630 default so
203
+ `og:image` is never broken. Generate one from your **own** landing page — it
204
+ screenshots `/` into `app/assets/images/og/{og,twitter,square}.png` — whenever the
205
+ page changes materially:
206
+
207
+ ```bash
208
+ bin/rails docs_kit:og # needs a headless browser: shot-scraper or chromium/chrome
209
+ ```
210
+
211
+ It's a documented, manual routine (never run at deploy time), so a machine without
212
+ a browser is never blocked. A guard spec keeps the shipped default present. Set
213
+ `DOCS_KIT_OG_URL` to shoot a deployed URL instead of booting locally, or
214
+ `DOCS_KIT_SHOT` to force a specific browser CLI.
215
+
172
216
  ### Custom nav (advanced)
173
217
 
174
218
  Sites that interleave several registries under a heading, or need custom
Binary file
@@ -0,0 +1 @@
1
+ <svg height="630" viewBox="0 0 1200 630" width="1200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#0f172a"/><stop offset="1" stop-color="#1e293b"/></linearGradient><linearGradient id="b" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#38bdf8"/><stop offset="1" stop-color="#818cf8"/></linearGradient><path d="m0 0h1200v630h-1200z" fill="url(#a)"/><path d="m0 0h1200v8h-1200z" fill="url(#b)"/><path d="m80 470h240m-240 40h180m-180 40h220" fill="none" opacity=".5" stroke="#334155" stroke-width="2"/><g transform="translate(80 150)"><rect fill="url(#b)" height="96" rx="20" width="96"/><path d="m28 32h40m-40 16h40m-40 16h28" stroke="#0f172a" stroke-linecap="round" stroke-width="7"/></g><g font-family="Helvetica, Arial, sans-serif"><text fill="#f8fafc" font-size="88" font-weight="700" x="80" y="330">Documentation</text><text fill="#94a3b8" font-size="34" font-weight="400" x="82" y="392">Built with docs-kit</text></g></svg>
@@ -0,0 +1,163 @@
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. og:image/og:url/canonical are absolutized against config.seo.site_url
15
+ # when set, else the request base URL; with neither, og:image degrades to its
16
+ # raw path and canonical/og:url are omitted (guarded like Shell#csp_nonce, so
17
+ # an isolated render or a request-less static build never raises).
18
+ class MetaTags < Phlex::HTML
19
+ include Phlex::Rails::Helpers::Request
20
+
21
+ # title: the page title (nil on a page that sets none, e.g. the home
22
+ # page) — combined with config.title_suffix for og:title.
23
+ # description: the page's resolved description (DocsUI::Page passes its own
24
+ # description or #lead); nil falls back to config.seo.description.
25
+ def initialize(title: nil, description: nil)
26
+ @title = title
27
+ @description = description
28
+ end
29
+
30
+ def view_template
31
+ description_meta
32
+ open_graph
33
+ twitter_card
34
+ canonical_link
35
+ favicon_link
36
+ robots_meta
37
+ theme_color_meta
38
+ end
39
+
40
+ private
41
+
42
+ def config = DocsKit.configuration
43
+ def seo = config.seo
44
+
45
+ # The page description, falling back to the site-wide default. nil → the
46
+ # description tags (meta description, og:description) are omitted.
47
+ def description = @description || seo.description
48
+
49
+ # The full title used for og:title, identical to what Shell puts in <title>:
50
+ # "Page · Suffix", or just the suffix on a title-less page (the home page).
51
+ def full_title
52
+ [@title, config.title_suffix].compact.join(" · ")
53
+ end
54
+
55
+ def description_meta
56
+ return unless description
57
+
58
+ meta(name: "description", content: description)
59
+ end
60
+
61
+ # The always-present minimal OG block (title/type/site_name) plus the opt-in
62
+ # description/image/url. A site that configures nothing still gets a valid
63
+ # card — never a broken empty tag.
64
+ def open_graph
65
+ meta(property: "og:title", content: full_title)
66
+ meta(property: "og:type", content: seo.og_type)
67
+ meta(property: "og:site_name", content: config.brand)
68
+ open_graph_optional
69
+ end
70
+
71
+ # The OG tags emitted only when their value resolves — kept separate so the
72
+ # always-present block above stays a flat, obvious minimum.
73
+ def open_graph_optional
74
+ meta(property: "og:locale", content: seo.locale) if seo.locale
75
+ meta(property: "og:description", content: description) if description
76
+ meta(property: "og:image", content: og_image_url) if og_image_url
77
+ meta(property: "og:url", content: canonical_url) if canonical_url
78
+ end
79
+
80
+ def twitter_card
81
+ meta(name: "twitter:card", content: seo.twitter_card)
82
+ meta(name: "twitter:site", content: seo.twitter_site) if seo.twitter_site
83
+ meta(name: "twitter:creator", content: seo.twitter_creator) if seo.twitter_creator
84
+ meta(name: "twitter:image", content: og_image_url) if og_image_url
85
+ end
86
+
87
+ def canonical_link
88
+ return unless canonical_url
89
+
90
+ link(rel: "canonical", href: canonical_url)
91
+ end
92
+
93
+ def favicon_link
94
+ return unless seo.favicon
95
+
96
+ link(rel: "icon", href: seo.favicon)
97
+ end
98
+
99
+ def robots_meta
100
+ return unless seo.robots
101
+
102
+ meta(name: "robots", content: seo.robots)
103
+ end
104
+
105
+ def theme_color_meta
106
+ return unless seo.theme_color
107
+
108
+ meta(name: "theme-color", content: seo.theme_color)
109
+ end
110
+
111
+ # The og:image as an absolute-when-possible URL: an already-absolute
112
+ # og_image is returned as-is; a relative path is joined onto the resolved
113
+ # base (config.seo.site_url or the request base); with no base it degrades to
114
+ # the raw path so og:image is never empty.
115
+ def og_image_url
116
+ image = seo.og_image
117
+ return if image.to_s.empty?
118
+ return image if absolute?(image)
119
+
120
+ base = base_url
121
+ base ? "#{base}/#{image.delete_prefix('/')}" : image
122
+ end
123
+
124
+ # The canonical/og:url for this page. From config.seo.site_url when set (its
125
+ # path is honored verbatim so a site can point canonical at a specific URL),
126
+ # else the request's original URL. nil when neither is available (an isolated
127
+ # render), so canonical/og:url are simply omitted.
128
+ def canonical_url
129
+ return seo.site_url if seo.site_url
130
+ return unless request?
131
+
132
+ request.original_url
133
+ end
134
+
135
+ # The base URL for absolutizing a relative og:image: config.seo.site_url's
136
+ # origin, else the request base URL, else nil. site_url may include a path
137
+ # (for canonical); strip it to an origin so the image path joins cleanly.
138
+ def base_url
139
+ return origin_of(seo.site_url) if seo.site_url
140
+ return unless request?
141
+
142
+ request.base_url
143
+ end
144
+
145
+ def absolute?(url) = url.to_s.match?(%r{\Ahttps?://}i)
146
+
147
+ # Just the scheme+host(+port) of a URL, dropping any path — so joining an
148
+ # image path onto it never doubles a path segment.
149
+ def origin_of(url)
150
+ uri = URI.parse(url)
151
+ port = uri.port && uri.default_port != uri.port ? ":#{uri.port}" : ""
152
+ "#{uri.scheme}://#{uri.host}#{port}"
153
+ rescue URI::InvalidURIError
154
+ url
155
+ end
156
+
157
+ # True only when there's a live Rails view context AND a request on it — the
158
+ # phlex-rails #request helper delegates to view_context, which raises without
159
+ # one. Mirrors Shell#csp_nonce's guard so an isolated Phlex render (the specs,
160
+ # a request-less static build) degrades cleanly instead of raising.
161
+ def request? = !view_context.nil? && !request.nil?
162
+ end
163
+ 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,79 @@
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, as an asset path (resolved via the Rails asset
26
+ # helper) or an absolute URL → og:image / twitter:image. Defaults to the
27
+ # neutral image docs-kit ships at app/assets/images/og/og.png so og:image is
28
+ # never broken; a site regenerates its own with `bin/rails docs_kit:og`.
29
+ attr_accessor :og_image
30
+
31
+ # The og:type. "website" for a docs site; a page could override to "article".
32
+ attr_accessor :og_type
33
+
34
+ # The twitter:card style. "summary_large_image" renders a full-width banner
35
+ # (the shipped og:image is 1200×630, sized for it); "summary" is the small
36
+ # square card.
37
+ attr_accessor :twitter_card
38
+
39
+ # The site's @handle → twitter:site, and the content author's → twitter:creator.
40
+ # Both nil by default (a site with no X presence emits neither tag).
41
+ attr_accessor :twitter_site, :twitter_creator
42
+
43
+ # The og:locale (e.g. "en_US", "sv_SE").
44
+ attr_accessor :locale
45
+
46
+ # The site's canonical base URL (e.g. "https://docs.example.com"). When set,
47
+ # og:image/og:url/canonical are absolutized against it even off a request
48
+ # (a static build, an isolated render). nil → DocsUI::MetaTags falls back to
49
+ # the request base URL, and omits canonical/absolute og:url when there's no
50
+ # request either.
51
+ attr_accessor :site_url
52
+
53
+ # A robots directive (e.g. "noindex, nofollow") → <meta name="robots">. nil
54
+ # omits the tag, so the page is indexable by default.
55
+ attr_accessor :favicon
56
+
57
+ # A favicon asset path/URL → <link rel="icon">. nil omits the link (the host
58
+ # may serve /favicon.ico itself).
59
+ attr_accessor :robots
60
+
61
+ # A theme-color (e.g. "#0f172a") → <meta name="theme-color">, tinting mobile
62
+ # browser chrome. nil omits the tag.
63
+ attr_accessor :theme_color
64
+
65
+ def initialize
66
+ @description = nil
67
+ @og_image = "og/og.png"
68
+ @og_type = "website"
69
+ @twitter_card = "summary_large_image"
70
+ @twitter_site = nil
71
+ @twitter_creator = nil
72
+ @locale = "en_US"
73
+ @site_url = nil
74
+ @robots = nil
75
+ @favicon = nil
76
+ @theme_color = nil
77
+ end
78
+ end
79
+ 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 = "0.1.0"
4
+ VERSION = "1.0.1"
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,20 @@ module DocsKit
168
168
  create_file "app/assets/builds/.keep", ""
169
169
  end
170
170
 
171
+ # The SEO social-share image routine: the `docs_kit:og` rake task (gem-owned
172
+ # wiring, refreshed on every run so a site gets task fixes) plus the neutral
173
+ # default OG image (site content — `copy_file` skips it if the site already
174
+ # generated/customized its own, so `bin/rails docs_kit:og` output is never
175
+ # clobbered). c.seo.og_image defaults to "og/og.png" so a site's og:image is
176
+ # valid immediately, before it runs the task.
177
+ def create_og_task
178
+ template "docs_kit_og.rake", "lib/tasks/docs_kit_og.rake"
179
+ copy_file(
180
+ File.expand_path("../../../../app/assets/images/og/og.png", __dir__),
181
+ "app/assets/images/og/og.png"
182
+ )
183
+ end
184
+
171
185
  def wire_assets_and_package_json
172
186
  # Serve the bun-built CSS from app/assets/builds.
173
187
  inject_into_file "config/initializers/assets.rb",
@@ -30,6 +30,26 @@ 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.og_image = "og/og.png" # generated by `bin/rails docs_kit:og`
40
+ # c.seo.twitter_card = "summary_large_image" # default; "summary" = small card
41
+ # c.seo.twitter_site = "@your_handle"
42
+ # c.seo.twitter_creator = "@author_handle"
43
+ # c.seo.site_url = "https://docs.example.com" # absolutizes og:image/canonical off a request
44
+ # c.seo.locale = "en_US"
45
+ # c.seo.robots = "noindex, nofollow" # keep a staging/private site out of search
46
+ # c.seo.favicon = "/favicon.ico"
47
+ # c.seo.theme_color = "#0f172a" # tints mobile browser chrome
48
+ #
49
+ # Regenerate the social-share image from your OWN landing page (screenshots
50
+ # "/" into app/assets/images/og/) whenever it changes materially:
51
+ # bin/rails docs_kit:og
52
+
33
53
  # Code blocks use one Rouge theme by default. To keep them readable in BOTH
34
54
  # light and dark daisyUI themes, set a light base + a dark override — the dark
35
55
  # 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: 0.1.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -16,6 +16,9 @@ dependencies:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '1.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '2'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -23,6 +26,9 @@ dependencies:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '1.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '2'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: phlex-rails
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -64,6 +70,9 @@ dependencies:
64
70
  - - ">="
65
71
  - !ruby/object:Gem::Version
66
72
  version: '4.0'
73
+ - - "<"
74
+ - !ruby/object:Gem::Version
75
+ version: '5'
67
76
  type: :runtime
68
77
  prerelease: false
69
78
  version_requirements: !ruby/object:Gem::Requirement
@@ -71,6 +80,9 @@ dependencies:
71
80
  - - ">="
72
81
  - !ruby/object:Gem::Version
73
82
  version: '4.0'
83
+ - - "<"
84
+ - !ruby/object:Gem::Version
85
+ version: '5'
74
86
  - !ruby/object:Gem::Dependency
75
87
  name: commonmarker
76
88
  requirement: !ruby/object:Gem::Requirement
@@ -92,6 +104,9 @@ dependencies:
92
104
  - - ">="
93
105
  - !ruby/object:Gem::Version
94
106
  version: '1.15'
107
+ - - "<"
108
+ - !ruby/object:Gem::Version
109
+ version: '2'
95
110
  type: :runtime
96
111
  prerelease: false
97
112
  version_requirements: !ruby/object:Gem::Requirement
@@ -99,6 +114,9 @@ dependencies:
99
114
  - - ">="
100
115
  - !ruby/object:Gem::Version
101
116
  version: '1.15'
117
+ - - "<"
118
+ - !ruby/object:Gem::Version
119
+ version: '2'
102
120
  - !ruby/object:Gem::Dependency
103
121
  name: zeitwerk
104
122
  requirement: !ruby/object:Gem::Requirement
@@ -120,6 +138,9 @@ dependencies:
120
138
  - - ">="
121
139
  - !ruby/object:Gem::Version
122
140
  version: '1.75'
141
+ - - "<"
142
+ - !ruby/object:Gem::Version
143
+ version: '2'
123
144
  type: :development
124
145
  prerelease: false
125
146
  version_requirements: !ruby/object:Gem::Requirement
@@ -127,6 +148,9 @@ dependencies:
127
148
  - - ">="
128
149
  - !ruby/object:Gem::Version
129
150
  version: '1.75'
151
+ - - "<"
152
+ - !ruby/object:Gem::Version
153
+ version: '2'
130
154
  description: DocsKit is a reusable documentation-site component library for Phlex
131
155
  + daisyUI. It extracts the shared shell, sidebar, code blocks, and page kit so multiple
132
156
  docs sites look identical and are maintained in one place. Reactive demos (phlex-reactive)
@@ -140,6 +164,8 @@ files:
140
164
  - CHANGELOG.md
141
165
  - LICENSE.txt
142
166
  - README.md
167
+ - app/assets/images/og/og.png
168
+ - app/assets/images/og/og.svg
143
169
  - app/components/docs_ui/brand_mark.rb
144
170
  - app/components/docs_ui/callout.rb
145
171
  - app/components/docs_ui/code.rb
@@ -152,6 +178,7 @@ files:
152
178
  - app/components/docs_ui/json_response.rb
153
179
  - app/components/docs_ui/markdown.rb
154
180
  - app/components/docs_ui/markdown_action.rb
181
+ - app/components/docs_ui/meta_tags.rb
155
182
  - app/components/docs_ui/on_this_page.rb
156
183
  - app/components/docs_ui/open_api_operation.rb
157
184
  - app/components/docs_ui/page.rb
@@ -190,6 +217,7 @@ files:
190
217
  - lib/docs_kit/mcp_server.rb
191
218
  - lib/docs_kit/mcp_tools.rb
192
219
  - lib/docs_kit/nav_item.rb
220
+ - lib/docs_kit/og_generator.rb
193
221
  - lib/docs_kit/open_api.rb
194
222
  - lib/docs_kit/open_api/document.rb
195
223
  - lib/docs_kit/open_api/operation.rb
@@ -199,6 +227,7 @@ files:
199
227
  - lib/docs_kit/search_hit.rb
200
228
  - lib/docs_kit/search_index.rb
201
229
  - lib/docs_kit/search_index/snippet.rb
230
+ - lib/docs_kit/seo_config.rb
202
231
  - lib/docs_kit/shortcut.rb
203
232
  - lib/docs_kit/templates/new_site.rb
204
233
  - lib/docs_kit/topbar_link.rb
@@ -213,6 +242,7 @@ files:
213
242
  - lib/generators/docs_kit/install/templates/doc.rb.erb
214
243
  - lib/generators/docs_kit/install/templates/docs_controller.rb.erb
215
244
  - lib/generators/docs_kit/install/templates/docs_kit.rb.erb
245
+ - lib/generators/docs_kit/install/templates/docs_kit_og.rake
216
246
  - lib/generators/docs_kit/install/templates/installation_page.rb.erb
217
247
  - lib/generators/docs_kit/install/templates/landing.rb.erb
218
248
  - lib/generators/docs_kit/install/templates/landings_controller.rb.erb
@@ -246,7 +276,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
246
276
  - !ruby/object:Gem::Version
247
277
  version: '0'
248
278
  requirements: []
249
- rubygems_version: 4.0.9
279
+ rubygems_version: 3.6.9
250
280
  specification_version: 4
251
281
  summary: Shared Phlex docs-site chrome (shell, sidebar, code, theme switcher) built
252
282
  on daisyUI