From 98617adb5f7875c11d07c9c4d038a551dfca0cd3 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 11:20:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(page):=20every=20page=20renders=20as=20Mar?= =?UTF-8?q?kdown=20=E2=80=94=20.md=20format=20+=20copy-page=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Derives a faithful GFM Markdown twin of every doc page FROM the page's own render — authors write nothing extra, and the twin can never drift from the rendered page the way a hand-written `to_text` copy does. This is the machine-readable layer llms.txt / search / MCP build on. - `GET /docs/x.md` returns GFM of exactly what `/docs/x` shows. - A "Markdown" masthead action links to the `.md` (JS-off: opens raw markdown; JS-on: docs-nav enhances the click into copy-to-clipboard). ## How We emit the HTML ourselves, so the tag vocabulary is bounded and a small hand-rolled Nokogiri visitor converts it exactly (Phlex, `md` islands, and raw tags in Prose all convert identically — conversion happens post-render). - `DocsKit::MarkdownExport` (+ Blocks/Inline/Table visitors): render the page, extract `#docs-content`, strip `[data-md-skip]`, walk to GFM (h1–h4, p, a with relative-href absolutization, strong/em/code, fenced code w/ language, nested lists, GFM tables, `> **Tip:**` callouts, hr, img). - Render-time hints drive the tricky cases: Code stamps `data-md-lang` (resolved Rouge tag → fence language), Callout stamps `data-md-callout` (level → label), Shell stamps `#docs-content` (extraction anchor), Page's "← Home" nav gets `data-md-skip`. - `DocsKit::Controller#render_page` branches on `request.format.md?`/`.text?`; the engine registers the `text/markdown` MIME; the install generator route allows the `(.:format)` segment. - `DocsUI::MarkdownAction` + a `copyMarkdown` action/`markdownLink` target on the ONE docs-nav controller (single-controller rule holds). Config `c.page_markdown_action` (default true) opts the affordance out. - nokogiri added to the gemspec (already present in Rails hosts). ## Test Coverage - markdown_export_spec: semantic asserts over the full vocabulary — heading levels, fenced language, absolutized links, nested-list indent, GFM table, `> **Tip:**`, chrome/[data-md-skip]/script excluded, full-page acceptance. - controller_spec: .md/.text → text/markdown twin; html → layout: false. - code_spec/callout_spec: data-md-lang / data-md-callout hints. - markdown_action_spec: .md href (query-safe, idempotent) + docs-nav wiring. - configuration_spec: page_markdown_action default + override. - install_generator_spec: route allows (.:format), no format: html pin. ## Verification - [x] bundle exec rspec — 297 examples, 0 failures (95.23% coverage) - [x] bundle exec rubocop — no offenses - [x] Dogfood: realistic page → clean GFM (fences/tables/nested lists/links) - [x] docs-nav JS: node --check passes; copyMarkdown degrades to plain link - [x] Backwards compatible: no .md route match → HTML rendering untouched Closes #17 --- README.md | 38 +- app/components/docs_ui/callout.rb | 11 +- app/components/docs_ui/code.rb | 8 +- app/components/docs_ui/markdown_action.rb | 45 +++ app/components/docs_ui/page.rb | 13 +- app/components/docs_ui/shell.rb | 5 +- .../controllers/docs_nav_controller.js | 40 ++ docs-kit.gemspec | 4 + lib/docs_kit/configuration.rb | 7 + lib/docs_kit/controller.rb | 26 ++ lib/docs_kit/engine.rb | 8 + lib/docs_kit/markdown_export.rb | 92 +++++ lib/docs_kit/markdown_export/blocks.rb | 155 ++++++++ lib/docs_kit/markdown_export/inline.rb | 86 +++++ lib/docs_kit/markdown_export/table.rb | 46 +++ .../docs_kit/install/install_generator.rb | 6 +- .../install/templates/docs_kit.rb.erb | 6 + spec/docs_kit/configuration_spec.rb | 12 + spec/docs_kit/controller_spec.rb | 90 +++++ spec/docs_kit/markdown_export_spec.rb | 342 ++++++++++++++++++ spec/docs_ui/callout_spec.rb | 39 ++ spec/docs_ui/code_spec.rb | 25 ++ spec/docs_ui/markdown_action_spec.rb | 41 +++ spec/generators/install_generator_spec.rb | 11 +- 24 files changed, 1147 insertions(+), 9 deletions(-) create mode 100644 app/components/docs_ui/markdown_action.rb create mode 100644 lib/docs_kit/markdown_export.rb create mode 100644 lib/docs_kit/markdown_export/blocks.rb create mode 100644 lib/docs_kit/markdown_export/inline.rb create mode 100644 lib/docs_kit/markdown_export/table.rb create mode 100644 spec/docs_kit/controller_spec.rb create mode 100644 spec/docs_kit/markdown_export_spec.rb create mode 100644 spec/docs_ui/callout_spec.rb create mode 100644 spec/docs_ui/markdown_action_spec.rb 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