From 8c44f0693565ee0b58ce78c2669254845886c982 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 15:32:52 +0200 Subject: [PATCH 1/2] fix(config): resolve theme names safely, honor String version_badge + explicit empty nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while dogfooding the docs site. Five Configuration bugs: - code_theme_class/code_theme_dark_class const_get raised NameError on a typo'd/unloaded Rouge theme, crashing every page with a code block; now degrades to the default theme (base) / nil (dark). - version_badge_text dropped a plain String value (respond_to?(:call) guard); now coerces a non-callable to its string form so c.version_badge = "v1.2" works. - nav_groups used @nav.equal?(DEFAULT_NAV) object identity, so any assigned lambda — even -> { {} } — skipped registry derivation; now tracks explicit assignment via a flag so an explicit empty nav wins and an untouched nav still derives from nav_registries. - Comments referenced a nonexistent Docs:: namespace; corrected to DocsUI::. TDD: RED specs added for each (theme degrade, String badge, explicit empty nav). Refs #8 Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX --- lib/docs_kit/configuration.rb | 70 +++++++++++++++++++++-------- spec/docs_kit/configuration_spec.rb | 53 ++++++++++++++++++++++ 2 files changed, 104 insertions(+), 19 deletions(-) diff --git a/lib/docs_kit/configuration.rb b/lib/docs_kit/configuration.rb index eeaae54..a3086b0 100644 --- a/lib/docs_kit/configuration.rb +++ b/lib/docs_kit/configuration.rb @@ -3,7 +3,7 @@ module DocsKit # Per-site configuration for the shared docs chrome. Everything that differs # between two otherwise-identical docs sites lives here, so the Phlex shell - # (Docs::Shell, Docs::Sidebar, Docs::ThemeSwitcher) is byte-identical across + # (DocsUI::Shell, DocsUI::Sidebar, DocsUI::ThemeSwitcher) is byte-identical across # sites and only the config changes. # # DocsKit.configure do |c| @@ -42,14 +42,22 @@ class Configuration # A callable returning the sidebar nav as an ordered Hash of # { "Heading" => { "Subgroup" => [items] } }. Each item must respond to - # the duck type the Sidebar renders (see Docs::Sidebar#nav_link): #href, + # the duck type the Sidebar renders (see DocsUI::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 + attr_reader :nav + + # Assigning #nav marks it explicit, so #nav_groups uses it verbatim rather + # than deriving from #nav_registries — tracked by a flag, not object + # identity, so ANY assigned lambda wins (even one that resolves to {}). + def nav=(value) + @nav_explicit = true + @nav = value + end # An ordered { "Heading" => registry_class } map. Each registry responds to # .nav_items (Registry v2) → { group => [NavItem] } for its authored pages. @@ -67,14 +75,14 @@ class Configuration # stylesheets (e.g. a separate rouge theme) lists them here. attr_accessor :stylesheets - # The Rouge theme class used by Docs::Code for inline syntax-highlight CSS. + # The Rouge theme class used by DocsUI::Code for inline syntax-highlight CSS. # This is the BASE (light) theme, emitted un-scoped so it applies to every # theme unless a dark override wins (see #code_theme_dark). attr_accessor :code_theme # An optional second Rouge theme (String name or Class) used for the site's # DARK daisyUI themes. Default nil → single-theme behavior, fully backwards - # compatible. When set, Docs::Code additionally emits this theme's CSS scoped + # compatible. When set, DocsUI::Code additionally emits this theme's CSS scoped # under [data-theme=X] .code-highlight for each shipped dark theme (see # #dark_themes), so code blocks stay readable when the switcher flips to a # dark theme — CSS-only, no JS, no flash. @@ -103,7 +111,7 @@ class Configuration # the brand. attr_writer :nav_storage_key - # The default "On this page" (auto-TOC) placement, used by Docs::Page when a + # The default "On this page" (auto-TOC) placement, used by DocsUI::Page when a # page doesn't pass its own on_page:. One of the ON_PAGE_MODES, or false to # render no auto-TOC by default. attr_writer :on_page_default @@ -118,7 +126,7 @@ class Configuration # "plaintext" (no highlighting, never raises). attr_accessor :code_lexer_fallback - # Human labels for language tabs in Docs::Example, merged over the built-ins + # Human labels for language tabs in DocsUI::Example, merged over the built-ins # (e.g. { elixir: "Elixir", curl: "cURL" }). Unknown tokens humanize. attr_accessor :code_language_labels @@ -209,10 +217,11 @@ def initialize @title_suffix = nil @themes = %w[dark light] @default_theme = nil - # 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). + # The default nav lambda. Until a site assigns #nav (which sets + # @nav_explicit), #nav_groups treats nav as "unset" and derives the sidebar + # from #nav_registries instead; an explicit c.nav (any lambda) then wins. @nav = DEFAULT_NAV + @nav_explicit = false @nav_registries = {} @version_badge = nil @stylesheets = %w[application] @@ -360,42 +369,65 @@ def default_theme # 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) + return nav_groups_from_registries unless @nav_explicit result = @nav.respond_to?(:call) ? @nav.call : @nav result || {} end # The resolved version badge string, or nil. + # The rendered version badge. A callable is invoked; a plain String (or any + # non-nil value) is coerced to its string form — so `c.version_badge = "v1.2"` + # renders, not only a lambda. def version_badge_text - return unless @version_badge.respond_to?(:call) + return if @version_badge.nil? + return @version_badge.call if @version_badge.respond_to?(:call) - @version_badge.call + @version_badge.to_s end - # The Rouge theme class resolved from #code_theme (String or class). + # The default Rouge theme both #code_theme_class and #code_theme_dark_class + # fall back to when a configured theme name can't be resolved — so a typo'd + # theme name degrades gracefully instead of raising on every code block. + DEFAULT_CODE_THEME = "Rouge::Themes::Monokai" + + # The Rouge theme class resolved from #code_theme (String or class). A String + # name that doesn't resolve degrades to the default theme rather than raising + # NameError on every DocsUI::Code render. def code_theme_class return @code_theme if @code_theme.is_a?(Class) - Object.const_get(@code_theme.to_s) + resolve_theme(@code_theme) || Object.const_get(DEFAULT_CODE_THEME) end # The dark Rouge theme class resolved from #code_theme_dark (String or - # class), or nil when unset — mirrors #code_theme_class. Docs::Code emits - # dark code CSS only when this is non-nil. + # class), or nil when unset — mirrors #code_theme_class. DocsUI::Code emits + # dark code CSS only when this is non-nil, so an unresolvable name degrades to + # nil (no dark restyle) rather than raising. def code_theme_dark_class return if @code_theme_dark.nil? return @code_theme_dark if @code_theme_dark.is_a?(Class) - Object.const_get(@code_theme_dark.to_s) + resolve_theme(@code_theme_dark) end # The dark themes the site actually ships: #dark_themes intersected with - # #themes, in #themes declaration order. Docs::Code scopes the dark theme's + # #themes, in #themes declaration order. DocsUI::Code scopes the dark theme's # CSS under [data-theme=X] for each of these, so a dark theme that isn't in # the Tailwind build never emits dead CSS. def dark_themes_shipped Array(@themes) & Array(@dark_themes) end + + private + + # Resolve a Rouge theme constant from its String name, returning nil (not + # raising) when the name doesn't resolve — a typo'd or unloaded theme must + # not crash every code block on the page. + def resolve_theme(name) + Object.const_get(name.to_s) + rescue NameError + nil + end end end diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb index bed6f3b..aada888 100644 --- a/spec/docs_kit/configuration_spec.rb +++ b/spec/docs_kit/configuration_spec.rb @@ -179,6 +179,46 @@ expect(DocsKit.configuration.code_theme_dark_class).to eq(Rouge::Themes::Monokai) end + + it "degrades to nil (no dark CSS) when the theme name doesn't resolve, rather than raising" do + DocsKit.configure { |c| c.code_theme_dark = "Rouge::Themes::Nope" } + + expect { DocsKit.configuration.code_theme_dark_class }.not_to raise_error + expect(DocsKit.configuration.code_theme_dark_class).to be_nil + end + end + + describe "#code_theme_class" do + it "resolves a String theme name to the Rouge theme class" do + DocsKit.configure { |c| c.code_theme = "Rouge::Themes::Github" } + + expect(DocsKit.configuration.code_theme_class).to eq(Rouge::Themes::Github) + end + + it "degrades to the default theme when a typo'd theme name doesn't resolve, rather than crashing every code block" do + DocsKit.configure { |c| c.code_theme = "Rouge::Themes::Doesnotexist" } + + expect { DocsKit.configuration.code_theme_class }.not_to raise_error + expect(DocsKit.configuration.code_theme_class).to eq(Rouge::Themes::Monokai) + end + end + + describe "#version_badge_text" do + it "returns nil when unset" do + expect(described_class.new.version_badge_text).to be_nil + end + + it "calls a lambda value" do + DocsKit.configure { |c| c.version_badge = -> { "v1.2.3" } } + + expect(DocsKit.configuration.version_badge_text).to eq("v1.2.3") + end + + it "renders a plain String value (not only a callable)" do + DocsKit.configure { |c| c.version_badge = "v1.2" } + + expect(DocsKit.configuration.version_badge_text).to eq("v1.2") + end end describe "#dark_themes" do @@ -288,6 +328,19 @@ def self.nav_items it "returns an empty Hash when neither nav nor nav_registries is set" do expect(described_class.new.nav_groups).to eq({}) end + + it "honors an explicitly-assigned nav that resolves to empty (not object identity)" do + # A site that deliberately sets an empty nav lambda must WIN over + # nav_registries — the 'is nav set?' test is explicit assignment, not + # `equal?(DEFAULT_NAV)` (any lambda is a different object). + reg = registry_stub + DocsKit.configure do |c| + c.nav_registries = { "Docs" => reg } + c.nav = -> { {} } + end + + expect(DocsKit.configuration.nav_groups).to eq({}) + end end describe "#api_base_url" do From 7aade40bc8f4370ff0ef8880436db513ee8264b3 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 15:38:48 +0200 Subject: [PATCH 2/2] fix(components,export,search): 20 bugs found while dogfooding the docs site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A capability+render audit of the dogfood docs site surfaced 25 confirmed bugs (5 config ones fixed in the previous commit). This commit fixes the remaining 20, each with a RED-first spec: Broken features - api_templates: RequestExample no longer aborts with JSON::ParserError on a non-JSON String body (compact_json only compacts valid JSON, else verbatim). - registry: Registry#nav_items now guards view_class + honors group_by_attribute, so a hash-entries registry no longer NoMethodErrors. - install_generator: the stimulus-registration fallback no longer writes an eager line when the site lazy-loads (double-registration). Wrong output - section: page-scoped anchor-id de-dup (shared Phlex render context) so two same-slug sections no longer emit duplicate ids that break the TOC/scroll-spy. - request_example: drop a colon-less auth header instead of emitting `-H "X: "`. - markdown_export/{blocks,inline,table}: correct callout title export, fence backtick-containing inline code, and size GFM tables to the widest row. - code: stamp the filename title bar with the md-skip marker so it no longer leaks above the fence in the .md twin. - error_table: treat a blank :param string as absent (no empty , no spurious Param column). - search_index: fence-aware section split — a `## ` inside a code fence is no longer indexed as a phantom heading. - shortcut: de-dup a chord that sets both :mod and :ctrl ("Ctrl Ctrl" → "Ctrl"). Papercuts / cosmetics - sidebar: brand link honors config.brand_href (matched Shell#topbar). - table: malformed typed cells raise instead of leaking the literal array. - field_table: a field with no :description degrades to the em-dash placeholder. - prop_table: a malformed empty row no longer emits an empty . - page: on_page defaults to DocsKit.configuration.on_page_default (matches doc). - code: comment separates code_lexer_aliases (highlighting) from code_language_labels (Example tab labels). - page_generator: no redundant slug:/view: keywords when they match the derived defaults. - example_spec: comment corrected — :curl resolves to Rouge's console lexer. 538 examples, 0 failures; rubocop clean. Refs #8 Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX --- app/components/docs_ui/code.rb | 13 +++- app/components/docs_ui/error_table.rb | 9 ++- app/components/docs_ui/field_table.rb | 2 +- app/components/docs_ui/page.rb | 2 +- app/components/docs_ui/prop_table.rb | 2 +- app/components/docs_ui/request_example.rb | 2 + app/components/docs_ui/section.rb | 19 +++++- app/components/docs_ui/sidebar.rb | 2 +- app/components/docs_ui/table.rb | 3 + lib/docs_kit/api_templates.rb | 5 +- lib/docs_kit/markdown_export/blocks.rb | 13 ++-- lib/docs_kit/markdown_export/inline.rb | 11 +++- lib/docs_kit/markdown_export/table.rb | 11 +++- lib/docs_kit/registry.rb | 4 +- lib/docs_kit/search_index.rb | 22 +++++-- lib/docs_kit/shortcut.rb | 2 +- .../docs_kit/install/install_generator.rb | 13 +++- .../docs_kit/page/page_generator.rb | 4 +- spec/docs_kit/api_client_spec.rb | 5 ++ spec/docs_kit/configuration_spec.rb | 2 +- spec/docs_kit/markdown_export_spec.rb | 45 +++++++++++++ spec/docs_kit/registry_spec.rb | 17 +++++ spec/docs_kit/search_index_spec.rb | 18 +++++ spec/docs_kit/shortcut_spec.rb | 5 ++ spec/docs_ui/error_table_spec.rb | 20 ++++++ spec/docs_ui/example_spec.rb | 6 +- spec/docs_ui/field_table_spec.rb | 7 ++ spec/docs_ui/page_spec.rb | 65 +++++++++++++++++++ spec/docs_ui/prop_table_spec.rb | 7 ++ spec/docs_ui/request_example_spec.rb | 8 +++ spec/docs_ui/section_spec.rb | 36 ++++++++++ spec/docs_ui/sidebar_spec.rb | 27 ++++++++ spec/docs_ui/table_spec.rb | 10 +++ spec/generators/install_generator_spec.rb | 18 +++++ spec/generators/page_generator_spec.rb | 14 ++++ 35 files changed, 420 insertions(+), 29 deletions(-) create mode 100644 spec/docs_ui/page_spec.rb create mode 100644 spec/docs_ui/sidebar_spec.rb diff --git a/app/components/docs_ui/code.rb b/app/components/docs_ui/code.rb index dcc4286..c8b673d 100644 --- a/app/components/docs_ui/code.rb +++ b/app/components/docs_ui/code.rb @@ -13,8 +13,10 @@ module DocsUI # # Any language Rouge knows (~200 lexers) works by its name or alias — python, # go, rust, elixir, kotlin, swift, json, dockerfile, ... — no allowlist. Add - # friendly aliases/labels via DocsKit.configure (code_lexer_aliases). An unknown - # language falls back to plaintext (never raises). + # friendly lexer aliases via DocsKit.configure (code_lexer_aliases). An unknown + # language falls back to plaintext (never raises). (Tab labels are a + # DocsUI::Example concern — set via code_language_labels, not here; Code has no + # label, only a filename.) class Code < Phlex::HTML include Phlex::Rails::Helpers::ContentSecurityPolicyNonce @@ -53,7 +55,12 @@ def view_template def csp_nonce = view_context && content_security_policy_nonce def title_bar - div(class: "flex items-center gap-2 border-b border-base-300 bg-base-300/60 px-4 py-2") do + # data-md-skip: the title bar is chrome. MarkdownExport strips it whole + # before the visitor runs, so the filename never leaks into the .md twin as + # a stray line above the fence. The visible HTML is unaffected (DROP_SELECTOR + # is applied only inside #to_md). + div(class: "flex items-center gap-2 border-b border-base-300 bg-base-300/60 px-4 py-2", + data: { md_skip: true }) do render DocsUI::Icon.new("file-code", class: "size-3.5 opacity-60") span(class: "font-mono text-xs opacity-70") { @filename } end diff --git a/app/components/docs_ui/error_table.rb b/app/components/docs_ui/error_table.rb index 36f3032..5dcfb98 100644 --- a/app/components/docs_ui/error_table.rb +++ b/app/components/docs_ui/error_table.rb @@ -24,7 +24,7 @@ class ErrorTable < Phlex::HTML def initialize(errors) @errors = errors - @with_param = errors.any? { |error| error[:param] } + @with_param = errors.any? { |error| present_param?(error[:param]) } end def view_template @@ -49,7 +49,12 @@ def row(error) def param_cell(error) param = error[:param] - param ? [:code, param] : NO_PARAM + present_param?(param) ? [:code, param] : NO_PARAM + end + + # A blank string is not a param — it flips no column and gets the em-dash. + def present_param?(param) + !param.nil? && !param.to_s.strip.empty? end end end diff --git a/app/components/docs_ui/field_table.rb b/app/components/docs_ui/field_table.rb index 4476344..8ced749 100644 --- a/app/components/docs_ui/field_table.rb +++ b/app/components/docs_ui/field_table.rb @@ -39,7 +39,7 @@ def row(field) [:code, field.fetch(:name)], field.fetch(:type), field.fetch(:required, false) ? REQUIRED_YES : REQUIRED_NO, - field.fetch(:description) + field.fetch(:description, REQUIRED_NO) ] end end diff --git a/app/components/docs_ui/page.rb b/app/components/docs_ui/page.rb index 77d62f7..51f8ce6 100644 --- a/app/components/docs_ui/page.rb +++ b/app/components/docs_ui/page.rb @@ -41,7 +41,7 @@ def eyebrow(value = nil) # or :panel/:toggle/:sidebar to override per page. def on_page(value = :__unset__) @on_page = value unless value == :__unset__ - defined?(@on_page) ? @on_page : true + defined?(@on_page) ? @on_page : DocsKit.configuration.on_page_default end end diff --git a/app/components/docs_ui/prop_table.rb b/app/components/docs_ui/prop_table.rb index de8b3bc..67c6d6d 100644 --- a/app/components/docs_ui/prop_table.rb +++ b/app/components/docs_ui/prop_table.rb @@ -36,7 +36,7 @@ def view_template # left as the author wrote it. def code_first_column(cells) first, *rest = cells - first = [:code, first] unless first.is_a?(Array) + first = [:code, first] unless first.nil? || first.is_a?(Array) [first, *rest] end end diff --git a/app/components/docs_ui/request_example.rb b/app/components/docs_ui/request_example.rb index 077c5eb..1fe819d 100644 --- a/app/components/docs_ui/request_example.rb +++ b/app/components/docs_ui/request_example.rb @@ -67,6 +67,8 @@ def merged_headers(auth_header) return @headers if auth_header.nil? || auth_header.strip.empty? name, value = auth_header.split(":", 2).map(&:strip) + return @headers if value.nil? || value.empty? + { name => value }.merge(@headers) end diff --git a/app/components/docs_ui/section.rb b/app/components/docs_ui/section.rb index 0110bd4..490d843 100644 --- a/app/components/docs_ui/section.rb +++ b/app/components/docs_ui/section.rb @@ -26,11 +26,14 @@ module DocsUI class Section < Phlex::HTML def initialize(title, id: nil, description: nil) @title = title - @id = id || slugify(title) + @explicit_id = id @description = description end def view_template(&) + # Resolve the anchor id at render time so it can be de-duplicated against + # sibling sections sharing this page's render context (see #resolve_id). + @id = @explicit_id || unique_id(slugify(@title)) section(id: @id, class: "mb-10 scroll-mt-20") do heading description @@ -73,5 +76,19 @@ def slugify(text) text.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "") end + + # De-duplicate the anchor id across every Section on the page. Phlex's render + # `context` is a Hash shared by the whole render tree, so sibling sections see + # the same used-id counter without any shared parent state. A title that + # slugifies to "" (e.g. "C++" → "c" is fine, but "+++" → "") falls back to + # "section"; colliding bases get a "-1", "-2", … sequence suffix so in-page + # anchors and the auto-TOC/scroll-spy resolve to distinct headings. + def unique_id(base) + base = "section" if base.empty? + used = (context[:__docs_ui_section_ids__] ||= Hash.new(0)) + n = used[base] + used[base] += 1 + n.zero? ? base : "#{base}-#{n}" + end end end diff --git a/app/components/docs_ui/sidebar.rb b/app/components/docs_ui/sidebar.rb index f334732..1cc7cee 100644 --- a/app/components/docs_ui/sidebar.rb +++ b/app/components/docs_ui/sidebar.rb @@ -42,7 +42,7 @@ def nav_groups = config.nav_groups def header_section div(class: "flex min-h-16 items-center gap-2 px-4") do - a(href: "/", class: "text-lg font-bold text-base-content") { config.brand } + a(href: config.brand_href, class: "text-lg font-bold text-base-content") { config.brand } badge = config.version_badge_text span(class: "badge badge-sm badge-ghost") { badge } if badge end diff --git a/app/components/docs_ui/table.rb b/app/components/docs_ui/table.rb index 98c08f1..053b457 100644 --- a/app/components/docs_ui/table.rb +++ b/app/components/docs_ui/table.rb @@ -54,6 +54,9 @@ def render_cell(cell) case cell in [:code, value] then code(class: "text-sm") { plain value.to_s } in [:md, value] then render DocsUI::Markdown.inline(value.to_s) + in [Symbol => _tag, *] + raise ArgumentError, + "DocsUI::Table: unknown or malformed typed cell #{cell.inspect}; use [:code, value] or [:md, value]" else plain cell.to_s end end diff --git a/lib/docs_kit/api_templates.rb b/lib/docs_kit/api_templates.rb index 06e4d40..91c549f 100644 --- a/lib/docs_kit/api_templates.rb +++ b/lib/docs_kit/api_templates.rb @@ -83,7 +83,10 @@ def python(request) # The body as compact single-line JSON, for inlining in a JS literal. def compact_json(request) require "json" - JSON.generate(JSON.parse(request.pretty_body_json)) + json = request.pretty_body_json + JSON.generate(JSON.parse(json)) + rescue JSON::ParserError + json end end end diff --git a/lib/docs_kit/markdown_export/blocks.rb b/lib/docs_kit/markdown_export/blocks.rb index 44d5a09..5db1bb2 100644 --- a/lib/docs_kit/markdown_export/blocks.rb +++ b/lib/docs_kit/markdown_export/blocks.rb @@ -103,11 +103,16 @@ def blockquote(node) quote(render(node)) end - # A callout → `> **Label:** body` as a blockquote. The label comes from the - # level; the body is the callout's inner text as inline Markdown. + # A callout → `> **Label:** body` as a blockquote. Callout stamps a + # `div.font-semibold` title (present only when title: is given) and a + # `div.text-sm` body. Read them separately: the author's title is the label + # when present (else the level label), and only the body div is rendered so + # the title never fuses into the body text. def callout(node, level) - label = CALLOUT_LABELS.fetch(level, "Note") - body = @inline.render(node).strip + title = node.at_css(".font-semibold") + body_node = node.at_css(".text-sm") || node + label = title ? @inline.render(title).strip : CALLOUT_LABELS.fetch(level, "Note") + body = @inline.render(body_node).strip quote("**#{label}:** #{body}") end diff --git a/lib/docs_kit/markdown_export/inline.rb b/lib/docs_kit/markdown_export/inline.rb index 1130a00..98abd29 100644 --- a/lib/docs_kit/markdown_export/inline.rb +++ b/lib/docs_kit/markdown_export/inline.rb @@ -43,7 +43,7 @@ def render_node(node) # any other wrapper recurses so its text survives. def element(node, name) case name - when "code" then "`#{node.text}`" + when "code" then code_span(node.text) when "a" then link(node) when "img" then image(node) when "br" then " \n" @@ -67,6 +67,15 @@ def heading_text(node) private + # A GFM-correct inline code span. The fence is a backtick run one longer + # than the longest run inside the text, so an interior backtick can never + # close the span; a space pads content that starts or ends with a backtick. + def code_span(text) + fence = "`" * ((text.scan(/`+/).map(&:length).max || 0) + 1) + pad = text.start_with?("`") || text.end_with?("`") ? " " : "" + "#{fence}#{pad}#{text}#{pad}#{fence}" + end + def self_anchor?(node) node.element? && node.name == "a" && node["href"].to_s.start_with?("#") end diff --git a/lib/docs_kit/markdown_export/table.rb b/lib/docs_kit/markdown_export/table.rb index 35f1b58..fc212bc 100644 --- a/lib/docs_kit/markdown_export/table.rb +++ b/lib/docs_kit/markdown_export/table.rb @@ -14,14 +14,21 @@ def render(node) rows = rows(node) return "" if rows.empty? - header, *body = rows - lines = [row(header), separator(header.length)] + width = rows.map(&:length).max + header, *body = pad(rows, width) + lines = [row(header), separator(width)] lines.concat(body.map { |cells| row(cells) }) lines.join("\n") end private + # Pad every row out to +width+ with empty cells so the header, separator, + # and all body rows declare the same column count (a rectangular GFM table). + def pad(rows, width) + rows.map { |cells| cells + Array.new(width - cells.length, "") } + end + # All rows as arrays of cell strings, header row first. A row leads; # /bare rows follow. def rows(node) diff --git a/lib/docs_kit/registry.rb b/lib/docs_kit/registry.rb index 1753caa..6914c0e 100644 --- a/lib/docs_kit/registry.rb +++ b/lib/docs_kit/registry.rb @@ -118,7 +118,9 @@ def grouped # 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| + all.select { |item| item.respond_to?(:view_class) && item.view_class } + .group_by { |item| item.public_send(group_by_attribute) } + .transform_values do |items| items.map { |item| DocsKit::NavItem.new(href: item.href, label: item.title, icon: item.icon) } end end diff --git a/lib/docs_kit/search_index.rb b/lib/docs_kit/search_index.rb index a765097..b34621a 100644 --- a/lib/docs_kit/search_index.rb +++ b/lib/docs_kit/search_index.rb @@ -97,11 +97,25 @@ def build_entry(page_title, section_title, href, body) end # → [intro_text, [[heading, body], ...]]. Splits on lines that are exactly a - # level-2 ATX heading (`## Foo`), matching MarkdownExport's twin output. + # level-2 ATX heading (`## Foo`), matching MarkdownExport's twin output. Scans + # line-by-line and toggles an in-fence flag on ``` / ~~~ fences so a `## ` + # inside a code block stays body text — the rendered page never ids it, so a + # section entry there would carry a dead anchor. def split_sections(markdown) - parts = markdown.split(/^\#\#[ \t]+(.+?)[ \t]*$/) - intro = parts.shift.to_s - sections = parts.each_slice(2).map { |heading, body| [heading.to_s.strip, body.to_s] } + intro = +"" + sections = [] + in_fence = false + markdown.each_line do |line| + in_fence = !in_fence if line.match?(/^[ \t]*(```|~~~)/) + heading = line.match(/^\#\#[ \t]+(.+?)[ \t]*$/) unless in_fence + if heading + sections << [heading[1].strip, +""] + elsif sections.empty? + intro << line + else + sections.last[1] << line + end + end [intro, sections] end diff --git a/lib/docs_kit/shortcut.rb b/lib/docs_kit/shortcut.rb index c813a32..4d33dd7 100644 --- a/lib/docs_kit/shortcut.rb +++ b/lib/docs_kit/shortcut.rb @@ -70,7 +70,7 @@ def meta? = @mods.include?(:meta) # "Ctrl K"); a BARE key is shown exactly as authored ("/", "s"). A named key # (e.g. "escape") is left as-is either way. def label - mods = LABEL_ORDER.select { |flag| @mods.include?(flag) }.map { |flag| MODIFIER_LABELS[flag] } + mods = LABEL_ORDER.select { |flag| @mods.include?(flag) }.map { |flag| MODIFIER_LABELS[flag] }.uniq (mods << key_label(chord: !mods.empty?)).join(" ") end diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 716d59c..f3272db 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -218,7 +218,18 @@ def register_stimulus_controller inject_into_file index, after: /eagerLoadControllersFrom\([^\n]*\n/ do "#{REGISTER_LINE}\n" end - append_to_file(index, "\n#{REGISTER_LINE}\n") unless stimulus_registered?(index) + return if stimulus_registered?(index) # inject handled it + + # No eager anchor to inject after: only append the eager line if the file + # actually imports eagerLoadControllersFrom — appending it to a lazy-only + # index.js writes a call with no import, a ReferenceError that aborts the + # module and registers ZERO controllers (the failure REGISTER_LINE warns + # of). A lazy-only file is valid, so warn instead of breaking it. + unless File.read(index).match?(/import\s*\{[^}]*eagerLoadControllersFrom/) + return say_status(:skip, "#{relative(index)} doesn't eager-load — add: #{REGISTER_LINE}", :yellow) + end + + append_to_file(index, "\n#{REGISTER_LINE}\n") end # Detect + print manual drift the generator can't safely automate (a diff --git a/lib/generators/docs_kit/page/page_generator.rb b/lib/generators/docs_kit/page/page_generator.rb index e4ae8fe..963ad74 100644 --- a/lib/generators/docs_kit/page/page_generator.rb +++ b/lib/generators/docs_kit/page/page_generator.rb @@ -90,8 +90,8 @@ def registry_line # 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 << %(slug: #{slug.inspect}) if slug != title.parameterize + kwargs << %(view: #{view_name.inspect}) if view_name != title.parameterize(separator: "_").camelize kwargs end diff --git a/spec/docs_kit/api_client_spec.rb b/spec/docs_kit/api_client_spec.rb index b369fe1..beccbe6 100644 --- a/spec/docs_kit/api_client_spec.rb +++ b/spec/docs_kit/api_client_spec.rb @@ -78,6 +78,11 @@ def render_default(token, **request_overrides) expect(without_body).not_to include("body: JSON.stringify(") end + it "javascript inlines a non-JSON String body verbatim instead of raising" do + out = render_default(:javascript, method: :post, body: "name=Acme") + expect(out).to include("body: JSON.stringify(name=Acme)") + end + it "ruby emits a Net::HTTP snippet, with a request body only when present" do with_body = render_default(:ruby, method: :post, body: { name: "Acme" }) expect(with_body).to include("Net::HTTP") diff --git a/spec/docs_kit/configuration_spec.rb b/spec/docs_kit/configuration_spec.rb index aada888..98919ab 100644 --- a/spec/docs_kit/configuration_spec.rb +++ b/spec/docs_kit/configuration_spec.rb @@ -195,7 +195,7 @@ expect(DocsKit.configuration.code_theme_class).to eq(Rouge::Themes::Github) end - it "degrades to the default theme when a typo'd theme name doesn't resolve, rather than crashing every code block" do + it "degrades to the default theme when a typo'd theme name doesn't resolve (not a crash)" do DocsKit.configure { |c| c.code_theme = "Rouge::Themes::Doesnotexist" } expect { DocsKit.configuration.code_theme_class }.not_to raise_error diff --git a/spec/docs_kit/markdown_export_spec.rb b/spec/docs_kit/markdown_export_spec.rb index b016ba6..d6c010f 100644 --- a/spec/docs_kit/markdown_export_spec.rb +++ b/spec/docs_kit/markdown_export_spec.rb @@ -104,6 +104,18 @@ def view_template expect(md).to include("`inline`") end + it "round-trips inline code containing a backtick through GFM" do + # A single-backtick fence closes at an interior backtick, corrupting the + # span. The fence run must be longer than the longest run inside the text + # (and padded when the content starts/ends with a backtick). + require "commonmarker" + md = html_to_md("

run a`b now.

") + + code_span = md[/`+ ?a`b ?`+/] + expect(code_span).not_to be_nil + expect(Commonmarker.to_html(code_span)).to include("a`b") + end + it "renders a link as [text](href)" do page = docs_content { p { a(href: "https://example.com/docs") { "the guide" } } } @@ -158,6 +170,17 @@ def view_template expect(md).not_to include("<") expect(md).not_to include("&") end + + it "does not leak a Code(filename:) title into the twin as a stray line" do + page = docs_content { render DocsUI::Code.new("puts 1", lexer: :ruby, filename: "app.rb") } + + md = to_md(page) + + # The title bar is chrome — the fence must be bare, with no loose "app.rb" + # paragraph above it. + expect(md).to eq("```ruby\nputs 1\n```") + expect(md).not_to include("app.rb") + end end describe "callouts" do @@ -177,6 +200,15 @@ def view_template expect(note).to include("> **Note:**") expect(warn).to include("> **Warning:**") end + + it "uses the author's title as the label and never fuses it into the body" do + page = docs_content { render DocsUI::Callout.new(:tip, title: "Heads up") { "Body here." } } + + md = to_md(page) + + expect(md).to include("> **Heads up:** Body here.") + expect(md).not_to include("Heads upBody here.") + end end describe "lists" do @@ -240,6 +272,19 @@ def view_template expect(md).to include("brand") expect(md).to include("Topbar heading.") end + + it "keeps a rectangular GFM table when a body row has more cells than the header" do + md = html_to_md( + "" \ + "" \ + "" \ + "
AB
123
" + ) + + # Every line must declare the same number of columns as the widest row (3). + pipe_counts = md.each_line.map { |line| line.count("|") } + expect(pipe_counts.uniq).to eq([4]) + end end describe "misc block elements" do diff --git a/spec/docs_kit/registry_spec.rb b/spec/docs_kit/registry_spec.rb index 4abd0a3..e1e8be1 100644 --- a/spec/docs_kit/registry_spec.rb +++ b/spec/docs_kit/registry_spec.rb @@ -71,6 +71,23 @@ def initialize(entry) expect(klass.grouped.keys).to eq(%w[Actions]) end + it "builds nav_items honoring a custom group_by_attribute (no #view_class/#group required)" do + klass = Class.new do + extend DocsKit::Registry + + entries [{ slug: "a", category: "Actions" }] + group_by_attribute :category + attr_reader :slug, :category + + def initialize(entry) + @slug = entry[:slug] + @category = entry[:category] + end + end + + expect(klass.nav_items).to eq({}) + 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 diff --git a/spec/docs_kit/search_index_spec.rb b/spec/docs_kit/search_index_spec.rb index d7c918f..24ea917 100644 --- a/spec/docs_kit/search_index_spec.rb +++ b/spec/docs_kit/search_index_spec.rb @@ -179,6 +179,24 @@ expect(hit.href).to eq("/docs/empty") end + it "treats a `## ` line inside a fenced code block as body, not a section" do + # The bash fence contains `## install the gem`; it must NOT become a phantom + # section entry with a `#install-the-gem` anchor (a slug the rendered page + # never stamps — DocsUI::Section only ids real headings). Only the real + # `## Real Section` heading below the fence is a section. + corpus = [["Guide", "/guide", "Intro.\n\n```bash\n## install the gem\n```\n\n## Real Section\n\nBody.\n"]] + index = described_class.new(corpus) + + # No entry carries the in-fence text as its section title / anchor. + fence = index.entries.find { |e| e.section_title == "install the gem" } + expect(fence).to be_nil + expect(index.entries.map(&:href)).not_to include("/guide#install-the-gem") + + # The in-fence text stays with the intro, and only the real heading is a section. + section_titles = index.entries.map(&:section_title) + expect(section_titles).to contain_exactly(nil, "Real Section") + end + it "snippets from the head when the match is a section-title, not body text" do corpus = [["P", "/docs/p", "Intro.\n\n## Widgets\n\n"]] # 'widgets' matches the heading; the section body is empty, so the snippet diff --git a/spec/docs_kit/shortcut_spec.rb b/spec/docs_kit/shortcut_spec.rb index e5e86fe..3c1bed2 100644 --- a/spec/docs_kit/shortcut_spec.rb +++ b/spec/docs_kit/shortcut_spec.rb @@ -79,6 +79,11 @@ it "spells out explicit modifiers in order" do expect(described_class.parse("ctrl+shift+f").label).to eq("Ctrl Shift F") end + + it "de-duplicates a label that maps two flags to the same text (mod+ctrl → one Ctrl)" do + # :mod and :ctrl both render as "Ctrl"; the badge must not read "Ctrl Ctrl K". + expect(described_class.parse("mod+ctrl+k").label).to eq("Ctrl K") + end end describe "#to_h — the shape docs-nav matches against" do diff --git a/spec/docs_ui/error_table_spec.rb b/spec/docs_ui/error_table_spec.rb index 0ac5bf1..55d6b53 100644 --- a/spec/docs_ui/error_table_spec.rb +++ b/spec/docs_ui/error_table_spec.rb @@ -60,6 +60,26 @@ def render_error_table(...) end end + context "when a param is a blank string" do + it "treats it as absent — no Param column when every param is blank" do + html = render_error_table([{ scenario: "x", status: "422", type: "validation_error", param: "" }]) + + expect(html).not_to include(">Param") + end + + it "fills the em-dash placeholder for a blank param when the column is shown" do + html = render_error_table( + [ + { scenario: "Non-HTTPS URL", status: "422", type: "validation_error", param: "url" }, + { scenario: "Blank param", status: "422", type: "validation_error", param: "" } + ] + ) + + expect(html).to include("—") + expect(html).not_to include(%()) + end + end + context "when no row has a param" do it "hides the Param column entirely" do html = render_error_table(errors_without_param) diff --git a/spec/docs_ui/example_spec.rb b/spec/docs_ui/example_spec.rb index ed16f30..1b6b38e 100644 --- a/spec/docs_ui/example_spec.rb +++ b/spec/docs_ui/example_spec.rb @@ -52,7 +52,11 @@ def render_group(&) end it "maps friendly language tokens to a real Rouge lexer" do - # :curl isn't a Rouge lexer; it must not blow up (falls back to shell). + # :curl is a friendly alias (DEFAULT_LEXER_ALIASES) that resolves to Rouge's + # console lexer — a real lexer, never blowing up. + expect(DocsUI::Code.new("curl https://api", lexer: :curl).send(:lexer)) + .to be_a(Rouge::Lexers::ConsoleLexer) + html = render_group do |ex| ex.code(:curl) { "curl https://api" } ex.code(:ruby) { "1" } diff --git a/spec/docs_ui/field_table_spec.rb b/spec/docs_ui/field_table_spec.rb index 6bd9523..675b16d 100644 --- a/spec/docs_ui/field_table_spec.rb +++ b/spec/docs_ui/field_table_spec.rb @@ -81,6 +81,13 @@ def render_field_table(...) expect(html).not_to include("url
") + end + it "escapes HTML in a plain-string description" do html = render_field_table([{ name: "x", type: "string", description: "see " }]) diff --git a/spec/docs_ui/page_spec.rb b/spec/docs_ui/page_spec.rb new file mode 100644 index 0000000..81bc92e --- /dev/null +++ b/spec/docs_ui/page_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "open3" + +# DocsUI::Page's `on_page` class-level accessor is the per-page auto-TOC knob. +# Page cannot autoload in the Rails-free suite (it includes +# Phlex::Rails::Helpers::Routes, whose body runs Rails.* at class-load time — see +# the "page-not-loadable-in-suite" note and spec/docs_ui/page_helpers_spec.rb). +# A global Rails stub would break icon_spec/the generator spec under random +# ordering, so exercise the REAL DocsUI::Page.on_page in an isolated child +# process (a minimal Rails routes stub lets Page load there without polluting the +# suite) and assert the contract: an unset on_page resolves to +# DocsKit.configuration.on_page_default, never a bare `true`. +# The subject is DocsUI::Page, but describing it by name (not the constant) is +# deliberate — referencing the constant would autoload Page and break the suite. +RSpec.describe "DocsUI::Page.on_page" do # rubocop:disable RSpec/DescribeClass + gem_root = File.expand_path("../..", __dir__) + + # Load the real DocsUI::Page in a child process and return the inspected + # `on_page` of a subclass, after applying `config` (a Configuration block body) + # and `on_page` (statements against the subclass `klass`). + define_method(:resolve) do |config: "", on_page: ""| + script = <<~RUBY + $LOAD_PATH.unshift "#{gem_root}/lib" + require "active_support/all" + require "action_dispatch" + require "phlex/rails" + require "daisy_ui" + module Rails + def self.application + @app ||= Class.new do + def routes = @routes ||= ActionDispatch::Routing::RouteSet.new + end.new + end + end + require "docs_kit" + DocsKit.configure { |c| #{config} } + klass = Class.new(DocsUI::Page) + #{on_page} + print klass.on_page.inspect + RUBY + stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-e", script) + raise "child process failed: #{stderr}" unless status.success? + + stdout + end + + context "when no per-page value is set" do + it "defaults to the configured on_page_default, not a bare true" do + expect(resolve(config: "c.on_page_default = :panel")).to eq(":panel") + end + end + + context "when set to an explicit mode" do + it "returns that mode" do + expect(resolve(on_page: "klass.on_page :toggle")).to eq(":toggle") + end + end + + context "when opted out with false" do + it "returns false" do + expect(resolve(on_page: "klass.on_page false")).to eq("false") + end + end +end diff --git a/spec/docs_ui/prop_table_spec.rb b/spec/docs_ui/prop_table_spec.rb index 9562d05..39cef4c 100644 --- a/spec/docs_ui/prop_table_spec.rb +++ b/spec/docs_ui/prop_table_spec.rb @@ -56,4 +56,11 @@ def render_prop_table(...) expect(html).to include(">Option") expect(html).not_to include("<td") end + + it "does not emit an empty <code> for a malformed empty row" do + html = render_prop_table([[]]) + + # A nil first cell must not be wrapped into an empty <code> element. + expect(html).not_to include("<code class=\"text-sm\"></code>") + end end diff --git a/spec/docs_ui/request_example_spec.rb b/spec/docs_ui/request_example_spec.rb index 640653e..ad83949 100644 --- a/spec/docs_ui/request_example_spec.rb +++ b/spec/docs_ui/request_example_spec.rb @@ -39,6 +39,14 @@ def render_request(...) expect(html).to include("Authorization: Bearer sk_live_...") end + it "drops a colon-less auth header instead of emitting a value-less line" do + DocsKit.configure { |c| c.api_auth_header = "Bearer sk_live_xyz" } + html = render_request(method: :get, path: "/v1/things") + + # No malformed `-H "Bearer sk_live_xyz: "` (name with an empty value). + expect(html).not_to include("Bearer sk_live_xyz") + end + it "filters and orders the tabs when clients: is given" do html = render_request(method: :get, path: "/v1/things", clients: %i[ruby curl]) diff --git a/spec/docs_ui/section_spec.rb b/spec/docs_ui/section_spec.rb index 9626d32..eaa55be 100644 --- a/spec/docs_ui/section_spec.rb +++ b/spec/docs_ui/section_spec.rb @@ -14,6 +14,42 @@ def render_section(*args, body: "BODY", **kwargs) end.new.call end + # Render two sibling sections inside one host so they share a single Phlex + # render context (as they would on a real Page). Colliding titles must NOT + # produce duplicate DOM ids/anchors, or in-page links + the auto-TOC break. + def render_two(title_a, title_b) + Class.new(Phlex::HTML) do + define_method(:view_template) do + render(DocsUI::Section.new(title_a) { plain "A" }) + render(DocsUI::Section.new(title_b) { plain "B" }) + end + end.new.call + end + + it "de-duplicates ids when two sibling sections slugify to the same value" do + html = render_two("Overview", "Overview") + + expect(html.scan('id="overview"').length).to eq(1) + expect(html).to include('id="overview-1"') + expect(html).to include('href="#overview"') + expect(html).to include('href="#overview-1"') + end + + it "de-duplicates ids for distinct titles that slugify identically (e.g. C++/C--)" do + html = render_two("C++", "C--") + + ids = html.scan(/id="([^"]*)"/).flatten + expect(ids.uniq.length).to eq(ids.length) + end + + it "falls back to a sequenced id when the title slugifies to empty" do + html = render_two("+++", "***") + + ids = html.scan(/id="([^"]*)"/).flatten + expect(ids).to all(match(/\Asection(-\d+)?\z/)) + expect(ids.uniq.length).to eq(ids.length) + end + it "renders the title, anchor, and body" do html = render_section("Add the gem") diff --git a/spec/docs_ui/sidebar_spec.rb b/spec/docs_ui/sidebar_spec.rb new file mode 100644 index 0000000..d8d3502 --- /dev/null +++ b/spec/docs_ui/sidebar_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe DocsUI::Sidebar do + # The brand header reads only DocsKit.configuration (no Rails request), so — like + # shell_spec's topbar-only render — we exercise just that fragment through a tiny + # subclass whose view_template renders only the header section. + let(:header_only) do + Class.new(described_class) do + def view_template = header_section + end + end + + describe "the brand link" do + it "defaults the brand href to \"/\"" do + html = header_only.new.call + + expect(html).to include('href="/"') + end + + it "follows config.brand_href when a site overrides it" do + DocsKit.configure { |c| c.brand_href = "/docs" } + html = header_only.new.call + + expect(html).to include('href="/docs"') + end + end +end diff --git a/spec/docs_ui/table_spec.rb b/spec/docs_ui/table_spec.rb index 12951e9..7dd9cbf 100644 --- a/spec/docs_ui/table_spec.rb +++ b/spec/docs_ui/table_spec.rb @@ -48,6 +48,16 @@ def render_table(...) expect(html).not_to include("<p>") end + it "raises on a typo'd typed-cell tag instead of leaking the literal array" do + expect { render_table(%w[Name Type], [["brand", [:codee, "String"]]]) } + .to raise_error(ArgumentError, /unknown or malformed typed cell/) + end + + it "raises on a wrong-arity typed cell instead of leaking the literal array" do + expect { render_table(%w[Name Type], [["brand", [:code, "String", "extra"]]]) } + .to raise_error(ArgumentError, /unknown or malformed typed cell/) + end + it "renders headers only when the rows array is empty" do html = render_table(%w[Name Type], []) diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 0c199d2..d98f25a 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -363,6 +363,24 @@ def capture_stream expect(index.scan("docs_kit/controllers").size).to eq(1) expect(index).to include(%(lazyLoadControllersFrom("docs_kit/controllers", application))) end + + it "does not append an unimported eager line to a lazy-only index.js" do + # A lazy-only index.js (stock stimulus-loading, no eagerLoadControllersFrom + # import) has no eager anchor to inject after. Appending the eager REGISTER_LINE + # would call eagerLoadControllersFrom with no import — a ReferenceError that + # aborts the module and registers ZERO controllers. Warn instead. + build_skeleton(stimulus_index: false) + write("app/javascript/controllers/index.js", <<~JS) + import { application } from "controllers/application" + import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" + lazyLoadControllersFrom("controllers", application) + JS + + run_generator + + index = read("app/javascript/controllers/index.js") + expect(index).not_to include("eagerLoadControllersFrom") + end end describe "bin/build-css" do diff --git a/spec/generators/page_generator_spec.rb b/spec/generators/page_generator_spec.rb index b542ce9..174708a 100644 --- a/spec/generators/page_generator_spec.rb +++ b/spec/generators/page_generator_spec.rb @@ -138,6 +138,20 @@ def silence_stream expect(read("app/models/doc.rb")).to include(%(view: "OauthGuide")) end + it "omits a redundant --slug that equals the derived default from the registry line" do + run_generator(["Getting Started"], { "group" => "Guide", "slug" => "getting-started" }) + + expect(read("app/models/doc.rb")).to include(%(page "Getting Started", group: "Guide"\n)) + expect(read("app/models/doc.rb")).not_to include(%(slug: "getting-started")) + end + + it "omits a redundant --view that equals the derived default from the registry line" do + run_generator(["Getting Started"], { "group" => "Guide", "view" => "GettingStarted" }) + + expect(read("app/models/doc.rb")).to include(%(page "Getting Started", group: "Guide"\n)) + expect(read("app/models/doc.rb")).not_to include(%(view: "GettingStarted")) + end + it "respects --eyebrow over the group default" do run_generator(["Getting Started"], { "group" => "Guide", "eyebrow" => "Start here" })