docs-kit 1.0.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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +44 -0
- data/app/assets/images/og/og.png +0 -0
- data/app/assets/images/og/og.svg +1 -0
- data/app/components/docs_ui/meta_tags.rb +163 -0
- data/app/components/docs_ui/page.rb +14 -1
- data/app/components/docs_ui/shell.rb +14 -4
- data/lib/docs_kit/configuration.rb +11 -0
- data/lib/docs_kit/og_generator.rb +167 -0
- data/lib/docs_kit/seo_config.rb +79 -0
- data/lib/docs_kit/templates/new_site.rb +3 -0
- data/lib/docs_kit/version.rb +1 -1
- data/lib/docs_kit.rb +7 -0
- data/lib/generators/docs_kit/install/install_generator.rb +14 -0
- data/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +20 -0
- data/lib/generators/docs_kit/install/templates/docs_kit_og.rake +44 -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: 72b8c93e9a6c2896bb31bd9ba8aaaf8cf5692424ab84d92ca8e5817d1b85291c
|
|
4
|
+
data.tar.gz: bfc8a48ab90cbb0d62083f864fd8f188a645070b86bf4af1ff63c9a302160e99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
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
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
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
|
data/lib/docs_kit/version.rb
CHANGED
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: 1.0.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -164,6 +164,8 @@ files:
|
|
|
164
164
|
- CHANGELOG.md
|
|
165
165
|
- LICENSE.txt
|
|
166
166
|
- README.md
|
|
167
|
+
- app/assets/images/og/og.png
|
|
168
|
+
- app/assets/images/og/og.svg
|
|
167
169
|
- app/components/docs_ui/brand_mark.rb
|
|
168
170
|
- app/components/docs_ui/callout.rb
|
|
169
171
|
- app/components/docs_ui/code.rb
|
|
@@ -176,6 +178,7 @@ files:
|
|
|
176
178
|
- app/components/docs_ui/json_response.rb
|
|
177
179
|
- app/components/docs_ui/markdown.rb
|
|
178
180
|
- app/components/docs_ui/markdown_action.rb
|
|
181
|
+
- app/components/docs_ui/meta_tags.rb
|
|
179
182
|
- app/components/docs_ui/on_this_page.rb
|
|
180
183
|
- app/components/docs_ui/open_api_operation.rb
|
|
181
184
|
- app/components/docs_ui/page.rb
|
|
@@ -214,6 +217,7 @@ files:
|
|
|
214
217
|
- lib/docs_kit/mcp_server.rb
|
|
215
218
|
- lib/docs_kit/mcp_tools.rb
|
|
216
219
|
- lib/docs_kit/nav_item.rb
|
|
220
|
+
- lib/docs_kit/og_generator.rb
|
|
217
221
|
- lib/docs_kit/open_api.rb
|
|
218
222
|
- lib/docs_kit/open_api/document.rb
|
|
219
223
|
- lib/docs_kit/open_api/operation.rb
|
|
@@ -223,6 +227,7 @@ files:
|
|
|
223
227
|
- lib/docs_kit/search_hit.rb
|
|
224
228
|
- lib/docs_kit/search_index.rb
|
|
225
229
|
- lib/docs_kit/search_index/snippet.rb
|
|
230
|
+
- lib/docs_kit/seo_config.rb
|
|
226
231
|
- lib/docs_kit/shortcut.rb
|
|
227
232
|
- lib/docs_kit/templates/new_site.rb
|
|
228
233
|
- lib/docs_kit/topbar_link.rb
|
|
@@ -237,6 +242,7 @@ files:
|
|
|
237
242
|
- lib/generators/docs_kit/install/templates/doc.rb.erb
|
|
238
243
|
- lib/generators/docs_kit/install/templates/docs_controller.rb.erb
|
|
239
244
|
- lib/generators/docs_kit/install/templates/docs_kit.rb.erb
|
|
245
|
+
- lib/generators/docs_kit/install/templates/docs_kit_og.rake
|
|
240
246
|
- lib/generators/docs_kit/install/templates/installation_page.rb.erb
|
|
241
247
|
- lib/generators/docs_kit/install/templates/landing.rb.erb
|
|
242
248
|
- lib/generators/docs_kit/install/templates/landings_controller.rb.erb
|