diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b28df1..2794aa8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
` —
meta description, Open Graph, Twitter Card, canonical, favicon, robots, and
theme-color — via the new `DocsUI::MetaTags` component, driven entirely by a
diff --git a/app/components/docs_ui/landing.rb b/app/components/docs_ui/landing.rb
new file mode 100644
index 0000000..de0f1c0
--- /dev/null
+++ b/app/components/docs_ui/landing.rb
@@ -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 . 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
diff --git a/docs/app/views/landings/show.rb b/docs/app/views/landings/show.rb
index 2c59ee9..db3b50e 100644
--- a/docs/app/views/landings/show.rb
+++ b/docs/app/views/landings/show.rb
@@ -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
diff --git a/docs/config/initializers/docs_kit.rb b/docs/config/initializers/docs_kit.rb
index 95a25ef..e7bf408 100644
--- a/docs/config/initializers/docs_kit.rb
+++ b/docs/config/initializers/docs_kit.rb
@@ -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 (description,
@@ -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
diff --git a/docs/spec/requests/progressive_enhancement_spec.rb b/docs/spec/requests/progressive_enhancement_spec.rb
index bf6272e..b50f8bc 100644
--- a/docs/spec/requests/progressive_enhancement_spec.rb
+++ b/docs/spec/requests/progressive_enhancement_spec.rb
@@ -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("").or include("")
- 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(%(Rails))
+ # 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
diff --git a/lib/docs_kit.rb b/lib/docs_kit.rb
index b956a8d..fce9d56 100644
--- a/lib/docs_kit.rb
+++ b/lib/docs_kit.rb
@@ -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.
diff --git a/lib/docs_kit/configuration.rb b/lib/docs_kit/configuration.rb
index b3c7d65..56e0e3a 100644
--- a/lib/docs_kit/configuration.rb
+++ b/lib/docs_kit/configuration.rb
@@ -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
@@ -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
diff --git a/lib/docs_kit/landing_config.rb b/lib/docs_kit/landing_config.rb
new file mode 100644
index 0000000..10aa21c
--- /dev/null
+++ b/lib/docs_kit/landing_config.rb
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+
+module DocsKit
+ # The per-site landing-page knobs, read by DocsUI::Landing to render a marketing
+ # home page (hero + feature grid + doc index) without a site hand-rolling one.
+ # Nested under DocsKit::Configuration#landing so a site configures it as a block:
+ #
+ # DocsKit.configure do |c|
+ # c.landing.eyebrow = "Developer Docs"
+ # c.landing.title = "Jobs & events on Postgres" # highlight a run with **…**
+ # c.landing.lead = "PostgreSQL-native job processing and 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 },
+ # { label: "GitHub", href: "https://github.com/me/repo", style: :ghost, icon: :github },
+ # ]
+ # c.landing.features = [
+ # { icon: "database", title: "One database", body: "No Redis, no broker." },
+ # { icon: "zap", title: "Fast", body: "…" },
+ # ]
+ # end
+ #
+ # Every field is optional and defaults to a backwards-safe value: a site that
+ # sets none still renders a minimal hero (the brand + a doc index), never a
+ # broken page. Plain accessors (not Data.define) because each field is
+ # individually assignable in the `c.landing.x = ...` block, mirroring
+ # DocsKit::SeoConfig.
+ class LandingConfig
+ # A small uppercase kicker above the title (e.g. "Developer Docs"). nil omits it.
+ attr_accessor :eyebrow
+
+ # The hero . Wrap a run in **double asterisks** to render it in the primary
+ # color (e.g. "Jobs & events on **Postgres**"). nil falls back to the brand.
+ attr_accessor :title
+
+ # The muted lead paragraph under the title. nil falls back to the tagline.
+ attr_accessor :lead
+
+ # An optional install/quickstart code block shown in the hero, as a Hash:
+ # { code: "gem \"x\"", filename: "Gemfile", lexer: :ruby }
+ # nil omits the block. See #install_snippet for the normalized form.
+ attr_accessor :install
+
+ # Whether to render the registry-grouped documentation index below the hero
+ # (the "Documentation" section linking every authored page). Default true.
+ attr_writer :doc_index
+
+ # The hero call-to-action buttons, each a Hash normalized into a Cta:
+ # { label:, href:, style: :primary|:ghost (default :ghost), icon: (brand/lucide token) }
+ attr_writer :ctas
+
+ # The feature cards shown in a grid under the hero, each a Hash normalized into
+ # a Feature: { icon: (lucide name), title:, body: }.
+ attr_writer :features
+
+ def initialize
+ @doc_index = true
+ @ctas = []
+ @features = []
+ end
+
+ # Whether the doc index is shown (default true).
+ def doc_index? = @doc_index != false
+
+ # The CTAs as normalized Cta value objects (empty when unset).
+ def ctas
+ Array(@ctas).map { |cta| Cta.from(cta) }
+ end
+
+ # The features as normalized Feature value objects (empty when unset).
+ def features
+ Array(@features).map { |feature| Feature.from(feature) }
+ end
+
+ # The install block normalized to { code:, filename:, lexer: } with a sensible
+ # default lexer, or nil when unset.
+ def install_snippet
+ return if @install.nil?
+
+ attrs = @install.to_h.transform_keys(&:to_sym)
+ { code: attrs[:code].to_s, filename: attrs[:filename], lexer: (attrs[:lexer] || :shell).to_sym }
+ end
+
+ # One hero call-to-action button. `style` maps to a daisyUI btn variant
+ # (:primary → btn-primary, anything else → btn-ghost). `icon` is an optional
+ # brand/lucide token rendered before the label (DocsUI::BrandMark resolves it).
+ Cta = Data.define(:label, :href, :style, :icon) do
+ def initialize(label:, href:, style: :ghost, icon: nil)
+ super(label:, href:, style: style&.to_sym, icon: icon)
+ end
+
+ def self.from(cta)
+ return cta if cta.is_a?(self)
+
+ attrs = cta.to_h.transform_keys(&:to_sym)
+ new(label: attrs[:label], href: attrs[:href], style: attrs[:style] || :ghost, icon: attrs[:icon])
+ end
+
+ # The daisyUI button class for this CTA's style.
+ def btn_class = style == :primary ? "btn btn-primary" : "btn btn-ghost"
+
+ # Whether the href points off-site (absolute http/https) — the component adds
+ # target=_blank + rel=noopener only for external links.
+ def external? = href.to_s.match?(%r{\Ahttps?://}i)
+ end
+
+ # One feature card: a lucide icon name, a title, and a short body.
+ Feature = Data.define(:icon, :title, :body) do
+ def initialize(title:, icon: nil, body: nil)
+ super
+ end
+
+ def self.from(feature)
+ return feature if feature.is_a?(self)
+
+ attrs = feature.to_h.transform_keys(&:to_sym)
+ new(icon: attrs[:icon], title: attrs[:title], body: attrs[:body])
+ end
+ end
+ end
+end
diff --git a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb
index 38d7898..6e07e0f 100644
--- a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb
+++ b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb
@@ -104,6 +104,25 @@ Rails.application.config.to_prepare do
# then. README → "Add your docs to an agent (MCP)".
# c.mcp = false
+ # The landing page (app/views/landings/show.rb renders DocsUI::Landing) — a
+ # marketing hero + feature grid + a registry-grouped doc index, all from these
+ # knobs. Every field is optional; with none set it still renders a minimal hero
+ # (the brand + the doc index). Wrap a run in **double asterisks** to accent it
+ # in the primary color.
+ # c.landing.eyebrow = "Developer Docs"
+ # c.landing.title = "The <%= app_brand %> **API**"
+ # c.landing.lead = "One sentence on what your product does."
+ # c.landing.install = { code: 'gem "<%= app_brand.downcase %>"', filename: "Gemfile", lexer: :ruby }
+ # c.landing.ctas = [
+ # { label: "Get started", href: "/docs/overview", style: :primary },
+ # { label: "GitHub", href: "https://github.com/OWNER/REPO", style: :ghost, icon: :github },
+ # ]
+ # c.landing.features = [
+ # { icon: "zap", title: "Fast", body: "Why it's fast." },
+ # { icon: "database", title: "One database", body: "No extra moving parts." },
+ # ]
+ # c.landing.doc_index = false # hide the "Documentation" index section
+
# The sidebar nav derives from the registry — one heading → one registry.
# Each registry's authored pages become NavItems automatically (an unwritten
# page is skipped, so no dead links). For bespoke nav (interleaved
diff --git a/lib/generators/docs_kit/install/templates/landing.rb.erb b/lib/generators/docs_kit/install/templates/landing.rb.erb
index 4276844..d1895dc 100644
--- a/lib/generators/docs_kit/install/templates/landing.rb.erb
+++ b/lib/generators/docs_kit/install/templates/landing.rb.erb
@@ -2,23 +2,14 @@
module Views
module Landings
- # The home page. Renders inside DocsUI::Shell (the full document + drawer
- # shell); links to the authored docs.
+ # The home page — a marketing hero + feature grid + doc index, rendered by the
+ # shared DocsUI::Landing component. Customize it entirely from config:
+ # `c.landing.{eyebrow, title, lead, install, ctas, features}` in
+ # config/initializers/docs_kit.rb. With no landing config it still renders a
+ # minimal hero (the brand + the doc index), so this works out of the box.
class Show < Phlex::HTML
- include Phlex::Rails::Helpers::Routes
-
def view_template
- render DocsUI::Shell.new do
- div(class: "prose max-w-none") do
- h1 { "<%= app_brand %>" }
- p { "Documentation, built with docs-kit." }
- ul do
- Doc.all.select(&:view_class).each do |doc|
- li { a(href: "/docs/#{doc.slug}", class: "link") { doc.title } }
- end
- end
- end
- end
+ render DocsUI::Landing.new
end
end
end
diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb
index fcadc4e..b3e2d04 100644
--- a/spec/docs_kit/configuration_spec.rb
+++ b/spec/docs_kit/configuration_spec.rb
@@ -53,6 +53,29 @@
end
end
+ describe "#landing" do
+ it "returns a DocsKit::LandingConfig with backwards-safe defaults" do
+ landing = described_class.new.landing
+
+ expect(landing).to be_a(DocsKit::LandingConfig)
+ expect(landing.doc_index?).to be(true)
+ expect(landing.features).to eq([])
+ end
+
+ it "memoizes the same instance so a `c.landing.x = ...` block sticks" do
+ config = described_class.new
+ first = config.landing
+
+ expect(config.landing).to be(first)
+ end
+
+ it "is configured via the nested block (c.landing.title = ...)" do
+ DocsKit.configure { |c| c.landing.title = "The Acme API" }
+
+ expect(DocsKit.configuration.landing.title).to eq("The Acme API")
+ end
+ end
+
describe "#page_markdown_action" do
it "defaults to true (every page shows the 'Markdown' affordance)" do
expect(described_class.new.page_markdown_action).to be(true)
diff --git a/spec/docs_kit/landing_config_spec.rb b/spec/docs_kit/landing_config_spec.rb
new file mode 100644
index 0000000..7182702
--- /dev/null
+++ b/spec/docs_kit/landing_config_spec.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+RSpec.describe DocsKit::LandingConfig do
+ subject(:landing) { described_class.new }
+
+ describe "defaults" do
+ it "shows the doc index by default" do
+ expect(landing.doc_index?).to be(true)
+ end
+
+ it "has no ctas, features, or install snippet" do
+ expect(landing.ctas).to eq([])
+ expect(landing.features).to eq([])
+ expect(landing.install_snippet).to be_nil
+ end
+ end
+
+ describe "#doc_index?" do
+ it "is false only when explicitly disabled" do
+ landing.doc_index = false
+ expect(landing.doc_index?).to be(false)
+ end
+
+ it "stays true for any non-false value" do
+ landing.doc_index = nil
+ expect(landing.doc_index?).to be(true)
+ end
+ end
+
+ describe "#features" do
+ it "normalizes Hashes into Feature value objects" do
+ landing.features = [{ icon: "zap", title: "Fast", body: "Very." }]
+
+ feature = landing.features.first
+ expect(feature).to be_a(described_class::Feature)
+ expect(feature.icon).to eq("zap")
+ expect(feature.title).to eq("Fast")
+ expect(feature.body).to eq("Very.")
+ end
+
+ it "accepts a title-only feature (icon/body optional)" do
+ landing.features = [{ title: "Simple" }]
+
+ feature = landing.features.first
+ expect(feature.title).to eq("Simple")
+ expect(feature.icon).to be_nil
+ expect(feature.body).to be_nil
+ end
+
+ it "passes an existing Feature through unchanged" do
+ feature = described_class::Feature.new(icon: "x", title: "T", body: "B")
+ landing.features = [feature]
+
+ expect(landing.features.first).to equal(feature)
+ end
+ end
+
+ describe "#ctas" do
+ it "normalizes Hashes into Cta value objects with a default ghost style" do
+ landing.ctas = [{ label: "GitHub", href: "https://github.com/x" }]
+
+ cta = landing.ctas.first
+ expect(cta).to be_a(described_class::Cta)
+ expect(cta.label).to eq("GitHub")
+ expect(cta.style).to eq(:ghost)
+ end
+
+ describe "Cta#btn_class" do
+ it "maps :primary to btn-primary" do
+ cta = described_class::Cta.new(label: "Go", href: "/x", style: :primary)
+ expect(cta.btn_class).to eq("btn btn-primary")
+ end
+
+ it "maps any other style to btn-ghost" do
+ cta = described_class::Cta.new(label: "Go", href: "/x", style: :ghost)
+ expect(cta.btn_class).to eq("btn btn-ghost")
+ end
+ end
+
+ describe "Cta#external?" do
+ it "is true for an absolute http(s) href" do
+ expect(described_class::Cta.new(label: "x", href: "https://a.com").external?).to be(true)
+ end
+
+ it "is false for a site-relative href" do
+ expect(described_class::Cta.new(label: "x", href: "/docs/overview").external?).to be(false)
+ end
+ end
+ end
+
+ describe "#install_snippet" do
+ it "normalizes the install Hash with a default :shell lexer" do
+ landing.install = { code: 'gem "x"', filename: "Gemfile" }
+
+ expect(landing.install_snippet).to eq(code: 'gem "x"', filename: "Gemfile", lexer: :shell)
+ end
+
+ it "honors an explicit lexer" do
+ landing.install = { code: "print()", lexer: :python }
+
+ expect(landing.install_snippet[:lexer]).to eq(:python)
+ end
+
+ it "is nil when unset" do
+ expect(landing.install_snippet).to be_nil
+ end
+ end
+end