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}]

Rendered.

" + 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