docs-kit 1.0.4 → 1.0.5

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: b0df82a287c454b9eff2243210da5dca51eb8f218434ede180e1ffdb3be7c941
4
- data.tar.gz: 215324e7604b6dacf964e736b6d29f38b30b4b047541ca5042dc1b1879132cae
3
+ metadata.gz: cf583f1cfdfc22d788c0dcffac91b35a374138d8670cfe24bdef3cc2e05eb171
4
+ data.tar.gz: 7c3168bcaf1d0d1939350d704a12cb39f0e37a561be75a255517dcf82e531eba
5
5
  SHA512:
6
- metadata.gz: 8fd44f0619a0f80d84ece5aff198252e4dd6bd74353d675ada893f585a8a335d456269a4ddcbe36a28c219687891f2058cfb7092748572dfb24ad7183a724931
7
- data.tar.gz: d2229edb327e97156d7e6ebdc593bbcdb52e6a68a6c92734697ca2e5898729cf01b6f192c33be03c1b27f4339ecdae31521c8de8c7c145cc4f6d89c01bc9e037
6
+ metadata.gz: 43c6f2f94b23ca4ad589a701f92121b3dcc3027cc06173432a653e215e9c4da2ab62e1a2044cf9ef3b1e19be9516d7a2698fc6b259f390ce3a15e5c2df50f6eb
7
+ data.tar.gz: 001253000ff36f16cd9ab76377cb4175dcab526a5a8dc2897fd0bde9809016d0dbd42fe5aa1e6474c24e7260eed25468843c393a4e73d447dc01f16c1a87b489
data/CHANGELOG.md CHANGED
@@ -18,6 +18,19 @@
18
18
 
19
19
  ### Added
20
20
 
21
+ - **`DocsUI::Landing` — a config-driven marketing landing page.** Every consuming
22
+ site (and this dogfood site) was hand-rolling a home page; now render
23
+ `DocsUI::Landing` and drive it from a new `c.landing` config block
24
+ (`DocsKit::LandingConfig`): a hero (`eyebrow`, `title` — wrap a run in
25
+ `**double asterisks**` to accent it in the primary color, `lead`, an optional
26
+ `install` code snippet, and `ctas`), a `features` card grid, and a
27
+ registry-grouped documentation index built from `nav_groups` (so it never drifts
28
+ from the authored pages). Every field is optional — with an empty `c.landing` it
29
+ still renders a minimal hero (brand + doc index), never a broken page — and its
30
+ `.md`/`.text` twin works like any page. The install generator's `landings#show`
31
+ now renders it and the initializer documents `c.landing`. This is the first
32
+ landing pattern proven on a **mounted** docs app (a docs section inside a larger
33
+ Rails app whose `/` is already taken), contributed back from that use case.
21
34
  - **SEO + social sharing.** Every page now emits a complete SEO `<head>` —
22
35
  meta description, Open Graph, Twitter Card, canonical, favicon, robots, and
23
36
  theme-color — via the new `DocsUI::MetaTags` component, driven entirely by a
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsUI
4
+ # The marketing landing page — a hero (eyebrow + title + lead + optional install
5
+ # snippet + CTA buttons), a feature-card grid, and a registry-grouped
6
+ # documentation index — rendered inside DocsUI::Shell. Every consuming site was
7
+ # hand-rolling this; drive it from config instead:
8
+ #
9
+ # # config/initializers/docs_kit.rb
10
+ # DocsKit.configure do |c|
11
+ # c.landing.eyebrow = "Developer Docs"
12
+ # c.landing.title = "Jobs & events on **Postgres**" # ** ** → primary color
13
+ # c.landing.lead = "PostgreSQL-native jobs + event bus for Rails."
14
+ # c.landing.install = { code: 'gem "pgbus"', filename: "Gemfile", lexer: :ruby }
15
+ # c.landing.ctas = [{ label: "Get started", href: "/docs/overview", style: :primary }]
16
+ # c.landing.features = [{ icon: "database", title: "One database", body: "No Redis." }]
17
+ # end
18
+ #
19
+ # # a controller that includes DocsKit::Controller
20
+ # def show = render_page(DocsUI::Landing.new)
21
+ #
22
+ # Everything is optional: with an empty c.landing it still renders a minimal hero
23
+ # (the brand name + the doc index), never a broken page. The doc index is built
24
+ # from DocsKit.configuration.nav_groups — the same registry the sidebar uses — so
25
+ # it never drifts from the authored pages.
26
+ #
27
+ # It IS a full document (composes Shell), so a controller renders it with
28
+ # `layout: false`, exactly like DocsUI::Page (DocsKit::Controller#render_page
29
+ # does this). The `.md`/`.text` twin of the landing works too — MarkdownExport
30
+ # walks the same #docs-content region Shell stamps.
31
+ class Landing < Phlex::HTML
32
+ include Phlex::Rails::Helpers::Request
33
+
34
+ def view_template
35
+ render DocsUI::Shell.new(title: landing.eyebrow || config.brand) do
36
+ div(class: "mx-auto max-w-5xl") do
37
+ hero
38
+ feature_grid
39
+ doc_index
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def config = DocsKit.configuration
47
+ def landing = config.landing
48
+
49
+ # --- hero ----------------------------------------------------------------
50
+
51
+ def hero
52
+ div(class: "flex flex-col gap-6") do
53
+ eyebrow
54
+ heading
55
+ lead
56
+ install_snippet
57
+ ctas
58
+ end
59
+ end
60
+
61
+ def eyebrow
62
+ return unless (text = landing.eyebrow)
63
+
64
+ p(class: "text-sm font-medium uppercase tracking-wide text-primary") { text }
65
+ end
66
+
67
+ # The <h1>. A **run** wrapped in double asterisks renders in the primary color
68
+ # (the one bit of markdown we honor, so a site can accent a word without HTML).
69
+ def heading
70
+ h1(class: "text-4xl font-bold tracking-tight md:text-5xl") do
71
+ (landing.title || config.brand).to_s.split(/\*\*(.+?)\*\*/).each_with_index do |part, index|
72
+ next if part.empty?
73
+
74
+ index.odd? ? span(class: "text-primary") { part } : plain(part)
75
+ end
76
+ end
77
+ end
78
+
79
+ def lead
80
+ return unless (text = landing.lead || config.tagline)
81
+
82
+ p(class: "max-w-2xl text-lg text-base-content/70") { text }
83
+ end
84
+
85
+ def install_snippet
86
+ return unless (snippet = landing.install_snippet)
87
+
88
+ render DocsUI::Code.new(snippet[:code], lexer: snippet[:lexer], filename: snippet[:filename])
89
+ end
90
+
91
+ def ctas
92
+ buttons = landing.ctas
93
+ return if buttons.empty?
94
+
95
+ div(class: "flex flex-wrap items-center gap-4 pt-2") do
96
+ buttons.each { |cta| cta_button(cta) }
97
+ end
98
+ end
99
+
100
+ def cta_button(cta)
101
+ attrs = { href: cta.href, class: "#{cta.btn_class} gap-2" }
102
+ if cta.external?
103
+ attrs[:target] = "_blank"
104
+ attrs[:rel] = "noopener"
105
+ end
106
+ a(**attrs) do
107
+ render DocsUI::BrandMark.new(cta.icon, class: "size-4", label: cta.label) if cta.icon
108
+ plain cta.label
109
+ end
110
+ end
111
+
112
+ # --- feature grid --------------------------------------------------------
113
+
114
+ def feature_grid
115
+ features = landing.features
116
+ return if features.empty?
117
+
118
+ div(class: "mt-12 grid gap-4 sm:grid-cols-2") do
119
+ features.each { |feature| feature_card(feature) }
120
+ end
121
+ end
122
+
123
+ def feature_card(feature)
124
+ div(class: "rounded-box border border-base-300 bg-base-200/40 p-5") do
125
+ div(class: "flex items-center gap-2 text-primary") do
126
+ render DocsUI::Icon.new(feature.icon, class: "size-5") if feature.icon
127
+ span(class: "font-semibold text-base-content") { feature.title }
128
+ end
129
+ p(class: "mt-2 text-sm text-base-content/70") { feature.body } if feature.body
130
+ end
131
+ end
132
+
133
+ # --- documentation index -------------------------------------------------
134
+
135
+ # The registry-grouped page index. nav_groups is the three-level Hash the
136
+ # sidebar renders ({ heading => { subgroup => [NavItem] } }); the landing
137
+ # flattens each heading's items into a linked column.
138
+ def doc_index
139
+ return if !landing.doc_index? || (groups = flattened_nav).empty?
140
+
141
+ div(class: "mt-16") do
142
+ h2(class: "text-sm font-semibold uppercase tracking-wide text-base-content/50") { "Documentation" }
143
+ div(class: "mt-6 grid gap-8 sm:grid-cols-2") do
144
+ groups.each { |heading, items| doc_index_group(heading, items) }
145
+ end
146
+ end
147
+ end
148
+
149
+ def doc_index_group(heading, items)
150
+ div do
151
+ h3(class: "text-xs font-semibold uppercase tracking-wide text-base-content/40") { heading }
152
+ ul(class: "mt-3 flex flex-col gap-2") do
153
+ items.each { |item| li { a(href: item.href, class: "link link-hover text-sm") { item.label } } }
154
+ end
155
+ end
156
+ end
157
+
158
+ # Collapse nav_groups ({ heading => { subgroup => [item] } }) to
159
+ # { heading => [item, ...] } — the landing shows one flat column per heading.
160
+ def flattened_nav
161
+ config.nav_groups.each_with_object({}) do |(heading, grouped), acc|
162
+ items = Array(grouped).flat_map { |_subgroup, list| Array(list) }
163
+ acc[heading] = items unless items.empty?
164
+ end
165
+ end
166
+ end
167
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "seo_config"
4
+ require_relative "landing_config"
4
5
 
5
6
  module DocsKit
6
7
  # Per-site configuration for the shared docs chrome. Everything that differs
@@ -281,6 +282,15 @@ module DocsKit
281
282
  @seo ||= DocsKit::SeoConfig.new
282
283
  end
283
284
 
285
+ # The landing-page knobs (DocsKit::LandingConfig), read by DocsUI::Landing.
286
+ # Lazily built and memoized so a `c.landing.title = ...` block mutates the one
287
+ # instance the component later reads. A site that never touches it still gets a
288
+ # minimal hero + the doc index (see LandingConfig), so DocsUI::Landing is safe
289
+ # to render with zero landing config.
290
+ def landing
291
+ @landing ||= DocsKit::LandingConfig.new
292
+ end
293
+
284
294
  # The loaded DocsKit::OpenApi::Document for #openapi. Memoized; when #openapi
285
295
  # is a file path, the memo is invalidated on an mtime change so editing the
286
296
  # spec in development is picked up without a server restart. Raises a
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DocsKit
4
+ # The per-site landing-page knobs, read by DocsUI::Landing to render a marketing
5
+ # home page (hero + feature grid + doc index) without a site hand-rolling one.
6
+ # Nested under DocsKit::Configuration#landing so a site configures it as a block:
7
+ #
8
+ # DocsKit.configure do |c|
9
+ # c.landing.eyebrow = "Developer Docs"
10
+ # c.landing.title = "Jobs & events on Postgres" # highlight a run with **…**
11
+ # c.landing.lead = "PostgreSQL-native job processing and event bus for Rails."
12
+ # c.landing.install = { code: 'gem "pgbus"', filename: "Gemfile", lexer: :ruby }
13
+ # c.landing.ctas = [
14
+ # { label: "Get started", href: "/docs/overview", style: :primary },
15
+ # { label: "GitHub", href: "https://github.com/me/repo", style: :ghost, icon: :github },
16
+ # ]
17
+ # c.landing.features = [
18
+ # { icon: "database", title: "One database", body: "No Redis, no broker." },
19
+ # { icon: "zap", title: "Fast", body: "…" },
20
+ # ]
21
+ # end
22
+ #
23
+ # Every field is optional and defaults to a backwards-safe value: a site that
24
+ # sets none still renders a minimal hero (the brand + a doc index), never a
25
+ # broken page. Plain accessors (not Data.define) because each field is
26
+ # individually assignable in the `c.landing.x = ...` block, mirroring
27
+ # DocsKit::SeoConfig.
28
+ class LandingConfig
29
+ # A small uppercase kicker above the title (e.g. "Developer Docs"). nil omits it.
30
+ attr_accessor :eyebrow
31
+
32
+ # The hero <h1>. Wrap a run in **double asterisks** to render it in the primary
33
+ # color (e.g. "Jobs & events on **Postgres**"). nil falls back to the brand.
34
+ attr_accessor :title
35
+
36
+ # The muted lead paragraph under the title. nil falls back to the tagline.
37
+ attr_accessor :lead
38
+
39
+ # An optional install/quickstart code block shown in the hero, as a Hash:
40
+ # { code: "gem \"x\"", filename: "Gemfile", lexer: :ruby }
41
+ # nil omits the block. See #install_snippet for the normalized form.
42
+ attr_accessor :install
43
+
44
+ # Whether to render the registry-grouped documentation index below the hero
45
+ # (the "Documentation" section linking every authored page). Default true.
46
+ attr_writer :doc_index
47
+
48
+ # The hero call-to-action buttons, each a Hash normalized into a Cta:
49
+ # { label:, href:, style: :primary|:ghost (default :ghost), icon: (brand/lucide token) }
50
+ attr_writer :ctas
51
+
52
+ # The feature cards shown in a grid under the hero, each a Hash normalized into
53
+ # a Feature: { icon: (lucide name), title:, body: }.
54
+ attr_writer :features
55
+
56
+ def initialize
57
+ @doc_index = true
58
+ @ctas = []
59
+ @features = []
60
+ end
61
+
62
+ # Whether the doc index is shown (default true).
63
+ def doc_index? = @doc_index != false
64
+
65
+ # The CTAs as normalized Cta value objects (empty when unset).
66
+ def ctas
67
+ Array(@ctas).map { |cta| Cta.from(cta) }
68
+ end
69
+
70
+ # The features as normalized Feature value objects (empty when unset).
71
+ def features
72
+ Array(@features).map { |feature| Feature.from(feature) }
73
+ end
74
+
75
+ # The install block normalized to { code:, filename:, lexer: } with a sensible
76
+ # default lexer, or nil when unset.
77
+ def install_snippet
78
+ return if @install.nil?
79
+
80
+ attrs = @install.to_h.transform_keys(&:to_sym)
81
+ { code: attrs[:code].to_s, filename: attrs[:filename], lexer: (attrs[:lexer] || :shell).to_sym }
82
+ end
83
+
84
+ # One hero call-to-action button. `style` maps to a daisyUI btn variant
85
+ # (:primary → btn-primary, anything else → btn-ghost). `icon` is an optional
86
+ # brand/lucide token rendered before the label (DocsUI::BrandMark resolves it).
87
+ Cta = Data.define(:label, :href, :style, :icon) do
88
+ def initialize(label:, href:, style: :ghost, icon: nil)
89
+ super(label:, href:, style: style&.to_sym, icon: icon)
90
+ end
91
+
92
+ def self.from(cta)
93
+ return cta if cta.is_a?(self)
94
+
95
+ attrs = cta.to_h.transform_keys(&:to_sym)
96
+ new(label: attrs[:label], href: attrs[:href], style: attrs[:style] || :ghost, icon: attrs[:icon])
97
+ end
98
+
99
+ # The daisyUI button class for this CTA's style.
100
+ def btn_class = style == :primary ? "btn btn-primary" : "btn btn-ghost"
101
+
102
+ # Whether the href points off-site (absolute http/https) — the component adds
103
+ # target=_blank + rel=noopener only for external links.
104
+ def external? = href.to_s.match?(%r{\Ahttps?://}i)
105
+ end
106
+
107
+ # One feature card: a lucide icon name, a title, and a short body.
108
+ Feature = Data.define(:icon, :title, :body) do
109
+ def initialize(title:, icon: nil, body: nil)
110
+ super
111
+ end
112
+
113
+ def self.from(feature)
114
+ return feature if feature.is_a?(self)
115
+
116
+ attrs = feature.to_h.transform_keys(&:to_sym)
117
+ new(icon: attrs[:icon], title: attrs[:title], body: attrs[:body])
118
+ end
119
+ end
120
+ end
121
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DocsKit
4
- VERSION = "1.0.4"
4
+ VERSION = "1.0.5"
5
5
  end
data/lib/docs_kit.rb CHANGED
@@ -57,6 +57,7 @@ loader.ignore(File.expand_path("docs_kit/configuration.rb", __dir__))
57
57
  # Required eagerly by configuration.rb (a plain-Ruby value object, no Rails), so
58
58
  # ignore it here too or zeitwerk double-manages the constant.
59
59
  loader.ignore(File.expand_path("docs_kit/seo_config.rb", __dir__))
60
+ loader.ignore(File.expand_path("docs_kit/landing_config.rb", __dir__))
60
61
  # Loaded ONLY by the host's docs_kit:og rake task (an explicit require), never at
61
62
  # gem runtime — so its Rack/browser tooling is never pulled into a host that
62
63
  # doesn't run the task. Ignore it so eager_load! doesn't require it.
@@ -104,6 +104,25 @@ Rails.application.config.to_prepare do
104
104
  # then. README → "Add your docs to an agent (MCP)".
105
105
  # c.mcp = false
106
106
 
107
+ # The landing page (app/views/landings/show.rb renders DocsUI::Landing) — a
108
+ # marketing hero + feature grid + a registry-grouped doc index, all from these
109
+ # knobs. Every field is optional; with none set it still renders a minimal hero
110
+ # (the brand + the doc index). Wrap a run in **double asterisks** to accent it
111
+ # in the primary color.
112
+ # c.landing.eyebrow = "Developer Docs"
113
+ # c.landing.title = "The <%= app_brand %> **API**"
114
+ # c.landing.lead = "One sentence on what your product does."
115
+ # c.landing.install = { code: 'gem "<%= app_brand.downcase %>"', filename: "Gemfile", lexer: :ruby }
116
+ # c.landing.ctas = [
117
+ # { label: "Get started", href: "/docs/overview", style: :primary },
118
+ # { label: "GitHub", href: "https://github.com/OWNER/REPO", style: :ghost, icon: :github },
119
+ # ]
120
+ # c.landing.features = [
121
+ # { icon: "zap", title: "Fast", body: "Why it's fast." },
122
+ # { icon: "database", title: "One database", body: "No extra moving parts." },
123
+ # ]
124
+ # c.landing.doc_index = false # hide the "Documentation" index section
125
+
107
126
  # The sidebar nav derives from the registry — one heading → one registry.
108
127
  # Each registry's authored pages become NavItems automatically (an unwritten
109
128
  # page is skipped, so no dead links). For bespoke nav (interleaved
@@ -2,23 +2,14 @@
2
2
 
3
3
  module Views
4
4
  module Landings
5
- # The home page. Renders inside DocsUI::Shell (the full document + drawer
6
- # shell); links to the authored docs.
5
+ # The home page a marketing hero + feature grid + doc index, rendered by the
6
+ # shared DocsUI::Landing component. Customize it entirely from config:
7
+ # `c.landing.{eyebrow, title, lead, install, ctas, features}` in
8
+ # config/initializers/docs_kit.rb. With no landing config it still renders a
9
+ # minimal hero (the brand + the doc index), so this works out of the box.
7
10
  class Show < Phlex::HTML
8
- include Phlex::Rails::Helpers::Routes
9
-
10
11
  def view_template
11
- render DocsUI::Shell.new do
12
- div(class: "prose max-w-none") do
13
- h1 { "<%= app_brand %>" }
14
- p { "Documentation, built with docs-kit." }
15
- ul do
16
- Doc.all.select(&:view_class).each do |doc|
17
- li { a(href: "/docs/#{doc.slug}", class: "link") { doc.title } }
18
- end
19
- end
20
- end
21
- end
12
+ render DocsUI::Landing.new
22
13
  end
23
14
  end
24
15
  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
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -174,6 +174,7 @@ files:
174
174
  - app/components/docs_ui/header.rb
175
175
  - app/components/docs_ui/icon.rb
176
176
  - app/components/docs_ui/json_response.rb
177
+ - app/components/docs_ui/landing.rb
177
178
  - app/components/docs_ui/markdown.rb
178
179
  - app/components/docs_ui/markdown_action.rb
179
180
  - app/components/docs_ui/meta_tags.rb
@@ -207,6 +208,7 @@ files:
207
208
  - lib/docs_kit/configuration.rb
208
209
  - lib/docs_kit/controller.rb
209
210
  - lib/docs_kit/engine.rb
211
+ - lib/docs_kit/landing_config.rb
210
212
  - lib/docs_kit/llms_text.rb
211
213
  - lib/docs_kit/markdown_export.rb
212
214
  - lib/docs_kit/markdown_export/blocks.rb