From 8719c5e6801cfc955235a1d62dfa86fa82d2abe6 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 4 Jul 2026 16:34:20 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(landing):=20DocsUI::Landing=20?= =?UTF-8?q?=E2=80=94=20a=20config-driven=20marketing=20landing=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 13 ++ app/components/docs_ui/landing.rb | 167 ++++++++++++++++++ docs/app/views/landings/show.rb | 51 +----- docs/config/initializers/docs_kit.rb | 32 +++- .../requests/progressive_enhancement_spec.rb | 11 +- lib/docs_kit.rb | 1 + lib/docs_kit/configuration.rb | 10 ++ lib/docs_kit/landing_config.rb | 121 +++++++++++++ .../install/templates/docs_kit.rb.erb | 19 ++ .../docs_kit/install/templates/landing.rb.erb | 21 +-- spec/docs_kit/configuration_spec.rb | 23 +++ spec/docs_kit/landing_config_spec.rb | 108 +++++++++++ 12 files changed, 512 insertions(+), 65 deletions(-) create mode 100644 app/components/docs_ui/landing.rb create mode 100644 lib/docs_kit/landing_config.rb create mode 100644 spec/docs_kit/landing_config_spec.rb 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..15bc3e5 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/installation", 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 From 9da51c05939ad11ced10cdc3c94596b890aed836 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 4 Jul 2026 16:38:19 +0200 Subject: [PATCH 2/2] 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. --- docs/config/initializers/docs_kit.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config/initializers/docs_kit.rb b/docs/config/initializers/docs_kit.rb index 15bc3e5..e7bf408 100644 --- a/docs/config/initializers/docs_kit.rb +++ b/docs/config/initializers/docs_kit.rb @@ -65,8 +65,8 @@ "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/installation", style: :primary }, - { label: "Browse components", href: "/docs/components", style: :ghost }, + { 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 = [