diff --git a/README.md b/README.md index 5e2615f..01a8115 100644 --- a/README.md +++ b/README.md @@ -53,16 +53,31 @@ DocsKit.configure do |c| c.title_suffix = "phlex-reactive" c.themes = %w[dark light synthwave retro cyberpunk dracula night nord sunset] c.version_badge = -> { "v#{Phlex::Reactive::VERSION}" } # optional - c.nav = lambda do - { - "Demos" => Demo.grouped.transform_values { |demos| - demos.map { |d| DocsKit::NavItem.new(href: "/demos/#{d.slug}", label: d.title, icon: d.icon) } - }, - "Docs" => Doc.all.select(&:view_class).group_by(&:group).transform_values { |docs| - docs.map { |d| DocsKit::NavItem.new(href: "/docs/#{d.slug}", label: d.title) } - } - } - end + + # The sidebar derives from your registries — one heading → one registry. + c.nav_registries = { "Docs" => Doc } +end +``` + +The nav is **derived from the registry**, so you never hand-write it. Each +registry maps a heading to its authored pages (`Doc.nav_items`); a page that +isn't written yet is skipped, so there are no dead links. Register a page with +one line (see [Add a page](#add-a-page)) and it appears in the sidebar. + +### Custom nav (advanced) + +Sites that interleave several registries under a heading, or need custom +subgroups, set an explicit `c.nav` lambda instead — it wins over +`nav_registries`: + +```ruby +c.nav = lambda do + { + "Demos" => Demo.grouped.transform_values { |demos| + demos.map { |d| DocsKit::NavItem.new(href: "/demos/#{d.slug}", label: d.title, icon: d.icon) } + }, + "Docs" => Doc.nav_items + } end ``` @@ -83,17 +98,47 @@ def show = render_page(Views::Docs::Pages::Installation.new) view context, so CSRF, `dom_id`, url helpers, and the reactive token signer all work inside components. -A page composes the shell + kit: +### Add a page + +One command scaffolds the page class **and** its registry line, both derived +from the title: + +```bash +rails g docs_kit:page "Getting Started" --group=Guide +``` + +That writes `app/views/docs/pages/getting_started.rb` (a `DocsUI::Page` subclass +with a starter Markdown section) and injects `page "Getting Started", group: +"Guide"` into your `Doc` registry — so the page is routed and in the sidebar the +moment you write its content. Every derivation is overridable: + +```bash +rails g docs_kit:page "OAuth" --group=Guide --slug=auth --view=OauthGuide +rails g docs_kit:page "Metrics" --group=Reference --eyebrow="Advanced" +rails g docs_kit:page "Guides Intro" --group=Guide --registry=Guide # a differently-named registry +``` + +Re-running is idempotent (no duplicate registry line, no clobbered file). If your +registry still uses the legacy hash `entries [...]` form, the generator writes +the page but prints the entry for you to add by hand instead of corrupting it. + +#### Under the hood + +A page is a `DocsUI::Page` subclass — the generator just writes this for you: ```ruby -class Views::Docs::Pages::Installation < DocsUI::Page - title "Installation" +# app/views/docs/pages/getting_started.rb — Zeitwerk resolves the compact +# reference through the directory-implied namespaces (no nested modules). +class Views::Docs::Pages::GettingStarted < DocsUI::Page + title "Getting Started" eyebrow "Guide" def lead = "Add the gem and render your first component." def content DocsUI::Section("Add the gem") do - prose { p { "Components are plain Ruby classes." } } + md <<~'MD' + Components are plain Ruby classes. + MD DocsUI::Code(<<~RUBY, filename: "Gemfile") gem "docs-kit" RUBY @@ -102,6 +147,19 @@ class Views::Docs::Pages::Installation < DocsUI::Page end ``` +…plus one line in the registry (`view_namespace` lets it derive the class): + +```ruby +# app/models/doc.rb +class Doc + extend DocsKit::Registry + path_prefix "/docs" + view_namespace "Views::Docs::Pages" + + page "Getting Started", group: "Guide" # slug "getting-started", view "GettingStarted" +end +``` + `DocsUI::Page` includes the kit, so inside `#content` you call the components directly — `DocsUI::Section(...)`, `DocsUI::Code(...)` — no `render … .new`. @@ -206,6 +264,9 @@ rails g rails_icons:sync --library=lucide bun install && bun run build:css ``` +Then add pages one command at a time — `rails g docs_kit:page "Title" +--group=Guide` (see [Add a page](#add-a-page)). + ## Deploy a new docs site The build + deploy is defined **once** in this gem's reusable workflow diff --git a/docs/app/models/doc.rb b/docs/app/models/doc.rb index a97fcb8..93b1fa7 100644 --- a/docs/app/models/doc.rb +++ b/docs/app/models/doc.rb @@ -1,37 +1,25 @@ # frozen_string_literal: true -# In-memory registry of the reference docs. Each entry maps a URL slug to its -# title, sidebar group, and the Phlex page class that renders it. Add a page by -# adding an entry here and a class under app/views/docs/pages/. +# In-memory registry of the reference docs. One line per page — slug and view +# derive from the title (both overridable), and the sidebar nav derives from +# this registry with zero extra code (see config/initializers/docs_kit.rb's +# `nav_registries`). Add a page with `rails g docs_kit:page "Title" --group=…`, +# which appends the `page` line here and writes the class under +# app/views/docs/pages/. # -# Uses DocsKit::Registry for the shared all/from_slug/grouped API. +# Uses DocsKit::Registry for the shared all/from_slug/grouped/nav_items API. class Doc extend DocsKit::Registry + path_prefix "/docs" + view_namespace "Views::Docs::Pages" - entries [ - { slug: "overview", title: "Overview", group: "Getting started", view: "Overview" }, - { slug: "installation", title: "Installation", group: "Getting started", view: "Installation" }, - { slug: "configuration", title: "Configuration", group: "Getting started", view: "Configuration" }, - { slug: "authoring", title: "Authoring pages", group: "Getting started", view: "Authoring" }, - { slug: "styling", title: "Styling & CSS", group: "Getting started", view: "Styling" }, - { slug: "components", title: "Components", group: "Reference", view: "Components" }, - { slug: "languages", title: "Code languages", group: "Reference", view: "Languages" }, - { slug: "on-this-page", title: "On this page", group: "Reference", view: "OnThisPage" }, - { slug: "deploy", title: "Deploy", group: "Reference", view: "Deploy" } - ] - - attr_reader :slug, :title, :group, :view_name - - def initialize(entry) - @slug = entry[:slug] - @title = entry[:title] - @group = entry[:group] - @view_name = entry[:view] - end - - # The hand-authored Phlex page class (nil until the class exists — the sidebar - # only links docs whose page is written, so no dead links). - def view_class - "Views::Docs::Pages::#{view_name}".safe_constantize - end + page "Overview", group: "Getting started" + page "Installation", group: "Getting started" + page "Configuration", group: "Getting started" + page "Authoring pages", group: "Getting started", slug: "authoring", view: "Authoring" + page "Styling & CSS", group: "Getting started", slug: "styling", view: "Styling" + page "Components", group: "Reference" + page "Code languages", group: "Reference", slug: "languages", view: "Languages" + page "On this page", group: "Reference" + page "Deploy", group: "Reference" end diff --git a/docs/app/views/docs/pages/authoring.rb b/docs/app/views/docs/pages/authoring.rb index 226404d..0489c84 100644 --- a/docs/app/views/docs/pages/authoring.rb +++ b/docs/app/views/docs/pages/authoring.rb @@ -3,15 +3,16 @@ module Views module Docs module Pages -# How to write a documentation page: a Phlex class, a registry entry, and + # How to write a documentation page: a Phlex class, a registry entry, and # the DocsUI building blocks, plus the automatic "On this page" TOC. class Authoring < DocsUI::Page title "Authoring pages" eyebrow "Getting started" - def lead = "Write a page as a Phlex class, register it, and the shell, masthead, and TOC come free." + def lead = "One command scaffolds a page — the class and its registry line. Then write content; the shell, masthead, and TOC come free." def content + one_command_section page_is_a_class_section register_section building_blocks_section @@ -20,35 +21,60 @@ def content private + def one_command_section + DocsUI::Section("One command", + description: "rails g docs_kit:page writes the class AND registers it — both derived from the title.") do + DocsUI::Code(<<~SHELL, lexer: :shell) + rails g docs_kit:page "Getting Started" --group=Guide + SHELL + + md <<~'MD' + That writes `app/views/docs/pages/getting_started.rb` (slug + `getting-started`, class `GettingStarted`) and injects + `page "Getting Started", group: "Guide"` into the `Doc` registry, so + the page is routed and in the sidebar the moment you fill in + `#content`. Every derivation is overridable: + + - `--slug=auth` — the URL slug, + - `--view=OauthGuide` — the class basename, + - `--eyebrow="Advanced"` — the eyebrow (defaults to the group), + - `--registry=Guide` — a differently-named registry class. + + Re-running is idempotent, and a legacy hash-`entries` registry is + left untouched (the generator prints the entry to add by hand). + MD + + DocsUI::Callout(:tip) do + "The rest of this page is what the generator produces — the shape to reach for when you hand-write or edit a page." + end + end + end + def page_is_a_class_section DocsUI::Section("A page is a Phlex class", description: "Subclass DocsUI::Page, declare its metadata, fill in #content.") do DocsUI::Code(<<~RUBY, filename: "app/views/docs/pages/guide.rb") # frozen_string_literal: true - module Views - module Docs - module Pages - class Guide < DocsUI::Page - title "Guide" - eyebrow "Getting started" - - def lead = "One sentence that sits under the page title." - - def content - DocsUI::Section("First steps", description: "What this section covers.") do - prose do - p { "Hand-authored prose with consistent reading rhythm." } - end - - DocsUI::Code(<<~SOURCE, filename: "config/routes.rb") - Rails.application.routes.draw do - mount DocsKit::Engine, at: "/docs" - end - SOURCE - end + # Compact class reference — Zeitwerk resolves it through the + # directory-implied namespaces, so no nested-module ceremony. + class Views::Docs::Pages::Guide < DocsUI::Page + title "Guide" + eyebrow "Getting started" + + def lead = "One sentence that sits under the page title." + + def content + DocsUI::Section("First steps", description: "What this section covers.") do + md <<~'MD' + Prose written as Markdown, styled with the reading rhythm. + MD + + DocsUI::Code(<<~SOURCE, filename: "config/routes.rb") + Rails.application.routes.draw do + mount DocsKit::Engine, at: "/docs" end - end + SOURCE end end end @@ -76,34 +102,34 @@ def content def register_section DocsUI::Section("Register the page", - description: "Add an entry so it appears in the nav and resolves at /docs/.") do - prose do - p do - plain "A page shows up once it has a row in the " - code { "Doc" } - plain " registry. The " - code { "view:" } - plain " maps to your class name under " - code { "Views::Docs::Pages" } - plain "; the " - code { "group:" } - plain " sets its sidebar heading." - end - end + description: "One line in the Doc registry — slug and view derive from the title.") do + md <<~'MD' + A page shows up once it has a `page` line in the `Doc` registry. + `slug` and `view` derive from the title (both overridable per line), + and `group:` sets its sidebar heading. The generator injects this + line for you. + MD DocsUI::Code(<<~RUBY, filename: "app/models/doc.rb") class Doc extend DocsKit::Registry + path_prefix "/docs" + view_namespace "Views::Docs::Pages" - entries [ - { slug: "overview", title: "Overview", group: "Getting started", view: "Overview" }, - { slug: "guide", title: "Guide", group: "Getting started", view: "Guide" } - ] + page "Overview", group: "Getting started" + page "Guide", group: "Getting started" + # overrides win: page "OAuth", group: "Guide", slug: "auth", view: "OauthGuide" end RUBY + md <<~'MD' + The sidebar derives from the registry — set + `c.nav_registries = { "Docs" => Doc }` in the initializer and never + hand-write a nav lambda again. + MD + DocsUI::Callout(:note) do - "The sidebar only links a page whose class exists, so an entry without its class yet is a no-op — no dead links." + "The sidebar only links a page whose class exists, so a page line without its class yet is a no-op — no dead links." end end end diff --git a/docs/config/initializers/docs_kit.rb b/docs/config/initializers/docs_kit.rb index 016de2e..689afbd 100644 --- a/docs/config/initializers/docs_kit.rb +++ b/docs/config/initializers/docs_kit.rb @@ -17,12 +17,10 @@ # c.code_lexer_aliases = { curl: "console" } # c.code_language_labels = { elixir: "Elixir" } - # The sidebar nav: an ordered { "Heading" => { "Subgroup" => [NavItem] } }. - c.nav = lambda do - docs = Doc.all.select(&:view_class).group_by(&:group).transform_values do |items| - items.map { |d| DocsKit::NavItem.new(href: "/docs/#{d.slug}", label: d.title) } - end - { "Docs" => docs }.reject { |_, v| v.empty? } - end + # 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 + # registries, custom subgroups) set a `c.nav` lambda instead; it wins. + c.nav_registries = { "Docs" => Doc } end end diff --git a/lib/docs_kit/configuration.rb b/lib/docs_kit/configuration.rb index ff0752c..0d19bc0 100644 --- a/lib/docs_kit/configuration.rb +++ b/lib/docs_kit/configuration.rb @@ -33,8 +33,20 @@ class Configuration # { "Heading" => { "Subgroup" => [items] } }. Each item must respond to # the duck type the Sidebar renders (see Docs::Sidebar#nav_link): #href, # #label, and optional #icon. Defaults to an empty nav. + # + # Prefer #nav_registries for the common case — an explicit #nav lambda is + # only needed for bespoke nav (multiple registries interleaved, custom + # subgroups). When #nav is left at its default, the sidebar derives from + # #nav_registries instead. attr_accessor :nav + # An ordered { "Heading" => registry_class } map. Each registry responds to + # .nav_items (Registry v2) → { group => [NavItem] } for its authored pages. + # #nav_groups derives the whole sidebar from this with zero site code, so a + # site never hand-writes the nav lambda. Defaults to {}. An explicit #nav + # lambda still wins (full backwards compatibility). + attr_accessor :nav_registries + # Optional callable returning a short version-badge string for the sidebar # header (e.g. -> { "v#{DaisyUI::VERSION}" }). nil renders no badge. attr_accessor :version_badge @@ -83,6 +95,10 @@ class Configuration # (e.g. { elixir: "Elixir", curl: "cURL" }). Unknown tokens humanize. attr_accessor :code_language_labels + # The sentinel "no explicit nav" lambda. #nav_groups compares against this + # identity to decide whether to derive the sidebar from #nav_registries. + DEFAULT_NAV = -> { {} } + # Built-in friendly aliases (kept small — Rouge resolves most names itself). DEFAULT_LEXER_ALIASES = { curl: "console", console: "console" }.freeze @@ -98,7 +114,11 @@ def initialize @title_suffix = nil @themes = %w[dark light] @default_theme = nil - @nav = -> { {} } + # The sentinel default nav lambda. #nav_groups treats it as "unset" and + # derives the sidebar from #nav_registries instead; an explicit c.nav + # replaces this object so the derivation steps aside (backwards compat). + @nav = DEFAULT_NAV + @nav_registries = {} @version_badge = nil @stylesheets = %w[application] @code_theme = "Rouge::Themes::Monokai" @@ -146,6 +166,15 @@ def normalize_on_page(value) private + # { heading => registry.nav_items }, dropping headings with no authored + # pages so the sidebar never shows an empty group. + def nav_groups_from_registries + @nav_registries.each_with_object({}) do |(heading, registry), acc| + items = registry.nav_items + acc[heading] = items unless items.empty? + end + end + def coerce_on_page_mode(value) case value when false, nil then false @@ -171,7 +200,14 @@ def default_theme end # The resolved nav Hash for this request. Always returns a Hash. + # + # An explicit #nav lambda wins. Otherwise the sidebar derives from + # #nav_registries: each heading maps to its registry's .nav_items, and a + # heading whose pages are all unauthored (empty nav_items) is dropped so no + # empty group renders. def nav_groups + return nav_groups_from_registries if @nav.equal?(DEFAULT_NAV) + result = @nav.respond_to?(:call) ? @nav.call : @nav result || {} end diff --git a/lib/docs_kit/registry.rb b/lib/docs_kit/registry.rb index 56820be..1753caa 100644 --- a/lib/docs_kit/registry.rb +++ b/lib/docs_kit/registry.rb @@ -1,38 +1,92 @@ # frozen_string_literal: true +# parameterize/camelize/underscore/safe_constantize for the v2 `page` DSL. A +# host Rails app already loads these; the gem requires them explicitly so the +# registry derives slugs/views even when loaded standalone (the suite, a plain +# Ruby consumer). +require "active_support/core_ext/string/inflections" + module DocsKit # A mixin for an in-memory docs registry (guides, demos, component references). - # Each site defines a plain-Ruby class whose instances wrap a frozen entry Hash; - # extending Registry gives the shared lookup/grouping API so every site's - # registry behaves identically and the Sidebar can consume any of them. # - # class Doc - # extend DocsKit::Registry - # entries [ - # { slug: "installation", title: "Installation", group: "Guide", view: "Installation" }, - # ] - # group_by_attribute :group - # attr_reader :slug, :title, :group, :view_name - # def initialize(entry) - # @slug = entry[:slug]; @title = entry[:title] - # @group = entry[:group]; @view_name = entry[:view] - # end - # # Resolve the authored Phlex page; nil if not yet written. - # def view_class = "Views::Docs::Pages::#{view_name}".safe_constantize - # end + # Two authoring styles, one shared lookup/grouping API: + # + # 1. The one-line `page` DSL (v2) — the default a site should reach for. slug + # and view derive from the title (both overridable); instances get default + # readers + view_class + href for free; the sidebar nav derives from the + # registry with zero site code: + # + # class Doc + # extend DocsKit::Registry + # path_prefix "/docs" # href = "#{path_prefix}/#{slug}" + # view_namespace "Views::Docs::Pages" # view_class resolves under this + # page "Installation", group: "Guide" # slug "installation", view "Installation" + # page "Getting started", group: "Guide", icon: "rocket" # slug "getting-started", view "GettingStarted" + # page "OAuth", group: "Guide", slug: "auth", view: "OauthGuide" # every derivation overridable + # end + # + # 2. The low-level hash `entries` API — for a registry with a bespoke schema + # (custom fields, a non-default view namespace). The site writes its own + # initialize/readers/view_class: + # + # class Demo + # extend DocsKit::Registry + # entries [{ slug: "counter", title: "Counter", group: "Examples", view: "Counter" }] + # attr_reader :slug, :title, :group, :view_name + # def initialize(entry) = (@slug, @title, @group, @view_name = entry.values_at(:slug, :title, :group, :view)) + # def view_class = "Views::Docs::Pages::#{view_name}".safe_constantize + # end + # + # Doc.all # => [entry instances] + # Doc.from_slug("installation") # => instance | nil + # Doc.grouped # => { "Guide" => [instances] } + # Doc.nav_items # => { "Guide" => [NavItem] } (authored pages only) # - # Doc.all # => [Doc, ...] - # Doc.from_slug("installation") # => Doc | nil - # Doc.grouped # => { "Guide" => [Doc, ...] } - # Doc.all.select(&:view_class).group_by(&:group) # "authored" filter + # A registry uses ONE style; mixing `page` and `entries` raises Registry::Error. module Registry - # Declares the frozen registry data. Called once at class definition. + # Raised on invalid registry declarations (e.g. mixing `page` and `entries`). + class Error < DocsKit::Error + end + + # Declares the frozen registry data directly (the low-level hash API). Each + # entry is a Hash; the site supplies its own instance class behavior. def entries(list = nil) return @entries if list.nil? + raise Error, "cannot mix `page` and `entries` in one registry" if @pages&.any? + @entries = list.map(&:freeze).freeze end + # Declares one page (the v2 DSL). slug/view derive from the title unless + # given. Appends to the registry in declaration order (== sidebar order). + # + # page "Getting started", group: "Guide", icon: "rocket", slug: "start", view: "Start" + def page(title, group:, slug: nil, view: nil, icon: nil) + raise Error, "cannot mix `page` and `entries` in one registry" if defined?(@entries) && @entries + + (@pages ||= []) << { + title: title, + group: group, + slug: slug || title.parameterize, + view: view || title.parameterize(separator: "_").camelize, + icon: icon + }.freeze + end + + # href = "#{path_prefix}/#{slug}". Defaults to "/docs". + def path_prefix(value = nil) + @path_prefix = value if value + @path_prefix || "/docs" + end + + # The namespace a page's view_class resolves under (v2 pages only), e.g. + # "Views::Docs::Pages". Required to resolve views via the default Entry. + def view_namespace(value = nil) + @view_namespace = value if value + @view_namespace + end + # The attribute used by #grouped (default :group). def group_by_attribute(attr = nil) @group_by_attribute = attr if attr @@ -41,8 +95,13 @@ def group_by_attribute(attr = nil) # All registry instances (built fresh each call — instances are cheap and a # site may resolve view classes that change under code reload in development). + # v2 pages are wrapped in the default Entry; hash entries in the site's class. def all - (entries || []).map { |entry| new(entry) } + if defined?(@pages) && @pages + @pages.map { |attrs| Entry.new(attrs, path_prefix, view_namespace) } + else + (entries || []).map { |entry| new(entry) } + end end # The instance whose slug matches, or nil. @@ -54,5 +113,38 @@ def from_slug(slug) def grouped all.group_by { |item| item.public_send(group_by_attribute) } end + + # { group => [NavItem] } for authored pages only (a resolvable view_class), + # so the sidebar never links a page that isn't written yet. This is the + # transform every site used to hand-write in its nav lambda. + def nav_items + all.select(&:view_class).group_by(&:group).transform_values do |items| + items.map { |item| DocsKit::NavItem.new(href: item.href, label: item.title, icon: item.icon) } + end + end + + # The default instance for a v2 `page` entry: readers + href + view_class + # resolved under the registry's view_namespace (nil until the class exists, + # preserving the no-dead-links behavior). + class Entry + attr_reader :slug, :title, :group, :icon, :view_name, :href + + def initialize(attrs, path_prefix, view_namespace) + @slug = attrs[:slug] + @title = attrs[:title] + @group = attrs[:group] + @icon = attrs[:icon] + @view_name = attrs[:view] + @view_namespace = view_namespace + @href = "#{path_prefix}/#{@slug}" + end + + # The authored Phlex page class, or nil until it's written. + def view_class + return unless @view_namespace + + "#{@view_namespace}::#{@view_name}".safe_constantize + end + end end end diff --git a/lib/generators/docs_kit/install/templates/doc.rb.erb b/lib/generators/docs_kit/install/templates/doc.rb.erb index 231a637..e6ee04b 100644 --- a/lib/generators/docs_kit/install/templates/doc.rb.erb +++ b/lib/generators/docs_kit/install/templates/doc.rb.erb @@ -1,29 +1,17 @@ # frozen_string_literal: true -# In-memory registry of the reference docs. Each entry maps a URL slug to its -# title, sidebar group, and the Phlex page class that renders it. Add a page by -# adding an entry here and a class under app/views/docs/pages/. +# In-memory registry of the reference docs. One line per page — slug and view +# derive from the title (both overridable), and the sidebar nav derives from +# this registry with zero extra code (see config/initializers/docs_kit.rb's +# `nav_registries`). Add a page with `rails g docs_kit:page "Title" --group=…`, +# which appends the `page` line here and writes the class under +# app/views/docs/pages/. # -# Uses DocsKit::Registry for the shared all/from_slug/grouped API. +# Uses DocsKit::Registry for the shared all/from_slug/grouped/nav_items API. class Doc extend DocsKit::Registry + path_prefix "/docs" + view_namespace "Views::Docs::Pages" - entries [ - { slug: "installation", title: "Installation", group: "Guide", view: "Installation" } - ] - - attr_reader :slug, :title, :group, :view_name - - def initialize(entry) - @slug = entry[:slug] - @title = entry[:title] - @group = entry[:group] - @view_name = entry[:view] - end - - # The hand-authored Phlex page class (nil until the class exists — the sidebar - # only links docs whose page is written, so no dead links). - def view_class - "Views::Docs::Pages::#{view_name}".safe_constantize - end + page "Installation", group: "Guide" 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 96ffe40..6679b9f 100644 --- a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +++ b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb @@ -17,12 +17,10 @@ Rails.application.config.to_prepare do # c.code_lexer_aliases = { curl: "console" } # c.code_language_labels = { elixir: "Elixir" } - # The sidebar nav: an ordered { "Heading" => { "Subgroup" => [NavItem] } }. - c.nav = lambda do - docs = Doc.all.select(&:view_class).group_by(&:group).transform_values do |items| - items.map { |d| DocsKit::NavItem.new(href: "/docs/#{d.slug}", label: d.title) } - end - { "Docs" => docs }.reject { |_, v| v.empty? } - end + # 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 + # registries, custom subgroups) set a `c.nav` lambda instead; it wins. + c.nav_registries = { "Docs" => Doc } end end diff --git a/lib/generators/docs_kit/install/templates/installation_page.rb.erb b/lib/generators/docs_kit/install/templates/installation_page.rb.erb index 86a9ee2..bb763d3 100644 --- a/lib/generators/docs_kit/install/templates/installation_page.rb.erb +++ b/lib/generators/docs_kit/install/templates/installation_page.rb.erb @@ -1,36 +1,37 @@ # frozen_string_literal: true -module Views - module Docs - module Pages - # A sample guide page. Subclass DocsUI::Page, set the title (+ optional - # eyebrow/lead), and build the body from the DocsUI kit (Section/Prose/Code). - # The "On this page" TOC + scroll-spy are automatic (config default). - class Installation < DocsUI::Page - title "Installation" - eyebrow "Guide" +# A sample guide page. Zeitwerk resolves this compact class reference through +# the directory-implied namespaces (app/views/docs/pages/ → Views::Docs::Pages), +# so there's no need for the 4-level nested-module ceremony. Subclass +# DocsUI::Page, set the title (+ optional eyebrow/lead), and build the body from +# the DocsUI kit (Section/Code) and Markdown islands (md). The "On this page" +# TOC + scroll-spy are automatic (config default). +class Views::Docs::Pages::Installation < DocsUI::Page + title "Installation" + eyebrow "Guide" - def lead = "Add the gem and render your first page." + def lead = "Add the gem and render your first page." - def content - DocsUI::Section("Add the gem", description: "One line in your Gemfile.") do - DocsUI::Prose { p { "docs-kit ships the shared Phlex chrome — configure it once." } } - DocsUI::Code(<<~RUBY, filename: "Gemfile") - gem "docs-kit" - RUBY - end + def content + DocsUI::Section("Add the gem", description: "One line in your Gemfile.") do + md <<~'MD' + docs-kit ships the shared Phlex chrome — configure it once. + MD + DocsUI::Code(<<~RUBY, filename: "Gemfile") + gem "docs-kit" + RUBY + end - DocsUI::Section("Configure") do - DocsUI::Prose { p { "Set your brand, themes, and nav:" } } - DocsUI::Code(<<~RUBY, filename: "config/initializers/docs_kit.rb") - DocsKit.configure do |c| - c.brand = "<%= app_brand %>" - c.themes = %w[dark light] - end - RUBY - end + DocsUI::Section("Configure") do + md <<~'MD' + Set your brand, themes, and nav: + MD + DocsUI::Code(<<~RUBY, filename: "config/initializers/docs_kit.rb") + DocsKit.configure do |c| + c.brand = "<%= app_brand %>" + c.themes = %w[dark light] end - end + RUBY end end end diff --git a/lib/generators/docs_kit/page/USAGE b/lib/generators/docs_kit/page/USAGE new file mode 100644 index 0000000..771b033 --- /dev/null +++ b/lib/generators/docs_kit/page/USAGE @@ -0,0 +1,26 @@ +Description: + Scaffold one docs page: a Phlex page class under app/views/docs/pages/ AND + its one-line registry entry, both derived from the title, in one command. + The unit of work for "add a docs page" drops to this command plus writing + content. + + The page class uses the compact form + `class Views::Docs::Pages::Title < DocsUI::Page` (Zeitwerk resolves it + through the directory-implied namespaces) with title/eyebrow/lead and a + starter Section containing a Markdown island. + + The registry line `page "Title", group: "Group"` is injected into the + Registry-v2 class (default: Doc). A legacy hash-`entries` registry is left + untouched with an instruction printed instead of corrupting it. + + Idempotent — re-running does not duplicate the registry line or clobber the + page file (in --skip mode). + +Example: + rails generate docs_kit:page "Getting Started" --group=Guide + + rails generate docs_kit:page "OAuth" --group=Guide --slug=auth --view=OauthGuide + rails generate docs_kit:page "Metrics" --group=Reference --eyebrow="Advanced" --registry=Doc + + After running: + # edit app/views/docs/pages/getting_started.rb — write your content diff --git a/lib/generators/docs_kit/page/page_generator.rb b/lib/generators/docs_kit/page/page_generator.rb new file mode 100644 index 0000000..e4ae8fe --- /dev/null +++ b/lib/generators/docs_kit/page/page_generator.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "rails/generators/base" +require "active_support/core_ext/string/inflections" + +module DocsKit + module Generators + # `rails g docs_kit:page TITLE --group=GROUP` + # + # Scaffolds one docs page — the Phlex page class AND its one-line registry + # entry, both derived from the title — so adding a page is one command plus + # writing content, not the old ceremony (a 4-level-nested class in one file + # plus a hand-synced registry line in another, either half easy to forget). + # + # rails g docs_kit:page "Getting Started" --group=Guide + # → app/views/docs/pages/getting_started.rb (compact class form) + # → injects `page "Getting Started", group: "Guide"` into Doc + # + # Every derivation is overridable: --slug, --view, --eyebrow, --registry. + # A legacy hash-`entries` registry is left untouched (an instruction is + # printed instead of corrupting it), and re-running is idempotent. + class PageGenerator < ::Rails::Generators::Base + source_root File.expand_path("templates", __dir__) + + argument :title, type: :string, + desc: %(The page title, e.g. "Getting Started") + + class_option :group, type: :string, default: "Guide", + desc: "The sidebar group heading" + class_option :slug, type: :string, + desc: "URL slug (default: the title parameterized)" + class_option :view, type: :string, + desc: "Page class basename (default: the title camelized)" + class_option :eyebrow, type: :string, + desc: "Eyebrow above the title (default: the group)" + class_option :registry, type: :string, default: "Doc", + desc: "Registry class to register the page in" + + def create_page_file + template "page.rb.erb", "app/views/docs/pages/#{view_name.underscore}.rb" + end + + def register_page + path = registry_path + rel = relative(path) + line = registry_line + return say_status(:skip, "#{rel} not found — add `#{line}` manually", :yellow) unless File.exist?(path) + + source = File.read(path) + return say_status(:skip, legacy_instruction(rel), :yellow) if legacy_entries?(source) + return say_status(:identical, "#{rel} already registers #{title.inspect}", :blue) if source.include?(line) + + inject_into_file path, " #{line}\n", after: registry_anchor(source) + end + + private + + # The by-hand instruction printed for a legacy hash-`entries` registry the + # generator won't touch (injecting a `page` line would corrupt it). + def legacy_instruction(rel) + "#{rel} uses the legacy `entries [...]` form — add this entry by hand:\n " \ + "{ slug: #{slug.inspect}, title: #{title.inspect}, " \ + "group: #{options[:group].inspect}, view: #{view_name.inspect} }" + end + + # The Phlex page class basename (e.g. "GettingStarted"). --view wins, + # else camelize the title (with "_" word boundaries so hyphens don't + # survive into the constant). + def view_name + options[:view].presence || title.parameterize(separator: "_").camelize + end + + # The URL slug. --slug wins, else the title parameterized. + def slug + options[:slug].presence || title.parameterize + end + + # The eyebrow above the title. --eyebrow wins, else the group. + def eyebrow + options[:eyebrow].presence || options[:group] + end + + # The one-line registry entry, with only the overrides that differ from + # the derived defaults spelled out (slug when it isn't the parameterized + # title; view when it isn't the camelized title). + def registry_line + ([%(page #{title.inspect}), %(group: #{options[:group].inspect})] + override_kwargs).join(", ") + end + + # The explicit slug:/view: keywords, present only when overridden. + def override_kwargs + kwargs = [] + kwargs << %(slug: #{slug.inspect}) if options[:slug].present? + kwargs << %(view: #{view_name.inspect}) if options[:view].present? + kwargs + end + + # A registry using the v2 `page` DSL has (or will have) `page` lines. The + # legacy form declares a hash `entries [...]` array and no `page` line. + def legacy_entries?(source) + source.match?(/^\s*entries\s*\[/) && !source.match?(/^\s*page\s+["']/) + end + + # Inject after the last existing `page` line so ordering lands at the end + # of the group; else after view_namespace/path_prefix; else after the + # `extend DocsKit::Registry` line. + def registry_anchor(source) + case source + when /^\s*page\s+["']/ + /^\s*page .*\n(?!\s*page )/ + when /^\s*view_namespace\s/ + /^\s*view_namespace .*\n/ + when /^\s*path_prefix\s/ + /^\s*path_prefix .*\n/ + else + /extend DocsKit::Registry\n/ + end + end + + def registry_path + File.join(destination_root, "app/models/#{options[:registry].underscore}.rb") + end + + def relative(path) = path.sub("#{destination_root}/", "") + end + end +end diff --git a/lib/generators/docs_kit/page/templates/page.rb.erb b/lib/generators/docs_kit/page/templates/page.rb.erb new file mode 100644 index 0000000..fea44d4 --- /dev/null +++ b/lib/generators/docs_kit/page/templates/page.rb.erb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Zeitwerk resolves this compact reference through the directory-implied +# namespaces (app/views/docs/pages/ → Views::Docs::Pages), so there's no need +# for the 4-level nested-module ceremony. +class Views::Docs::Pages::<%= view_name %> < DocsUI::Page + title "<%= title %>" + eyebrow "<%= eyebrow %>" + + def lead = "A one-sentence summary of this page." + + def content + DocsUI::Section("Overview") do + md <<~'MD' + Write your content here as Markdown — headings, lists, `inline code`, + and fenced code blocks all render as prose. Add more `DocsUI::Section`s + for each part of the page; each becomes an "On this page" TOC entry. + MD + end + end +end diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb index 10f634f..5cee90e 100644 --- a/spec/docs_kit/configuration_spec.rb +++ b/spec/docs_kit/configuration_spec.rb @@ -12,4 +12,60 @@ expect(DocsKit.configuration.icon_library).to eq("phosphor") end end + + # A registry-v2 stub: a class with the .nav_items API the config derives nav + # from. Two authored pages in one group. + def registry_stub + Class.new do + def self.nav_items + { "Guide" => [DocsKit::NavItem.new(href: "/docs/installation", label: "Installation")] } + end + end + end + + describe "#nav_registries" do + it "defaults to an empty Hash" do + expect(described_class.new.nav_registries).to eq({}) + end + + it "is overridable so a site maps a heading to its registry" do + reg = registry_stub + DocsKit.configure { |c| c.nav_registries = { "Docs" => reg } } + + expect(DocsKit.configuration.nav_registries).to eq({ "Docs" => reg }) + end + end + + describe "#nav_groups" do + it "derives from nav_registries when no explicit nav lambda is set" do + reg = registry_stub + DocsKit.configure { |c| c.nav_registries = { "Docs" => reg } } + + groups = DocsKit.configuration.nav_groups + expect(groups.keys).to eq(%w[Docs]) + expect(groups["Docs"]["Guide"].map(&:label)).to eq(%w[Installation]) + end + + it "drops a registry heading whose pages are all unauthored (empty nav_items)" do + empty = Class.new { def self.nav_items = {} } + reg = registry_stub + DocsKit.configure { |c| c.nav_registries = { "Empty" => empty, "Docs" => reg } } + + expect(DocsKit.configuration.nav_groups.keys).to eq(%w[Docs]) + end + + it "lets an explicit nav lambda win over nav_registries (backwards compatible)" do + reg = registry_stub + DocsKit.configure do |c| + c.nav_registries = { "Docs" => reg } + c.nav = -> { { "Custom" => { "Group" => [] } } } + end + + expect(DocsKit.configuration.nav_groups.keys).to eq(%w[Custom]) + end + + it "returns an empty Hash when neither nav nor nav_registries is set" do + expect(described_class.new.nav_groups).to eq({}) + end + end end diff --git a/spec/docs_kit/registry_spec.rb b/spec/docs_kit/registry_spec.rb index 22f15ef..4abd0a3 100644 --- a/spec/docs_kit/registry_spec.rb +++ b/spec/docs_kit/registry_spec.rb @@ -70,4 +70,139 @@ def initialize(entry) expect(klass.grouped.keys).to eq(%w[Actions]) end + + # --------------------------------------------------------------------------- + # Registry v2: the one-line `page` DSL. A site declares pages with a single + # line; slug/view derive from the title (both overridable), instances get the + # default readers + view_class + href for free, and the sidebar nav derives + # from the registry with zero site code. + # --------------------------------------------------------------------------- + describe "the page DSL (v2)" do + let(:v2) do + Class.new do + extend DocsKit::Registry + + path_prefix "/docs" + view_namespace "DocsKit" + + page "Installation", group: "Guide" + page "Getting started", group: "Guide", icon: "rocket" + page "OAuth", group: "Guide", slug: "auth", view: "String" + end + end + + it "derives slug (parameterize) and view (camelize) from the title" do + getting_started = v2.from_slug("getting-started") + expect(getting_started.slug).to eq("getting-started") + expect(getting_started.view_name).to eq("GettingStarted") + expect(getting_started.title).to eq("Getting started") + expect(getting_started.group).to eq("Guide") + end + + it "lets slug and view overrides win over the derived values" do + oauth = v2.from_slug("auth") + expect(oauth.slug).to eq("auth") + expect(oauth.view_name).to eq("String") + end + + it "exposes all/grouped over page-declared entries, preserving order" do + expect(v2.all.map(&:slug)).to eq(%w[installation getting-started auth]) + expect(v2.grouped.keys).to eq(%w[Guide]) + expect(v2.grouped["Guide"].map(&:slug)).to eq(%w[installation getting-started auth]) + end + + it "resolves view_class under view_namespace via safe_constantize (nil until authored)" do + # "DocsKit::String" does not exist → unauthored; "DocsKit::Installation" ditto. + expect(v2.from_slug("installation").view_class).to be_nil + # A registry whose view resolves under the namespace is 'authored'. + authored = Class.new do + extend DocsKit::Registry + + view_namespace "DocsKit" + page "Configuration", group: "Guide" # → DocsKit::Configuration (exists) + end + expect(authored.from_slug("configuration").view_class).to eq(DocsKit::Configuration) + end + + it "builds an href from path_prefix and slug" do + expect(v2.from_slug("getting-started").href).to eq("/docs/getting-started") + end + + it "treats every page as unauthored when view_namespace is unset" do + no_ns = Class.new do + extend DocsKit::Registry + + page "Configuration", group: "Guide" # DocsKit::Configuration exists, but no namespace + end + expect(no_ns.from_slug("configuration").view_class).to be_nil + expect(no_ns.nav_items).to eq({}) + end + + it "carries the optional icon through to the instance" do + expect(v2.from_slug("getting-started").icon).to eq("rocket") + expect(v2.from_slug("installation").icon).to be_nil + end + + describe ".nav_items" do + # Only authored pages (a resolvable view_class) become NavItems, so the + # sidebar never links a page that isn't written yet. + let(:registry) do + Class.new do + extend DocsKit::Registry + + path_prefix "/docs" + view_namespace "DocsKit" + + page "Configuration", group: "Guide", icon: "gear" # DocsKit::Configuration exists + page "Registry", group: "Guide" # DocsKit::Registry exists + page "Nonexistent", group: "Reference" # unauthored → dropped + end + end + + it "returns { group => [NavItem] } for authored pages only" do + nav = registry.nav_items + expect(nav.keys).to eq(%w[Guide]) + expect(nav["Guide"].map(&:label)).to eq(%w[Configuration Registry]) + end + + it "builds NavItems with the derived href and the declared icon" do + item = registry.nav_items["Guide"].first + expect(item).to be_a(DocsKit::NavItem) + expect(item.href).to eq("/docs/configuration") + expect(item.icon).to eq("gear") + end + end + + it "path_prefix defaults to /docs when unset" do + klass = Class.new do + extend DocsKit::Registry + + view_namespace "DocsKit" + page "Configuration", group: "Guide" + end + expect(klass.from_slug("configuration").href).to eq("/docs/configuration") + end + + it "raises when `page` follows `entries` in one registry" do + expect do + Class.new do + extend DocsKit::Registry + + entries [{ slug: "a", title: "A", group: "G", view: "A" }] + page "B", group: "G" + end + end.to raise_error(DocsKit::Registry::Error, /cannot mix/i) + end + + it "raises when `entries` follows `page` in one registry" do + expect do + Class.new do + extend DocsKit::Registry + + page "B", group: "G" + entries [{ slug: "a", title: "A", group: "G", view: "A" }] + end + end.to raise_error(DocsKit::Registry::Error, /cannot mix/i) + end + end end diff --git a/spec/generators/page_generator_spec.rb b/spec/generators/page_generator_spec.rb new file mode 100644 index 0000000..b542ce9 --- /dev/null +++ b/spec/generators/page_generator_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "fileutils" +require "tmpdir" +require "rails/generators" +require "generators/docs_kit/page/page_generator" + +# The page generator, like the install generator, never boots Rails — it writes +# a page class under app/views/docs/pages/ and injects a `page` line into the +# registry class, both under destination_root via Thor. We exercise it against a +# throwaway destination root seeded with a Registry-v2 `Doc` class, run the +# generator, and assert the new page file + the mutated registry. +RSpec.describe DocsKit::Generators::PageGenerator do + let(:destination) { File.join(Dir.tmpdir, "docs-kit-page-spec", "my_app_docs") } + + # A Registry-v2 registry class (the `page` DSL form) — the injection target. + def registry_v2(*extra_page_lines) + body = extra_page_lines.map { |l| " #{l}\n" }.join + <<~RUBY + # frozen_string_literal: true + + class Doc + extend DocsKit::Registry + path_prefix "/docs" + view_namespace "Views::Docs::Pages" + + #{body unless body.empty?}end + RUBY + end + + # The legacy hash-entries registry — the generator must NOT corrupt this. + def registry_legacy + <<~RUBY + # frozen_string_literal: true + + class Doc + extend DocsKit::Registry + + entries [ + { slug: "installation", title: "Installation", group: "Guide", view: "Installation" } + ] + end + RUBY + end + + def seed_registry(source) + write("app/models/doc.rb", source) + end + + def write(rel, content) + path = File.join(destination, rel) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + def read(rel) = File.read(File.join(destination, rel)) + def exist?(rel) = File.exist?(File.join(destination, rel)) + + # Run the generator quietly. `args` is the CLI arg list (title first, then + # --flags translated to Thor options via the second Hash). + def run_generator(args, options = {}) + generator = described_class.new(Array(args), options, destination_root: destination) + silence_stream { generator.invoke_all } + end + + def silence_stream + original = $stdout + $stdout = File.open(File::NULL, "w") + yield + ensure + $stdout.close + $stdout = original + end + + before { FileUtils.rm_rf(destination) } + after { FileUtils.rm_rf(destination) } + + describe "the generated page file" do + before do + seed_registry(registry_v2) + run_generator(["Getting Started"], { "group" => "Guide" }) + end + + it "writes the page at app/views/docs/pages/.rb" do + expect(exist?("app/views/docs/pages/getting_started.rb")).to be(true) + end + + it "uses the compact one-line class form (no 8-line module nesting)" do + src = read("app/views/docs/pages/getting_started.rb") + expect(src).to include("class Views::Docs::Pages::GettingStarted < DocsUI::Page") + expect(src).not_to include("module Views") + end + + it "sets the title, eyebrow (from the group), lead, and a starter Section" do + src = read("app/views/docs/pages/getting_started.rb") + expect(src).to include(%(title "Getting Started")) + expect(src).to include(%(eyebrow "Guide")) + expect(src).to include("def lead") + expect(src).to include("def content") + expect(src).to include("DocsUI::Section(") + expect(src).to include("md <<~") + end + end + + describe "the registry injection (Registry v2)" do + it "injects a page line into an empty v2 registry" do + seed_registry(registry_v2) + run_generator(["Getting Started"], { "group" => "Guide" }) + + expect(read("app/models/doc.rb")).to include(%(page "Getting Started", group: "Guide")) + end + + it "appends after the last existing page line so ordering lands at the group's end" do + seed_registry(registry_v2(%(page "Installation", group: "Guide"))) + run_generator(["Getting Started"], { "group" => "Guide" }) + + doc = read("app/models/doc.rb") + expect(doc.index(%(page "Installation"))).to be < doc.index(%(page "Getting Started")) + end + end + + describe "flag overrides" do + before { seed_registry(registry_v2) } + + it "respects --slug for the filename and the registry line" do + run_generator(["OAuth"], { "group" => "Guide", "slug" => "auth" }) + + expect(exist?("app/views/docs/pages/oauth.rb")).to be(true) # view still derives from title + expect(read("app/models/doc.rb")).to include(%(page "OAuth", group: "Guide", slug: "auth")) + end + + it "respects --view for the class name and filename" do + run_generator(["OAuth"], { "group" => "Guide", "view" => "OauthGuide" }) + + expect(exist?("app/views/docs/pages/oauth_guide.rb")).to be(true) + expect(read("app/views/docs/pages/oauth_guide.rb")) + .to include("class Views::Docs::Pages::OauthGuide < DocsUI::Page") + expect(read("app/models/doc.rb")).to include(%(view: "OauthGuide")) + end + + it "respects --eyebrow over the group default" do + run_generator(["Getting Started"], { "group" => "Guide", "eyebrow" => "Start here" }) + + expect(read("app/views/docs/pages/getting_started.rb")).to include(%(eyebrow "Start here")) + end + + it "respects --registry to target a differently-named registry class" do + write("app/models/guide.rb", <<~RUBY) + # frozen_string_literal: true + + class Guide + extend DocsKit::Registry + view_namespace "Views::Docs::Pages" + end + RUBY + run_generator(["Getting Started"], { "group" => "Guide", "registry" => "Guide" }) + + expect(read("app/models/guide.rb")).to include(%(page "Getting Started", group: "Guide")) + end + end + + describe "a legacy hash-entries registry" do + before do + seed_registry(registry_legacy) + run_generator(["Getting Started"], { "group" => "Guide" }) + end + + it "still writes the page file" do + expect(exist?("app/views/docs/pages/getting_started.rb")).to be(true) + end + + it "does NOT mutate the legacy registry (no corruption)" do + expect(read("app/models/doc.rb")).to eq(registry_legacy) + end + + it "leaves the legacy registry without a v2 page line" do + expect(read("app/models/doc.rb")).not_to include(%(page "Getting Started")) + end + end + + describe "idempotence" do + before { seed_registry(registry_v2) } + + it "does not inject a duplicate page line on a second run" do + run_generator(["Getting Started"], { "group" => "Guide" }) + run_generator(["Getting Started"], { "group" => "Guide", "skip" => true }) + + doc = read("app/models/doc.rb") + expect(doc.scan(%(page "Getting Started", group: "Guide")).size).to eq(1) + end + + it "does not clobber the existing page file in --skip mode" do + run_generator(["Getting Started"], { "group" => "Guide" }) + write("app/views/docs/pages/getting_started.rb", "# hand-edited\n") + + run_generator(["Getting Started"], { "group" => "Guide", "skip" => true }) + + expect(read("app/views/docs/pages/getting_started.rb")).to eq("# hand-edited\n") + end + end +end