Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@

### Added

- **`DocsUI::Landing` — a config-driven marketing landing page.** Every consuming
site (and this dogfood site) was hand-rolling a home page; now render
`DocsUI::Landing` and drive it from 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 `ctas`), 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. The install generator's `landings#show`
now renders it and the initializer documents `c.landing`. 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.
- **SEO + social sharing.** Every page now emits a complete SEO `<head>` —
meta description, Open Graph, Twitter Card, canonical, favicon, robots, and
theme-color — via the new `DocsUI::MetaTags` component, driven entirely by a
Expand Down
167 changes: 167 additions & 0 deletions app/components/docs_ui/landing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# frozen_string_literal: true

module DocsUI
# The marketing landing page — a hero (eyebrow + title + lead + optional install
# snippet + CTA buttons), a feature-card grid, and a registry-grouped
# documentation index — rendered inside DocsUI::Shell. Every consuming site was
# hand-rolling this; drive it from config instead:
#
# # config/initializers/docs_kit.rb
# DocsKit.configure do |c|
# c.landing.eyebrow = "Developer Docs"
# c.landing.title = "Jobs & events on **Postgres**" # ** ** → primary color
# c.landing.lead = "PostgreSQL-native jobs + event bus for Rails."
# c.landing.install = { code: 'gem "pgbus"', filename: "Gemfile", lexer: :ruby }
# c.landing.ctas = [{ label: "Get started", href: "/docs/overview", style: :primary }]
# c.landing.features = [{ icon: "database", title: "One database", body: "No Redis." }]
# end
#
# # a controller that includes DocsKit::Controller
# def show = render_page(DocsUI::Landing.new)
#
# Everything is optional: with an empty c.landing it still renders a minimal hero
# (the brand name + the doc index), never a broken page. The doc index is built
# from DocsKit.configuration.nav_groups — the same registry the sidebar uses — so
# it never drifts from the authored pages.
#
# It IS a full document (composes Shell), so a controller renders it with
# `layout: false`, exactly like DocsUI::Page (DocsKit::Controller#render_page
# does this). The `.md`/`.text` twin of the landing works too — MarkdownExport
# walks the same #docs-content region Shell stamps.
class Landing < Phlex::HTML
include Phlex::Rails::Helpers::Request

def view_template
render DocsUI::Shell.new(title: landing.eyebrow || config.brand) do
div(class: "mx-auto max-w-5xl") do
hero
feature_grid
doc_index
end
end
end

private

def config = DocsKit.configuration
def landing = config.landing

# --- hero ----------------------------------------------------------------

def hero
div(class: "flex flex-col gap-6") do
eyebrow
heading
lead
install_snippet
ctas
end
end

def eyebrow
return unless (text = landing.eyebrow)

p(class: "text-sm font-medium uppercase tracking-wide text-primary") { text }
end

# The <h1>. A **run** wrapped in double asterisks renders in the primary color
# (the one bit of markdown we honor, so a site can accent a word without HTML).
def heading
h1(class: "text-4xl font-bold tracking-tight md:text-5xl") do
(landing.title || config.brand).to_s.split(/\*\*(.+?)\*\*/).each_with_index do |part, index|
next if part.empty?

index.odd? ? span(class: "text-primary") { part } : plain(part)
end
end
end

def lead
return unless (text = landing.lead || config.tagline)

p(class: "max-w-2xl text-lg text-base-content/70") { text }
end

def install_snippet
return unless (snippet = landing.install_snippet)

render DocsUI::Code.new(snippet[:code], lexer: snippet[:lexer], filename: snippet[:filename])
end

def ctas
buttons = landing.ctas
return if buttons.empty?

div(class: "flex flex-wrap items-center gap-4 pt-2") do
buttons.each { |cta| cta_button(cta) }
end
end

def cta_button(cta)
attrs = { href: cta.href, class: "#{cta.btn_class} gap-2" }
if cta.external?
attrs[:target] = "_blank"
attrs[:rel] = "noopener"
end
a(**attrs) do
render DocsUI::BrandMark.new(cta.icon, class: "size-4", label: cta.label) if cta.icon
plain cta.label
end
end

# --- feature grid --------------------------------------------------------

def feature_grid
features = landing.features
return if features.empty?

div(class: "mt-12 grid gap-4 sm:grid-cols-2") do
features.each { |feature| feature_card(feature) }
end
end

def feature_card(feature)
div(class: "rounded-box border border-base-300 bg-base-200/40 p-5") do
div(class: "flex items-center gap-2 text-primary") do
render DocsUI::Icon.new(feature.icon, class: "size-5") if feature.icon
span(class: "font-semibold text-base-content") { feature.title }
end
p(class: "mt-2 text-sm text-base-content/70") { feature.body } if feature.body
end
end

# --- documentation index -------------------------------------------------

# The registry-grouped page index. nav_groups is the three-level Hash the
# sidebar renders ({ heading => { subgroup => [NavItem] } }); the landing
# flattens each heading's items into a linked column.
def doc_index
return if !landing.doc_index? || (groups = flattened_nav).empty?

div(class: "mt-16") do
h2(class: "text-sm font-semibold uppercase tracking-wide text-base-content/50") { "Documentation" }
div(class: "mt-6 grid gap-8 sm:grid-cols-2") do
groups.each { |heading, items| doc_index_group(heading, items) }
end
end
end

def doc_index_group(heading, items)
div do
h3(class: "text-xs font-semibold uppercase tracking-wide text-base-content/40") { heading }
ul(class: "mt-3 flex flex-col gap-2") do
items.each { |item| li { a(href: item.href, class: "link link-hover text-sm") { item.label } } }
end
end
end

# Collapse nav_groups ({ heading => { subgroup => [item] } }) to
# { heading => [item, ...] } — the landing shows one flat column per heading.
def flattened_nav
config.nav_groups.each_with_object({}) do |(heading, grouped), acc|
items = Array(grouped).flat_map { |_subgroup, list| Array(list) }
acc[heading] = items unless items.empty?
end
end
end
end
51 changes: 4 additions & 47 deletions docs/app/views/landings/show.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,55 +2,12 @@

module Views
module Landings
# The home page. Renders inside DocsUI::Shell (the full document + drawer
# shell); a short hero plus the authored docs, grouped like the sidebar.
# The home page — a marketing hero + feature grid + doc index, all from
# `c.landing` config (see config/initializers/docs_kit.rb). Renders the shared
# DocsUI::Landing component, so this site no longer hand-rolls a landing.
class Show < Phlex::HTML
include Phlex::Rails::Helpers::Routes

def view_template
render DocsUI::Shell.new do
hero
doc_index
end
end

private

def hero
div(class: "not-prose mb-10") do
p(class: "mb-2 text-sm font-medium uppercase tracking-wide text-primary") { "docs-kit" }
h1(class: "mb-4 text-4xl font-bold tracking-tight") { "Shared docs chrome for Rails, in Phlex." }
p(class: "max-w-2xl text-lg text-base-content/70") do
plain "A gem that gives you the shell, sidebar, theme switcher, syntax highlighting, "
plain "multi-language examples, and an automatic table of contents — configure it once, "
plain "write your pages, deploy with one workflow. "
strong { "This site is built with docs-kit." }
end
div(class: "mt-6 flex flex-wrap gap-3") do
a(href: "/docs/overview", class: "btn btn-primary") { "Get started" }
a(href: "/docs/components", class: "btn btn-ghost") { "Browse components" }
end
end
end

def doc_index
Doc.all.select(&:view_class).group_by(&:group).each do |group, docs|
div(class: "not-prose mb-8") do
h2(class: "mb-3 text-lg font-semibold") { group }
div(class: "grid gap-3 sm:grid-cols-2") do
docs.each { |doc| doc_card(doc) }
end
end
end
end

def doc_card(doc)
a(
href: "/docs/#{doc.slug}",
class: "block rounded-box border border-base-300 bg-base-200 p-4 transition hover:border-primary"
) do
div(class: "font-medium") { doc.title }
end
render DocsUI::Landing.new
end
end
end
Expand Down
32 changes: 31 additions & 1 deletion docs/config/initializers/docs_kit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# A link to the source repo in the topbar (next to the theme switcher),
# rendered with the shipped GitHub brand mark. Dogfoods c.topbar_links.
c.topbar_links = [
{ href: "https://github.com/mhenrixon/docs-kit", label: "GitHub", icon: :github },
{ href: "https://github.com/mhenrixon/docs-kit", label: "GitHub", icon: :github }
]

# SEO + social sharing, dogfooded. docs-kit emits the full <head> (description,
Expand Down Expand Up @@ -53,5 +53,35 @@
# tooling / Deploy). For bespoke nav (interleaved registries) set a `c.nav`
# lambda instead; it wins.
c.nav_registries = { "Docs" => Doc }

# The landing page (DocsUI::Landing), dogfooded — a hero + feature grid + a
# registry-grouped doc index, all from config. A **run** in the title renders
# in the primary color. See LandingsController#show (render_page).
c.landing.eyebrow = "docs-kit"
c.landing.title = "Shared docs chrome for **Rails**, in Phlex."
c.landing.lead = "The shell, sidebar, theme switcher, syntax highlighting, " \
"multi-language examples, and an automatic table of contents — " \
"configure it once, write your pages, deploy with one workflow. " \
"This site is built with docs-kit."
c.landing.install = { code: 'gem "docs-kit"', filename: "Gemfile", lexer: :ruby }
c.landing.ctas = [
{ label: "Get started", href: "/docs/overview", style: :primary },
{ label: "Browse components", href: "/docs/components", style: :ghost },
{ label: "GitHub", href: "https://github.com/mhenrixon/docs-kit", style: :ghost, icon: :github }
]
c.landing.features = [
{ icon: "layout-template", title: "One shared shell",
body: "The topbar, drawer sidebar, theme switcher, and content column — identical across every site, driven by config." },
{ icon: "code", title: "Syntax + multi-language examples",
body: "Rouge highlighting with a light/dark theme pair, and tabbed code with a sticky global language choice." },
{ icon: "list-tree", title: "Registry-driven nav & search",
body: "One `page` declaration feeds the sidebar, the search index, and llms.txt — they never drift from your pages." },
{ icon: "file-text", title: "Markdown twins + llms.txt",
body: "Every page has a .md twin and an llms.txt index for free, derived from the same render your readers see." },
{ icon: "plug", title: "API-reference kit",
body: "DocsUI::Endpoint / FieldTable / RequestExample turn one declaration into a badge, tables, and a tab per client." },
{ icon: "rocket", title: "Deploy with one workflow",
body: "Scaffold a deployable site with `docs-kit new`, or add it to an existing Rails app with the install generator." }
]
end
end
11 changes: 9 additions & 2 deletions docs/spec/requests/progressive_enhancement_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@
# (no browser, no JS executed), so a regression that made the page JS-dependent
# would fail here.
RSpec.describe "Progressive enhancement (JS off)", type: :request do
it "renders the landing page as a complete HTML document" do
it "renders the landing page (DocsUI::Landing) as a complete HTML document" do
get "/"

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

it "renders every sidebar section expanded (details open) so no-JS readers see the full nav" do
Expand Down
1 change: 1 addition & 0 deletions lib/docs_kit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ module DocsUI
# Required eagerly by configuration.rb (a plain-Ruby value object, no Rails), so
# ignore it here too or zeitwerk double-manages the constant.
loader.ignore(File.expand_path("docs_kit/seo_config.rb", __dir__))
loader.ignore(File.expand_path("docs_kit/landing_config.rb", __dir__))
# Loaded ONLY by the host's docs_kit:og rake task (an explicit require), never at
# gem runtime — so its Rack/browser tooling is never pulled into a host that
# doesn't run the task. Ignore it so eager_load! doesn't require it.
Expand Down
10 changes: 10 additions & 0 deletions lib/docs_kit/configuration.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "seo_config"
require_relative "landing_config"

module DocsKit
# Per-site configuration for the shared docs chrome. Everything that differs
Expand Down Expand Up @@ -281,6 +282,15 @@ def seo
@seo ||= DocsKit::SeoConfig.new
end

# The landing-page knobs (DocsKit::LandingConfig), read by DocsUI::Landing.
# Lazily built and memoized so a `c.landing.title = ...` block mutates the one
# instance the component later reads. A site that never touches it still gets a
# minimal hero + the doc index (see LandingConfig), so DocsUI::Landing is safe
# to render with zero landing config.
def landing
@landing ||= DocsKit::LandingConfig.new
end

# The loaded DocsKit::OpenApi::Document for #openapi. Memoized; when #openapi
# is a file path, the memo is invalidated on an mtime change so editing the
# spec in development is picked up without a server restart. Raises a
Expand Down
Loading
Loading