Skip to content

Commit 41ecde4

Browse files
authored
feat(landing): DocsUI::Landing — a config-driven marketing landing page (#55)
* feat(landing): DocsUI::Landing — a config-driven marketing landing page Every consuming site (and this dogfood site) was hand-rolling a home page. Add a shared DocsUI::Landing component driven by a new c.landing config block (DocsKit::LandingConfig): - a hero: eyebrow, title (wrap a run in **double asterisks** to accent it in the primary color), lead, an optional install code snippet, and CTA buttons; - a features card grid; and - a registry-grouped documentation index built from nav_groups, so it never drifts from the authored pages. Every field is optional — with an empty c.landing it still renders a minimal hero (brand + doc index), never a broken page — and its .md/.text twin works like any page (it composes DocsUI::Shell, rendered layout:false). - lib/docs_kit/landing_config.rb: the config + Cta/Feature value objects (Hash → value-object normalization, like TopbarLink), wired as c.landing (memoized like c.seo). - app/components/docs_ui/landing.rb: the component. - Generator: landings#show now renders DocsUI::Landing; the initializer template documents c.landing. - Dogfood: the docs-kit site's own landing now uses it (config in the initializer), proving the pattern on a real site. This is the first landing pattern proven on a MOUNTED docs app (a docs section inside a larger Rails app whose "/" is already taken) — contributed back from that use case. Tests: 91 config/component-config examples + a dogfood request spec; full gem suite 755 green, 94.7% line coverage, rubocop clean. * test(dogfood): point the landing 'Get started' CTA at /docs/overview The docs_chrome system spec asserts have_link('Get started', href: '/docs/overview'); the dogfood landing config pointed it at /docs/installation. Align the CTA with the existing chrome contract.
1 parent 7c70700 commit 41ecde4

12 files changed

Lines changed: 512 additions & 65 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@
1818

1919
### Added
2020

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.
2134
- **SEO + social sharing.** Every page now emits a complete SEO `<head>`
2235
meta description, Open Graph, Twitter Card, canonical, favicon, robots, and
2336
theme-color — via the new `DocsUI::MetaTags` component, driven entirely by a

app/components/docs_ui/landing.rb

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

docs/app/views/landings/show.rb

Lines changed: 4 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,55 +2,12 @@
22

33
module Views
44
module Landings
5-
# The home page. Renders inside DocsUI::Shell (the full document + drawer
6-
# shell); a short hero plus the authored docs, grouped like the sidebar.
5+
# The home page — a marketing hero + feature grid + doc index, all from
6+
# `c.landing` config (see config/initializers/docs_kit.rb). Renders the shared
7+
# DocsUI::Landing component, so this site no longer hand-rolls a landing.
78
class Show < Phlex::HTML
8-
include Phlex::Rails::Helpers::Routes
9-
109
def view_template
11-
render DocsUI::Shell.new do
12-
hero
13-
doc_index
14-
end
15-
end
16-
17-
private
18-
19-
def hero
20-
div(class: "not-prose mb-10") do
21-
p(class: "mb-2 text-sm font-medium uppercase tracking-wide text-primary") { "docs-kit" }
22-
h1(class: "mb-4 text-4xl font-bold tracking-tight") { "Shared docs chrome for Rails, in Phlex." }
23-
p(class: "max-w-2xl text-lg text-base-content/70") do
24-
plain "A gem that gives you the shell, sidebar, theme switcher, syntax highlighting, "
25-
plain "multi-language examples, and an automatic table of contents — configure it once, "
26-
plain "write your pages, deploy with one workflow. "
27-
strong { "This site is built with docs-kit." }
28-
end
29-
div(class: "mt-6 flex flex-wrap gap-3") do
30-
a(href: "/docs/overview", class: "btn btn-primary") { "Get started" }
31-
a(href: "/docs/components", class: "btn btn-ghost") { "Browse components" }
32-
end
33-
end
34-
end
35-
36-
def doc_index
37-
Doc.all.select(&:view_class).group_by(&:group).each do |group, docs|
38-
div(class: "not-prose mb-8") do
39-
h2(class: "mb-3 text-lg font-semibold") { group }
40-
div(class: "grid gap-3 sm:grid-cols-2") do
41-
docs.each { |doc| doc_card(doc) }
42-
end
43-
end
44-
end
45-
end
46-
47-
def doc_card(doc)
48-
a(
49-
href: "/docs/#{doc.slug}",
50-
class: "block rounded-box border border-base-300 bg-base-200 p-4 transition hover:border-primary"
51-
) do
52-
div(class: "font-medium") { doc.title }
53-
end
10+
render DocsUI::Landing.new
5411
end
5512
end
5613
end

docs/config/initializers/docs_kit.rb

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
# A link to the source repo in the topbar (next to the theme switcher),
1818
# rendered with the shipped GitHub brand mark. Dogfoods c.topbar_links.
1919
c.topbar_links = [
20-
{ href: "https://github.com/mhenrixon/docs-kit", label: "GitHub", icon: :github },
20+
{ href: "https://github.com/mhenrixon/docs-kit", label: "GitHub", icon: :github }
2121
]
2222

2323
# SEO + social sharing, dogfooded. docs-kit emits the full <head> (description,
@@ -53,5 +53,35 @@
5353
# tooling / Deploy). For bespoke nav (interleaved registries) set a `c.nav`
5454
# lambda instead; it wins.
5555
c.nav_registries = { "Docs" => Doc }
56+
57+
# The landing page (DocsUI::Landing), dogfooded — a hero + feature grid + a
58+
# registry-grouped doc index, all from config. A **run** in the title renders
59+
# in the primary color. See LandingsController#show (render_page).
60+
c.landing.eyebrow = "docs-kit"
61+
c.landing.title = "Shared docs chrome for **Rails**, in Phlex."
62+
c.landing.lead = "The shell, sidebar, theme switcher, syntax highlighting, " \
63+
"multi-language examples, and an automatic table of contents — " \
64+
"configure it once, write your pages, deploy with one workflow. " \
65+
"This site is built with docs-kit."
66+
c.landing.install = { code: 'gem "docs-kit"', filename: "Gemfile", lexer: :ruby }
67+
c.landing.ctas = [
68+
{ label: "Get started", href: "/docs/overview", style: :primary },
69+
{ label: "Browse components", href: "/docs/components", style: :ghost },
70+
{ label: "GitHub", href: "https://github.com/mhenrixon/docs-kit", style: :ghost, icon: :github }
71+
]
72+
c.landing.features = [
73+
{ icon: "layout-template", title: "One shared shell",
74+
body: "The topbar, drawer sidebar, theme switcher, and content column — identical across every site, driven by config." },
75+
{ icon: "code", title: "Syntax + multi-language examples",
76+
body: "Rouge highlighting with a light/dark theme pair, and tabbed code with a sticky global language choice." },
77+
{ icon: "list-tree", title: "Registry-driven nav & search",
78+
body: "One `page` declaration feeds the sidebar, the search index, and llms.txt — they never drift from your pages." },
79+
{ icon: "file-text", title: "Markdown twins + llms.txt",
80+
body: "Every page has a .md twin and an llms.txt index for free, derived from the same render your readers see." },
81+
{ icon: "plug", title: "API-reference kit",
82+
body: "DocsUI::Endpoint / FieldTable / RequestExample turn one declaration into a badge, tables, and a tab per client." },
83+
{ icon: "rocket", title: "Deploy with one workflow",
84+
body: "Scaffold a deployable site with `docs-kit new`, or add it to an existing Rails app with the install generator." }
85+
]
5686
end
5787
end

docs/spec/requests/progressive_enhancement_spec.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,19 @@
88
# (no browser, no JS executed), so a regression that made the page JS-dependent
99
# would fail here.
1010
RSpec.describe "Progressive enhancement (JS off)", type: :request do
11-
it "renders the landing page as a complete HTML document" do
11+
it "renders the landing page (DocsUI::Landing) as a complete HTML document" do
1212
get "/"
1313

1414
expect(response).to have_http_status(:ok)
1515
expect(response.body).to include("<!doctype html>").or include("<!DOCTYPE html>")
16-
expect(response.body).to include("Shared docs chrome for Rails")
16+
# The hero title, with the **Rails** run rendered in the primary color.
17+
expect(response.body).to include("Shared docs chrome for")
18+
expect(response.body).to include(%(<span class="text-primary">Rails</span>))
19+
# A feature card and a CTA prove the config-driven landing rendered.
20+
expect(response.body).to include("One shared shell")
21+
expect(response.body).to include("Get started")
22+
# The registry-grouped doc index links the authored pages.
23+
expect(response.body).to include("/docs/installation")
1724
end
1825

1926
it "renders every sidebar section expanded (details open) so no-JS readers see the full nav" do

lib/docs_kit.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ module DocsUI
5757
# Required eagerly by configuration.rb (a plain-Ruby value object, no Rails), so
5858
# ignore it here too or zeitwerk double-manages the constant.
5959
loader.ignore(File.expand_path("docs_kit/seo_config.rb", __dir__))
60+
loader.ignore(File.expand_path("docs_kit/landing_config.rb", __dir__))
6061
# Loaded ONLY by the host's docs_kit:og rake task (an explicit require), never at
6162
# gem runtime — so its Rack/browser tooling is never pulled into a host that
6263
# doesn't run the task. Ignore it so eager_load! doesn't require it.

lib/docs_kit/configuration.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative "seo_config"
4+
require_relative "landing_config"
45

56
module DocsKit
67
# Per-site configuration for the shared docs chrome. Everything that differs
@@ -281,6 +282,15 @@ def seo
281282
@seo ||= DocsKit::SeoConfig.new
282283
end
283284

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+
284294
# The loaded DocsKit::OpenApi::Document for #openapi. Memoized; when #openapi
285295
# is a file path, the memo is invalidated on an mtime change so editing the
286296
# spec in development is picked up without a server restart. Raises a

0 commit comments

Comments
 (0)