diff --git a/README.md b/README.md
index 7bbf351..7ce1a10 100644
--- a/README.md
+++ b/README.md
@@ -31,9 +31,11 @@ A `DocsUI::` Phlex kit, configured once per site:
| `DocsUI::RequestExample` | One request declaration → one code tab per configured client (curl / JS / Ruby / Python by default). |
| `DocsUI::JsonResponse` | A Ruby Hash (or String) rendered as a pretty-printed JSON response block. |
| `DocsUI::Example` | Base for a live example with `method_source`-extracted source. |
+| `DocsUI::MarkdownAction` | The "Markdown" masthead action → the page's `.md` twin; `docs-nav` enhances it into copy-to-clipboard. |
Plus `DocsKit::Registry` (in-memory docs registry mixin), `DocsKit::NavItem`
-(sidebar link value object), and `DocsKit::Controller#render_page`.
+(sidebar link value object), `DocsKit::MarkdownExport` ([every page as
+Markdown](#every-page-is-also-markdown)), and `DocsKit::Controller#render_page`.
## Install
@@ -263,6 +265,40 @@ this page" TOC still come from `DocsUI::Section`** — keep section titles as
`Section`, and use Markdown headings only for sub-headings inside a section. Raw
HTML in the Markdown source is dropped (no `
safe
")
+
+ expect(md).to include("safe")
+ expect(md).not_to include("alert(1)")
+ expect(md).not_to include("color:red")
+ end
+ end
+
+ describe "the full-page fixture (issue acceptance)" do
+ let(:page) do
+ docs_content do
+ render DocsUI::Section.new("Getting started") do
+ render DocsUI::Markdown.new("Install with **bundler**, then run the [server](/start).")
+ render DocsUI::Code.new("bundle add docs-kit", lexer: :ruby)
+ render DocsUI::Callout.new(:tip) { "Restart after editing config." }
+ render DocsUI::PropTable.new([["brand", "String", '"Docs"', "Heading."]])
+ end
+ end
+ end
+
+ it "produces faithful GFM covering every vocabulary element" do
+ md = to_md(page, base_url: "https://acme.dev")
+
+ # One realistic page, every vocabulary element at once — aggregated so a
+ # single failure still reports which element regressed.
+ aggregate_failures do
+ expect(md).to include("## Getting started")
+ expect(md).to include("**bundler**")
+ expect(md).to include("[server](https://acme.dev/start)")
+ expect(md).to include("```ruby")
+ expect(md).to include("bundle add docs-kit")
+ expect(md).to include("> **Tip:**")
+ expect(md).to include("| Option | Type | Default | Description |")
+ end
+ end
+ end
+
+ it "renders through a view context when one is given" do
+ # The production path renders the Phlex view WITH a Rails view context (CSRF,
+ # url helpers). Here we prove the seam: a view whose #call takes view_context:.
+ view = Class.new do
+ def call(view_context: nil)
+ "[ctx:#{view_context}]"
+ end
+ end.new
+
+ md = described_class.new(view, view_context: "VC").to_md
+
+ expect(md).to include("Rendered.")
+ end
+
+ it "returns empty string when there is no #docs-content region" do
+ page = Class.new(Phlex::HTML) do
+ def view_template = div(class: "no-anchor") { p { "orphan" } }
+ end.new
+
+ expect(described_class.new(page).to_md).to eq("")
+ end
+end
diff --git a/spec/docs_ui/callout_spec.rb b/spec/docs_ui/callout_spec.rb
new file mode 100644
index 0000000..d7ba1c2
--- /dev/null
+++ b/spec/docs_ui/callout_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+RSpec.describe DocsUI::Callout do
+ it "renders a daisyUI alert with the level's styling and icon" do
+ html = described_class.new(:warning) { "Restart the server." }.call
+
+ expect(html).to include("alert-warning")
+ expect(html).to include("Restart the server.")
+ end
+
+ it "renders the title when given" do
+ html = described_class.new(:tip, title: "Heads up") { "body" }.call
+
+ expect(html).to include("Heads up")
+ end
+
+ # The Markdown export (DocsKit::MarkdownExport) turns a callout into a
+ # `> **Tip:** …` blockquote. It reads the level off data-md-callout so the
+ # converter needn't reverse-engineer it from the alert-* class.
+ describe "data-md-callout (the Markdown-export blockquote hint)" do
+ it "stamps the level name on the alert" do
+ html = described_class.new(:tip) { "A helpful tip." }.call
+
+ expect(html).to include('data-md-callout="tip"')
+ end
+
+ it "stamps note for the default level" do
+ html = described_class.new { "Just a note." }.call
+
+ expect(html).to include('data-md-callout="note"')
+ end
+
+ it "falls back to note for an unknown level" do
+ html = described_class.new(:bogus) { "text" }.call
+
+ expect(html).to include('data-md-callout="note"')
+ end
+ end
+end
diff --git a/spec/docs_ui/code_spec.rb b/spec/docs_ui/code_spec.rb
index a1747b6..43c86a8 100644
--- a/spec/docs_ui/code_spec.rb
+++ b/spec/docs_ui/code_spec.rb
@@ -98,6 +98,31 @@ def csp_nonce = "testnonce"
expect(html).to include("font-mono")
end
+ # The Markdown export (DocsKit::MarkdownExport) reads the resolved Rouge lexer
+ # tag off the highlight wrapper to emit a ```lang fenced block. Code stamps it
+ # as data-md-lang so the converter never has to re-resolve the language.
+ describe "data-md-lang (the Markdown-export fence hint)" do
+ it "stamps the resolved lexer tag on the highlight wrapper" do
+ html = described_class.new("puts 'hi'", lexer: :ruby).call
+
+ expect(html).to include('data-md-lang="ruby"')
+ end
+
+ it "reflects the actual resolved language, not the requested alias" do
+ DocsKit.configure { |c| c.code_lexer_aliases = { fancy: "ruby" } }
+ html = described_class.new("puts 'hi'", lexer: :fancy).call
+
+ # The alias resolves to ruby — the hint carries the real Rouge tag.
+ expect(html).to include('data-md-lang="ruby"')
+ end
+
+ it "stamps plaintext when the language is unknown (fence stays language-less)" do
+ html = described_class.new("anything", lexer: :nope).call
+
+ expect(html).to include('data-md-lang="plaintext"')
+ end
+ end
+
it "falls back to plaintext for an unknown lexer" do
html = described_class.new("anything", lexer: :nope).call
diff --git a/spec/docs_ui/markdown_action_spec.rb b/spec/docs_ui/markdown_action_spec.rb
new file mode 100644
index 0000000..984e5ff
--- /dev/null
+++ b/spec/docs_ui/markdown_action_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+RSpec.describe DocsUI::MarkdownAction do
+ def render_action(path)
+ described_class.new(path).call
+ end
+
+ it "renders a link to the page's .md twin" do
+ html = render_action("/docs/installation")
+
+ expect(html).to include('href="/docs/installation.md"')
+ end
+
+ it "keeps an existing query string but points the path at .md" do
+ html = render_action("/docs/installation?x=1")
+
+ expect(html).to include('href="/docs/installation.md?x=1"')
+ end
+
+ it "does not double-append .md when the path already ends in .md" do
+ html = render_action("/docs/installation.md")
+
+ expect(html).to include('href="/docs/installation.md"')
+ expect(html).not_to include(".md.md")
+ end
+
+ # JS-OFF: the plain link opens the raw Markdown. JS-ON: docs-nav enhances the
+ # click into copy-to-clipboard (a target + action on the ONE controller).
+ it "wires the docs-nav copy enhancement (target + action) so the one controller can enhance it" do
+ html = render_action("/docs/x")
+
+ expect(html).to include('data-docs-nav-target="markdownLink"')
+ expect(html).to include("docs-nav#copyMarkdown")
+ end
+
+ it "labels the affordance 'Markdown'" do
+ html = render_action("/docs/x")
+
+ expect(html).to include("Markdown")
+ end
+end
diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb
index e7d1fda..b60b0ac 100644
--- a/spec/generators/install_generator_spec.rb
+++ b/spec/generators/install_generator_spec.rb
@@ -138,9 +138,18 @@ def silence_stream
it "adds the docs and root routes" do
routes = read("config/routes.rb")
- expect(routes).to include(%(get "docs/:doc" => "docs#show", as: :doc))
+ expect(routes).to include(%(get "docs/:doc(.:format)" => "docs#show", as: :doc))
expect(routes).to include(%(root "landings#show"))
end
+
+ it "allows an optional .:format on the docs route (so /docs/x.md serves the twin)" do
+ routes = read("config/routes.rb")
+
+ # The Markdown twin (GET /docs/x.md) needs the format segment enabled. The
+ # docs route explicitly opts it in and must NOT pin format: 'html'.
+ expect(routes).to include("(.:format)")
+ expect(routes).not_to match(/defaults:\s*\{\s*format:/)
+ end
end
describe "controller injection (include_controller_helper)" do