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/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/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; #
/barerun a`b now.
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(
+ "| A | B | |
|---|---|---|
| 1 | 2 | 3 |
))
+ 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(" element.
+ expect(html).not_to include("")
+ 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("")
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" })